Skip to content

feat: add Clipboard text/html write API#23615

Draft
Artur- wants to merge 1 commit into
mainfrom
feature/clipboard
Draft

feat: add Clipboard text/html write API#23615
Artur- wants to merge 1 commit into
mainfrom
feature/clipboard

Conversation

@Artur-
Copy link
Copy Markdown
Member

@Artur- Artur- commented Feb 21, 2026

Public surface (com.vaadin.flow.component.clipboard):
Clipboard.on(ClickNotifier) // click trigger sugar
Clipboard.on(Trigger) // any custom trigger

ClipboardBinding verbs (each in fire-and-forget + observed flavours):
copyTextFrom(String | HasValue+Component | Action.Input)
copyHtmlFrom(String | Action.Input)
copyFrom(ClipboardContent) // multi-format ClipboardItem

The observed flavours take onSuccess (SerializableRunnable) and onError
(SerializableConsumer) consumers, dispatched on the UI thread by
PromiseAction. Both are required in the observed form — pass () -> {} or
err -> {} to opt out of one.

Internals (com.vaadin.flow.component.trigger.internal, alongside the
framework's other actions because JsBuilder is package-private):

  • ClipboardCopyAction extends PromiseAction; emits
    navigator.clipboard.write([new ClipboardItem({...})]) with any
    combination of text/plain and text/html slots.

Tests verify the generated JsFunction body (e.g. "text/plain":"Hello",
$0["value"]), the trigger entry points (click vs. custom DOM event), and
that empty ClipboardContent rejects.

Image copy and clipboard availability are intentionally split into
follow-up commits; active reads and paste/copy/cut event capture are
not yet exposed (they need a typed server-callback primitive queued for
trigger-step3).

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 21, 2026

Test Results

1 210 files   -   203  1 210 suites   - 203   1h 15m 10s ⏱️ - 8m 47s
7 695 tests  - 2 287  7 644 ✅  - 2 269  48 💤  - 21  3 ❌ +3 
8 170 runs   - 2 287  8 117 ✅  - 2 269  50 💤  - 21  3 ❌ +3 

For more details on these failures, see this check.

Results for commit c07ac1f. ± Comparison against base commit 9b240a3.

♻️ This comment has been updated with latest results.

@sonarqubecloud
Copy link
Copy Markdown

@mcollovati
Copy link
Copy Markdown
Collaborator

mcollovati commented Mar 5, 2026

I did a test with the view at https://gist.github.com/mcollovati/0090b03502eacc0bf96a4336612a2424

Seems to work fine, except for copy and cut listeners; in the example view, copied/cut text is never sent to the server. Could it be because it should be read from document.getSelection() on the client side?

EDIT: update the gist with a workaround for copy/cut listeners

@mcollovati mcollovati mentioned this pull request Mar 31, 2026
@Artur- Artur- mentioned this pull request Apr 14, 2026
21 tasks
Artur- added a commit to vaadin/use-cases that referenced this pull request May 11, 2026
Adds a new use-cases module mirroring the geolocation/signals layout,
covering the Clipboard API requirements from vaadin/platform#8759 and
the Flow PR vaadin/flow#23615:

  UC1 — copy static text on click (with success/error callbacks)
  UC2 — copy current value of a component
  UC3 — copy image
  UC4 — paste text and HTML
  UC5 — paste files
  UC6 — copy via a context-menu item
  UC7 — detect availability and degrade gracefully
@mshabarov
Copy link
Copy Markdown
Contributor

Tested with the https://clipboard-cases.fly.dev - some use cases don't work:

Use Case Chrome Firefox Safari
Copy Image Says image copied, but in fact clipboard is empty Copy failed Copy failed
Paste text/HTML Paste works, but HTML preview doesn't render html Same Same
Availability Available even thought disabled in settings Same Couldn't find a setting to disable copying from clipboard

@Artur-
Copy link
Copy Markdown
Member Author

Artur- commented May 12, 2026

There was an issue where the newest version of the use cases app wasn't auto deployed: vaadin/use-cases#235. I think most of the mentioned issues are not in that app but it still makes sense to check

Copy link
Copy Markdown
Contributor

@mshabarov mshabarov left a comment

Choose a reason for hiding this comment

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

First round of review, focus on implementation.

Comment on lines +74 to +76
public byte[] getData() {
return data;
}
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.

The method's docs say do not modify, but the array is returned as-is. A copy could be returned or an immutable object.

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.

Also makes sense to add getInputStream (get data as an input stream).

* @throws NullPointerException
* if {@code target} or {@code listener} is {@code null}
*/
public static Registration addPasteListener(Component target,
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.

If I do

  Registration r1 = Clipboard.addPasteListener(target, l1);
  Registration r2 = Clipboard.addPasteListener(target, l2);  // overwrites attribute
  r1.remove();                                                                             // removes attribute

So r2's listener still fires, but files never upload anymore because the attribute was removed.
Maybe we should throw in this case and support only one listener per target component?

* @throws NullPointerException
* if any argument is {@code null}
*/
public static Registration addPasteListener(Component target,
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.

Similar issue here - two overloads of addPasteListener cannot be used simultaneously because they share the same attribute.

* @throws NullPointerException
* if {@code trigger} is {@code null}
*/
public static ClipboardCopy copyOnClick(Component trigger, String text) {
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.

Similar problem as for paste listener:

  ClipboardCopy h1 = Clipboard.copyOnClick(btn, "A");
  ClipboardCopy h2 = Clipboard.copyOnClick(btn, "B");  // h1 silently superseded
  h1.remove();                                        // tears down h2's handler
  // h2 looks active but clicks no longer copy anything

because one slot per element in used in JavaScript so these two objects/handlers have dependencies to each other.

UploadHandler uploadHandler, @Nullable PasteState pasteState,
SerializableConsumer<ClipboardEvent> listener) {
Element element = target.getElement();
element.setAttribute("__clipboard-paste-upload", uploadHandler);
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.

Minor: custom attributes are usually data-*. Using data-clipboard-paste-upload would be more idiomatic and won't surprise anyone inspecting the DOM, this convention is used somewhere else in the Flow already. The double-underscore convention seems borrowed from the __clipboardText property (which is fine because it's a JS property, not an HTML attribute).

state.clickHandler = async () => {
const src = (state.image as HTMLImageElement | undefined)?.src;
if (!src) {
return;
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.

Maybe worth to log an error/debug message with concole.debug here if an image has no src ?

Copy link
Copy Markdown
Contributor

@mshabarov mshabarov left a comment

Choose a reason for hiding this comment

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

Several API-related comments

* @throws NullPointerException
* if {@code trigger} or {@code imageSource} is {@code null}
*/
public static ClipboardCopy copyImageOnClick(Component trigger,
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.

Text copy variant has no "text" in the name. Every new content type forces a new top-level method (e.g. copyHtmlOnClick, copyFileOnClick). This can be improved by grouping copy sources into categories:

  Clipboard.on(button).copy(String);
  Clipboard.on(button).copyValueOf(HasValue);
  Clipboard.on(button).copyImageOf(Image);

also taking into account that a trigger is always needed.

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.

Feels to me that we can simply put an Image as an argument instead of Component.

But what about IFrame and HtmlObject, shall we allow them to copy their image to clipboard?

*/
public static ClipboardCopy copyImageOnClick(Component trigger,
Component imageSource, @Nullable Command onSuccess,
@Nullable Command onError) {
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.

onError is just a command without any information about an error. This could give error code, clipboard availability, exception type, message.

* registered for success/error callbacks. Idempotent.
*/
@Override
public void remove() {
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.

I couldn't find a good example of unregistering from "copy to clipboard" events - these are permanent in most of the cases I think until they need an explicit clean up upon detach. The use-case project doesn't use this at all, that's a sign.

On the other hand, this method looks very useful

var copy = Clipboard.copyOnClick(button, link);
// later, when the share URL is regenerated server-side:
copy.setText(newLink);

but this isn't used in the use cases either.

Comment on lines +74 to +76
public byte[] getData() {
return data;
}
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.

Also makes sense to add getInputStream (get data as an input stream).

* cut events typically carry only text and HTML — files are paste-only on the
* browser side.
*/
public final class ClipboardEvent implements Serializable {
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.

Could replace hasXyz() and getXyz() methods with Optional<XyzType> xyz() or Optional<XyzType> getXyz(), e.g.Optional<String> text().

* @throws NullPointerException
* if {@code target} or {@code listener} is {@code null}
*/
public static Registration addPasteListener(Component target,
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.

I noticed in the use cases examples that the drop target uses dropZone.getElement().setAttribute("tabindex", "0"); to make it focusible otherwise clipboard paste won't work, maybe we should add it automatically?

* this value is essentially never observed in practice; once a real value
* has arrived, the signal never returns to {@code UNKNOWN}.
*/
UNKNOWN
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.

Could we consider merging UNSUPPORTED and UNKNOWN into one (and maybe turn the enum into boolean) as I assume a code in the projects would perhaps always be like:

if (clipboardAvailability == AVAILABLE) {
 myComponent.setEnabled(true);
} else {
 logger.trace("Clipboard is not available for user " + user);
 myComponent.setEnabled(false);
}

Comment on lines +220 to +221
public static ClipboardCopy copyOnClick(Component trigger,
Component source) {
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.

Can/should this be

copyOnClick(Component trigger, HasValue<?, ?> source)

* @throws NullPointerException
* if {@code trigger} or {@code source} is {@code null}
*/
public static ClipboardCopy copyOnClick(Component trigger,
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.

Does it make sense to provide an overload that would allows to give a function

  public static <C extends Component> ClipboardCopy copyOnClick(
          Component trigger, C source,
          SerializableFunction<C, String> textProvider)

so that I can set up a custom text


  TextField customer = new TextField("Customer ID");
  Button copy = new Button("Copy link");

  Clipboard.copyOnClick(copy, customer,
          field -> "https://crm.example.com/customer/" + field.getValue());

@mshabarov
Copy link
Copy Markdown
Contributor

What if we introduce Trigger level that can do a copying with a chosen user action, not only on button click:

  1. Primary way — separate Copy button
  TextField shareLink = new TextField("Share link");
  shareLink.setValue("https://example.com/share/abc123");
  shareLink.setReadOnly(true);
  shareLink.setWidthFull();

  Button copyBtn = new Button("Copy", VaadinIcon.COPY.create());

  Clipboard.copyValueOf(shareLink)
          .on(Trigger.click(copyBtn));

   or with callbacks (even though Geolocation does have them as method arguments):

   Clipboard.copyValueOf(shareLink)
          .on(Trigger.click(copyBtn))
          .onSuccess(() -> Notification.show("Link copied"))
          .onError(err -> Notification.show("Copy failed: " + err.reason()));

  add(new HorizontalLayout(shareLink, copyBtn));
  1. Secondary way — context-menu item on a target
 Pre snippet = new Pre("""
         SELECT id, email, created_at
         FROM users
         WHERE active = true
         ORDER BY created_at DESC;
         """);
 snippet.addClassName("code-block");

 Button copyBtn = new Button("Copy SQL");

 Clipboard.copyText(snippet.getText())
         .on(Trigger.click(copyBtn))                            // primary trigger
         .on(Trigger.contextMenu(snippet, "Copy SQL"))          // additive: right-click on the block
         .onSuccess(() -> Notification.show("SQL copied"));

 add(snippet, copyBtn);
  1. Chip-style — single click on the content itself
String commitSha = "9f8e7a6b4c2d1e0f3a5b7c9d2e4f6a8b";

Span shaChip = new Span(commitSha.substring(0, 7));
shaChip.addClassName("sha-chip");
shaChip.getElement().setAttribute("title", "Click to copy " + commitSha);
shaChip.getStyle()
        .set("cursor", "pointer")
        .set("font-family", "monospace")
        .set("padding", "2px 8px")
        .set("background", "var(--aura-contrast-10pct)")
        .set("border-radius", "var(--aura-border-radius-s)");

Clipboard.copyText(commitSha)
        .on(Trigger.contentClick(shaChip))
        .onSuccess(() -> Notification.show("SHA copied"));

add(shaChip);

@mshabarov
Copy link
Copy Markdown
Contributor

About the shortcut trigger, I'm not sure we should provide it for clipboard, even though the trigger itself makes sense for other features. Two reasons: browsers react to Ctrl+C/Cmd+C already when you have selection on the page; a better way is to select and focus an element or text in an element that then could be copied by browser native shortcut.

Comment on lines +35 to +40
/** Event type identifier for paste events. */
public static final String PASTE = "paste";
/** Event type identifier for copy events. */
public static final String COPY = "copy";
/** Event type identifier for cut events. */
public static final String CUT = "cut";
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.

These could be enumeration values?


private final String type;
private final @Nullable String text;
private final @Nullable String html;
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.

I wonder how html is more special than just a text string? Maybe we can remove it?

@Artur- Artur- force-pushed the feature/clipboard branch 3 times, most recently from 16cb109 to 79fdb13 Compare May 21, 2026 15:20
@Artur- Artur- changed the title feat: add Clipboard API for browser clipboard access feat: add Clipboard write API on top of the trigger framework May 21, 2026
@Artur- Artur- force-pushed the feature/clipboard branch from 79fdb13 to c07ac1f Compare May 22, 2026 05:43
@Artur- Artur- changed the title feat: add Clipboard write API on top of the trigger framework feat: add Clipboard text/html write API May 22, 2026
@Artur-
Copy link
Copy Markdown
Member Author

Artur- commented May 22, 2026

Rewrote this PR to be much smaller and more focused - now only handles text/html copying and we can do the rest in followup PRs

@sonarqubecloud
Copy link
Copy Markdown

Public surface (com.vaadin.flow.component.clipboard):
  Clipboard.on(ClickNotifier)              // click trigger sugar
  Clipboard.on(Trigger)                    // any custom trigger

ClipboardBinding verbs (each in fire-and-forget + observed flavours):
  copyTextFrom(String | HasValue+Component | Action.Input<String>)
  copyHtmlFrom(String | Action.Input<String>)
  copyFrom(ClipboardContent)               // multi-format ClipboardItem

The observed flavours take onSuccess (SerializableRunnable) and onError
(SerializableConsumer<String>) consumers, dispatched on the UI thread by
PromiseAction. Both are required in the observed form — pass () -> {} or
err -> {} to opt out of one.

Internals (com.vaadin.flow.component.trigger.internal, alongside the
framework's other actions because JsBuilder is package-private):
  - ClipboardCopyAction extends PromiseAction; emits
    `navigator.clipboard.write([new ClipboardItem({...})])` with any
    combination of text/plain and text/html slots.

Tests verify the generated JsFunction body (e.g. "text/plain":"Hello",
$0["value"]), the trigger entry points (click vs. custom DOM event), and
that empty ClipboardContent rejects.

Image copy and clipboard availability are intentionally split into
follow-up commits; active reads and paste/copy/cut event capture are
not yet exposed (they need a typed server-callback primitive queued for
trigger-step3).
@Artur- Artur- force-pushed the feature/clipboard branch from c07ac1f to 7f5f607 Compare May 22, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: 🔎Iteration reviews

Development

Successfully merging this pull request may close these issues.

3 participants