Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions flow-client/src/main/frontend/Clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/

/**
* Textual clipboard payload sent to the server, matching the Java
* ClipboardPayload record.
*/
interface VaadinClipboardPayload {
text: string | null;
html: string | null;
Comment on lines +22 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future we may want to add more content types (already have plans for images), this can bring incompatible changes. But not blocking this intermediate low level PR I think.

}

/**
* Reads the first item from the system clipboard and returns its text/plain
* and text/html representations. Either field is {@code null} if the
* corresponding MIME type is not present.
*
* The caller is expected to be inside a transient user gesture and to have
* been granted the {@code clipboard-read} permission; otherwise
* {@code navigator.clipboard.read} rejects and this function propagates the
* rejection.
*/
async function readClipboardPayload(): Promise<VaadinClipboardPayload | null> {
const items = await navigator.clipboard.read();
if (!items.length) {
return null;
}
const item = items[0];
const get = async (type: string): Promise<string | null> =>
item.types.includes(type) ? (await item.getType(type)).text() : null;
return {
text: await get('text/plain'),
html: await get('text/html')
};
}

const $wnd = window as any;
$wnd.Vaadin ??= {};
$wnd.Vaadin.Flow ??= {};
$wnd.Vaadin.Flow.clipboard = {
readPayload: readClipboardPayload
};

// Empty export to ensure TypeScript emits this as an ES module,
// which is required for Vite to load it via import.
export {};
1 change: 1 addition & 0 deletions flow-client/src/main/frontend/Flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type ConnectionStateChangeListener,
type ConnectionStateStore
} from '@vaadin/common-frontend';
import './Clipboard';
import './Geolocation';
import { currentVisibility } from './PageVisibility';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.trigger.internal;

import java.io.Serializable;

import org.jspecify.annotations.Nullable;

/**
* Textual clipboard contents delivered to {@link ClipboardReadAction}'s
* handler. Either field may be {@code null} if the corresponding MIME type was
* not present on the clipboard item.
* <p>
* For internal use only. May be renamed or removed in a future release.
*
* @param text
* {@code text/plain} contents, or {@code null} if not present
* @param html
* {@code text/html} contents, or {@code null} if not present
*/
public record ClipboardPayload(@Nullable String text,
@Nullable String html) implements Serializable {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.trigger.internal;

import java.util.Objects;

import org.jspecify.annotations.Nullable;

import com.vaadin.flow.function.SerializableConsumer;

/**
* Reads the user's clipboard via {@code navigator.clipboard.read()} when the
* bound trigger fires and delivers the textual contents to the handler on the
* UI thread.
* <p>
* The Clipboard API requires the call to happen inside a short-lived user
* gesture (click, key press, …) AND the user to grant the
* {@code clipboard-read} permission — without both, the browser rejects the
* read. Bind this action to a {@link Trigger} that fires during such a gesture,
* typically a {@link ClickTrigger}.
* <p>
* The handler receives a {@link ClipboardPayload} with the {@code text/plain}
* and {@code text/html} representations of the first clipboard item, or
* {@code null} if the read fails for any reason (permission denied, no item,
* unsupported browser, …). Error detail is intentionally not exposed — see
* {@link PromiseAction}'s subclasses for the success/error-split shape if that
* distinction matters.
*
* <pre>{@code
* new ClickTrigger(pasteButton).triggers(new ClipboardReadAction(payload -> {
* if (payload == null) {
* notification.show("Clipboard read denied");
* } else {
* editor.setValue(
* payload.html() != null ? payload.html() : payload.text());
* }
* }));
* }</pre>
*
* For internal use only. May be renamed or removed in a future release.
*/
public class ClipboardReadAction extends Action {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One possible potential improvement could be if ClipboardReadAction would extend PromiseAction and PromiseAction would have a generic type. JsonNode then would not leak into app code through a Success wrapper; every subclass won't write an adapter to unwrap it.

What would get better:

  • PromiseAction — payload type is generic; framework Jackson-decodes once, onSuccess is Consumer<@nullable T>.
  • Success is gone — one fewer wrapper.
  • Subclasses drop their adapters — CopyTextToClipboardAction extends PromiseAction; RequestFullscreenAction extends PromiseAction.
  • Type-safe at the API surface — the compiler enforces the payload shape; JsonNode never leaks.
  • One pattern for value-returning promises — future built-ins (clipboard read, share, file picker) reuse PromiseAction instead of inventing adapters.

Then:
ClipboardReadAction extends PromiseAction<ClipboardPayload>
CopyTextToClipboardAction extends PromiseAction<String>
RequestFullscreenAction extends PromiseAction<Void>

And there one "but": this is an internal API so far, so no high priority and maybe no need for now - just something to think about in the code design.


private final SerializableConsumer<@Nullable ClipboardPayload> handler;

/**
* Creates an action that reads the user's clipboard and delivers the
* contents to {@code handler}.
*
* @param handler
* invoked on the UI thread with the clipboard contents, or
* {@code null} if the read failed; not {@code null}
*/
public ClipboardReadAction(
SerializableConsumer<@Nullable ClipboardPayload> handler) {
this.handler = Objects.requireNonNull(handler,
"handler must not be null");
}

@Override
protected void appendStatement(JsBuilder builder, StringBuilder out) {
String cb = builder.callback(ClipboardPayload.class, handler);
// The actual clipboard read + {text, html} extraction lives in
// Clipboard.ts (window.Vaadin.Flow.clipboard.readPayload); this
// Action just routes its resolved value (or null on any failure)
// to the typed server callback.
out.append("window.Vaadin.Flow.clipboard.readPayload()")
.append(".then(p=>").append(cb).append("(p))")
.append(".catch(()=>").append(cb).append("(null))");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,14 @@
import java.util.Map;

import org.jspecify.annotations.Nullable;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;

import com.vaadin.flow.dom.Element;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JacksonUtils;
import com.vaadin.flow.internal.nodefeature.ReturnChannelMap;
import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration;

/**
* Collects element references and produces JS placeholders for them while a
Expand Down Expand Up @@ -96,6 +101,66 @@ String capture(@Nullable Object value) {
return ref;
}

/**
* Allocates a {@code $N} placeholder for a server-side callback function: a
* JS function {@code (payload) => …} that, when called from client JS,
* deserialises its first argument to {@code T} via Jackson and invokes
* {@code handler} on the UI thread.
* <p>
* Use this from {@link Action#appendStatement} when an action needs to ship
* a typed payload back from the browser — async API outcomes, extracted
* event data, anything Jackson can deserialise.
* <p>
* Each call registers a fresh {@link ReturnChannelRegistration} on the
* trigger's host node; channels are not deduplicated across calls.
*
* @param payloadType
* type to deserialise the first JS argument into; never
* {@code null}
* @param handler
* invoked on the UI thread with the deserialised payload, or
* {@code null} if the JS argument is {@code null}/missing; never
* {@code null}
* @return the JS placeholder ({@code "$N"}) referencing the callback
* @param <T>
* payload type
*/
<T> String callback(Class<T> payloadType,
SerializableConsumer<@Nullable T> handler) {
return capture(registerChannel(arg -> handler.accept(arg == null ? null
: JacksonUtils.readValue(arg, payloadType))));
}

/**
* Like {@link #callback(Class, SerializableConsumer)} but accepts a
* {@link TypeReference} so the payload type can be a parameterised type
* (e.g. {@code new TypeReference<List<Foo>>(){}}).
*
* @param payloadType
* type reference to deserialise the first JS argument into;
* never {@code null}
* @param handler
* invoked on the UI thread with the deserialised payload, or
* {@code null} if the JS argument is {@code null}/missing; never
* {@code null}
* @return the JS placeholder ({@code "$N"}) referencing the callback
* @param <T>
* payload type
*/
<T> String callback(TypeReference<T> payloadType,
SerializableConsumer<@Nullable T> handler) {
return capture(registerChannel(arg -> handler.accept(arg == null ? null
: JacksonUtils.readValue(arg, payloadType))));
}

private ReturnChannelRegistration registerChannel(
SerializableConsumer<@Nullable JsonNode> dispatcher) {
return trigger.getHost().getNode().getFeature(ReturnChannelMap.class)
.registerChannel(args -> dispatcher
.accept(args.isEmpty() || args.get(0).isNull() ? null
: args.get(0)));
}

/**
* Returns the captures collected by this builder, in the order they were
* first referenced — these become the captures of the handler
Expand Down
Loading
Loading