diff --git a/flow-client/src/main/frontend/Clipboard.ts b/flow-client/src/main/frontend/Clipboard.ts index cfd0cc27210..43fcb8da60c 100644 --- a/flow-client/src/main/frontend/Clipboard.ts +++ b/flow-client/src/main/frontend/Clipboard.ts @@ -48,27 +48,81 @@ async function readClipboardPayload(): Promise { } /** - * Writes the given text/plain and/or text/html representations to the system - * clipboard as a single ClipboardItem. Either argument may be {@code null} to - * omit that MIME type; at least one is expected to be non-null (the caller - * enforces this). + * Re-encodes the given {@code } as {@code image/png} via a canvas + * round-trip. The source can be any rasterisable format the browser already + * decodes ({@code image/png}, {@code image/jpeg}, {@code image/svg+xml}, ...); + * the output is always a {@code Promise} of {@code image/png}, the only + * image MIME type every browser's asynchronous Clipboard API accepts on write. + * + * Cross-origin images need {@code crossorigin="anonymous"} on the {@code } + * plus matching CORS headers, otherwise the canvas is tainted and + * {@code toBlob} throws. + */ +function imageToPngBlob(img: HTMLImageElement): Promise { + return new Promise((resolve, reject) => { + const draw = () => { + try { + const width = img.naturalWidth || img.width; + const height = img.naturalHeight || img.height; + if (!width || !height) { + reject(new Error('image has no intrinsic size')); + return; + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('2D canvas context not available')); + return; + } + ctx.drawImage(img, 0, 0, width, height); + canvas.toBlob((png) => (png ? resolve(png) : reject(new Error('canvas.toBlob returned null'))), 'image/png'); + } catch (err) { + reject(err); + } + }; + if (img.complete && img.naturalWidth > 0) { + draw(); + } else { + img.addEventListener('load', draw, { once: true }); + img.addEventListener('error', () => reject(new Error('image load failed')), { once: true }); + } + }); +} + +/** + * Writes any combination of text/plain, text/html and image/png to the system + * clipboard as a single ClipboardItem. Any argument may be {@code null} to omit + * that MIME type; at least one is expected to be non-null (the caller enforces + * this). The image argument is the source {@code }; it is re-encoded as + * {@code image/png} via {@link imageToPngBlob} and the resulting + * {@code Promise} is fed directly to {@code ClipboardItem} so the + * {@code navigator.clipboard.write} call stays synchronous inside the user + * gesture (Safari otherwise loses activation on the first await). * * The caller is expected to be inside a transient user gesture; otherwise * {@code navigator.clipboard.write} rejects and this function propagates the * rejection. * * Resolves with the {@code text/plain} value if present, otherwise with the - * {@code text/html} value — so the caller's success handler sees the exact - * string that reached the clipboard. + * {@code text/html} value, otherwise with {@code null} (image-only case). */ -async function writeClipboardPayload(text: string | null, html: string | null): Promise { - const entries: Record = {}; +async function writeClipboardPayload( + text: string | null, + html: string | null, + image: HTMLImageElement | null +): Promise { + const entries: Record> = {}; if (text !== null) { entries['text/plain'] = text; } if (html !== null) { entries['text/html'] = html; } + if (image !== null) { + entries['image/png'] = imageToPngBlob(image); + } await navigator.clipboard.write([new ClipboardItem(entries)]); return text !== null ? text : html; } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java index 8a9c55fc647..30409d155f9 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java @@ -18,9 +18,15 @@ import java.io.Serializable; import java.util.Objects; +import tools.jackson.databind.JsonNode; + import com.vaadin.flow.component.ClickNotifier; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.trigger.internal.ClickTrigger; +import com.vaadin.flow.dom.DomListenerRegistration; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.shared.Registration; /** * Entry point for the browser clipboard API. Bind clipboard actions to a user @@ -36,9 +42,39 @@ * * The Clipboard API requires a fresh user gesture for each write, so actions * only run during the DOM event that fires the underlying trigger. + *

+ * Read-side support is exposed through + * {@link #onPaste(Component, SerializableConsumer) onPaste}, which forwards the + * browser's native {@code paste} event to a server-side listener as a + * {@link PasteEvent}. Unlike the write API, {@code onPaste} does not need a + * click binding — it attaches a DOM listener directly to the given + * component and fires on every paste gesture targeting it (or any of its + * descendants, since {@code paste} bubbles). Pass the + * {@link com.vaadin.flow.component.UI UI} as the component for UI-wide scope; + * the {@link #onPaste(Component, PasteOptions, SerializableConsumer) options + * overload} lets the application skip pastes whose target is a form field + * — useful for page-wide listeners that should only react to pastes + * intended for the page as a whole. */ public final class Clipboard implements Serializable { + // The same string is used both as the JS expression evaluated client-side + // and as the lookup key in DomEvent#getEventData() server-side. Any drift + // between the two would silently produce a null value, so the expressions + // are kept in a single constant each. `?.` guards against synthetic events + // without a DataTransfer; `|| null` collapses the empty string (the + // browser's value for an absent MIME type) and the optional-chain + // short-circuit into JSON null. + private static final String PASTE_TEXT_EXPR = "event.clipboardData?.getData('text/plain') || null"; + private static final String PASTE_HTML_EXPR = "event.clipboardData?.getData('text/html') || null"; + + // Walks event.composedPath() so the check sees through open shadow DOMs + // (e.g. a Vaadin web component's internal ). Matches input, + // textarea, or anything with isContentEditable; the filter passes only + // when none of those are in the path. + private static final String PASTE_FILTER_SKIP_EDITABLE = "!event.composedPath().some(function(e){" + + "return e.tagName&&(e.tagName==='INPUT'||e.tagName==='TEXTAREA'||e.isContentEditable===true);})"; + private Clipboard() { // utility class } @@ -60,4 +96,118 @@ public static > ClipboardBinding onClick( Objects.requireNonNull(component, "component must not be null"); return new ClipboardBinding(new ClickTrigger(component)); } + + /** + * Registers a listener for browser {@code paste} events on the given + * component. The listener is invoked on the UI thread once per paste + * gesture targeting {@code component} (or any descendant, since + * {@code paste} bubbles) with a {@link PasteEvent} carrying the + * {@code text/plain} and {@code text/html} representations of the pasted + * content. + * + *

+ * The browser only fires {@code paste} when the target element is focused + * at the moment the user invokes paste. For non-editable elements such as a + * {@code Div} this means the element must be made focusable, typically via + * {@code tabindex="0"}. See {@link PasteEvent} for the rest of the browser + * caveats. + * + *

+ * Example: + * + *

{@code
+     * Div pasteTarget = new Div();
+     * pasteTarget.getElement().setAttribute("tabindex", "0");
+     * add(pasteTarget);
+     *
+     * Clipboard.onPaste(pasteTarget, event -> {
+     *     if (event.hasHtml()) {
+     *         renderHtml(event.getHtml());
+     *     } else if (event.hasText()) {
+     *         renderText(event.getText());
+     *     }
+     * });
+     * }
+ * + * @param component + * the component to listen for paste events on, not {@code null} + * @param listener + * the listener invoked for each paste, not {@code null} + * @return a {@link Registration} whose {@link Registration#remove() remove} + * detaches the paste listener + */ + public static Registration onPaste(Component component, + SerializableConsumer listener) { + return onPaste(component, PasteOptions.includingInputFields(), + listener); + } + + /** + * Registers a listener for browser {@code paste} events on the given + * component with the given {@link PasteOptions}. The listener is invoked on + * the UI thread for each paste gesture targeting {@code component} (or any + * descendant, since {@code paste} bubbles) whose target matches the + * options. For UI-wide scope, pass the {@link com.vaadin.flow.component.UI + * UI} as the component; the UI's root element is {@code } so it + * receives every paste event that bubbles up from anywhere on the page. + *

+ * The component does not need to be attached at registration time — the + * underlying DOM listener is bound to the component's element and is + * applied when the element is attached to a UI. + *

+ * Pass {@link PasteOptions#defaults()} to skip pastes whose target is an + * input, textarea, or {@code contenteditable} element (typically what a + * page-wide listener wants). Pass + * {@link PasteOptions#includingInputFields()} to observe every paste + * regardless of focus. + * + * @param component + * the component to listen for paste events on, not {@code null} + * @param options + * paste filtering options, not {@code null} + * @param listener + * the listener invoked for each matching paste, not {@code null} + * @return a {@link Registration} whose {@link Registration#remove() remove} + * detaches the paste listener + */ + public static Registration onPaste(Component component, + PasteOptions options, SerializableConsumer listener) { + Objects.requireNonNull(component, "component must not be null"); + Objects.requireNonNull(options, "options must not be null"); + Objects.requireNonNull(listener, "listener must not be null"); + return register(component, options, listener); + } + + private static Registration register(Component host, PasteOptions options, + SerializableConsumer listener) { + DomListenerRegistration registration = host.getElement() + .addEventListener("paste", domEvent -> { + JsonNode data = domEvent.getEventData(); + // mapEventTargetElement() does the DOM ancestor walk in + // the browser to find the closest Flow-tracked element to + // event.target. That gives DOM-truth (not state-tree + // order, which can diverge from DOM for virtual children, + // slotted content, etc.). + Element targetElement = domEvent.getEventTarget() + .orElse(null); + // The JS `|| null` already collapses "" and missing MIME + // types to JSON null; asStringOpt() then yields empty for + // those, so callers see null (not ""). + listener.accept(new PasteEvent(host, + data.optional(PASTE_TEXT_EXPR) + .flatMap(JsonNode::asStringOpt) + .orElse(null), + data.optional(PASTE_HTML_EXPR) + .flatMap(JsonNode::asStringOpt) + .orElse(null), + targetElement)); + }); + registration.addEventData(PASTE_TEXT_EXPR); + registration.addEventData(PASTE_HTML_EXPR); + registration.mapEventTargetElement(); + if (!options.includeInputFieldPastes()) { + registration.setFilter(PASTE_FILTER_SKIP_EDITABLE); + } + return registration::remove; + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardBinding.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardBinding.java index b1acfe6cbf0..755d8fb3132 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardBinding.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardBinding.java @@ -22,12 +22,17 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasValue; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.trigger.internal.ImageBlobInput; import com.vaadin.flow.component.trigger.internal.LiteralInput; import com.vaadin.flow.component.trigger.internal.PromiseAction.Error; import com.vaadin.flow.component.trigger.internal.PropertyInput; import com.vaadin.flow.component.trigger.internal.Trigger; import com.vaadin.flow.component.trigger.internal.WriteToClipboardAction; +import com.vaadin.flow.dom.Element; import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.server.streams.AbstractDownloadHandler; +import com.vaadin.flow.server.streams.DownloadHandler; /** * Fluent surface returned from {@link Clipboard#onClick}. Each {@code write*} @@ -167,6 +172,107 @@ public void writeHtml(String literal, onCopied, onError)); } + /** + * Copies the given component's root {@code } element to the clipboard + * as {@code image/png} when the underlying trigger fires. The image is + * drawn on a canvas and exported as PNG on the client, so the source can be + * any rasterisable format ({@code image/png}, {@code image/jpeg}, + * {@code image/svg+xml}, ...) as long as it has intrinsic dimensions. + *

+ * Cross-origin images need {@code crossorigin="anonymous"} on the + * {@code } plus matching CORS headers; otherwise the canvas is tainted + * and the write fails. Same-origin and {@code data:} URLs always work. + * + * @param source + * the component whose root {@code } should be copied, not + * {@code null} + */ + public void writeImage(Component source) { + Objects.requireNonNull(source, "source must not be null"); + bind(new WriteToClipboardAction(new ImageBlobInput(source))); + } + + /** + * Like {@link #writeImage(Component)} but reports the outcome back to the + * server. {@code onCopied} receives {@code null} — the image-only write has + * no meaningful string value. + * + * @param source + * the component whose root {@code } should be copied, not + * {@code null} + * @param onCopied + * UI-thread callback receiving {@code null}, not {@code null} + * @param onError + * UI-thread callback receiving the browser's error, not + * {@code null} + */ + public void writeImage(Component source, + SerializableConsumer<@Nullable String> onCopied, + SerializableConsumer onError) { + Objects.requireNonNull(source, "source must not be null"); + bind(new WriteToClipboardAction(new ImageBlobInput(source), onCopied, + onError)); + } + + /** + * Copies the image served by the given {@link DownloadHandler} to the + * clipboard as {@code image/png} when the underlying trigger fires. + *

+ * A hidden {@code } bound to the handler is appended to the trigger + * host, so the browser begins downloading the image as soon as this method + * is called — well before the user can click. At fire time the image is + * already decoded (or finishes decoding inside the canvas converter's + * {@code load} listener), drawn onto a canvas, and exported as PNG. + *

+ * Cross-origin concerns do not apply because the handler is served by the + * same origin as the application. + * + * @param handler + * the download handler producing the image bytes, not + * {@code null} + */ + public void writeImage(DownloadHandler handler) { + Objects.requireNonNull(handler, "handler must not be null"); + bind(new WriteToClipboardAction( + new ImageBlobInput(attachHiddenImg(handler)))); + } + + /** + * Like {@link #writeImage(DownloadHandler)} but reports the outcome back to + * the server. {@code onCopied} receives {@code null} — the image-only write + * has no meaningful string value. + * + * @param handler + * the download handler producing the image bytes, not + * {@code null} + * @param onCopied + * UI-thread callback receiving {@code null}, not {@code null} + * @param onError + * UI-thread callback receiving the browser's error, not + * {@code null} + */ + public void writeImage(DownloadHandler handler, + SerializableConsumer<@Nullable String> onCopied, + SerializableConsumer onError) { + Objects.requireNonNull(handler, "handler must not be null"); + bind(new WriteToClipboardAction( + new ImageBlobInput(attachHiddenImg(handler)), onCopied, + onError)); + } + + private Element attachHiddenImg(DownloadHandler handler) { + // AbstractDownloadHandler defaults to Content-Disposition: attachment; + // for an in-page we want inline. Mirrors Image#setSrc. + if (handler instanceof AbstractDownloadHandler ah) { + ah.inline(); + } + Element img = new Element(Tag.IMG); + img.getStyle().set("display", "none"); + img.setAttribute("src", handler.allowDisabled()); + trigger.getHost().appendChild(img); + return img; + } + /** * Copies a multi-format payload to the clipboard, packed into a single * {@code ClipboardItem}. @@ -178,8 +284,7 @@ public void writeHtml(String literal, */ public void write(ClipboardContent content) { Objects.requireNonNull(content, "content must not be null"); - bind(new WriteToClipboardAction(content.getTextInput(), - content.getHtmlInput())); + bind(new WriteToClipboardAction(content)); } /** @@ -200,8 +305,7 @@ public void write(ClipboardContent content, SerializableConsumer<@Nullable String> onCopied, SerializableConsumer onError) { Objects.requireNonNull(content, "content must not be null"); - bind(new WriteToClipboardAction(content.getTextInput(), - content.getHtmlInput(), onCopied, onError)); + bind(new WriteToClipboardAction(content, onCopied, onError)); } private void bind(WriteToClipboardAction action) { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardContent.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardContent.java index 30f8f2b3de1..47ebcbf3e3f 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardContent.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardContent.java @@ -23,25 +23,27 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasValue; import com.vaadin.flow.component.trigger.internal.Action; +import com.vaadin.flow.component.trigger.internal.ImageBlobInput; import com.vaadin.flow.component.trigger.internal.LiteralInput; import com.vaadin.flow.component.trigger.internal.PropertyInput; /** * Multi-format payload for {@link ClipboardBinding#write}. Any combination of - * {@code text/plain} and {@code text/html} can be set; each accessor returns - * {@code null} when the corresponding slot is empty. + * {@code text/plain}, {@code text/html} and an image source can be set; each + * accessor returns {@code null} when the corresponding slot is empty. *

* Use the static factory: * *

{@code
- * Clipboard.onClick(button)
- *         .write(ClipboardContent.create().text("Hello").html("Hello"));
+ * Clipboard.onClick(button).write(ClipboardContent.create().text("Hello")
+ *         .html("Hello").image(previewImage));
  * }
*/ public final class ClipboardContent implements Serializable { private Action.@Nullable Input textInput; private Action.@Nullable Input htmlInput; + private Action.@Nullable Input imageInput; private ClipboardContent() { } @@ -100,11 +102,44 @@ public ClipboardContent html(String literal) { return this; } - Action.@Nullable Input getTextInput() { + /** + * Sets the {@code image/png} payload to a PNG re-encoding of the given + * component's root {@code } element, produced on the client when the + * trigger fires. The source can be any rasterisable image + * ({@code image/png}, {@code image/jpeg}, {@code image/svg+xml}, ...) with + * intrinsic dimensions; cross-origin sources need + * {@code crossorigin="anonymous"} on the {@code } plus matching CORS + * headers, otherwise the canvas is tainted and the write fails. + * + * @param source + * the component whose root {@code } should be copied, not + * {@code null} + * @return this builder + */ + public ClipboardContent image(Component source) { + Objects.requireNonNull(source, "source must not be null"); + this.imageInput = new ImageBlobInput(source); + return this; + } + + /** + * @return the text input, or {@code null} if no text was set + */ + public Action.@Nullable Input getTextInput() { return textInput; } - Action.@Nullable Input getHtmlInput() { + /** + * @return the html input, or {@code null} if no html was set + */ + public Action.@Nullable Input getHtmlInput() { return htmlInput; } + + /** + * @return the image input, or {@code null} if no image was set + */ + public Action.@Nullable Input getImageInput() { + return imageInput; + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/PasteEvent.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/PasteEvent.java new file mode 100644 index 00000000000..5d0568e4b8b --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/PasteEvent.java @@ -0,0 +1,160 @@ +/* + * 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.clipboard; + +import java.io.Serializable; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.dom.Element; + +/** + * Server-side representation of a browser {@code paste} event delivered to a + * listener registered with + * {@link Clipboard#onPaste(Component, com.vaadin.flow.function.SerializableConsumer) + * Clipboard.onPaste}. + *

+ * The event carries the two textual MIME types the browser exposes on + * {@code event.clipboardData}: {@code text/plain} via {@link #getText()} and + * {@code text/html} via {@link #getHtml()}. Either may be {@code null} when the + * paste did not include that representation. {@link #getTargetElement()} + * resolves to the closest Flow-tracked {@link Element} ancestor of the paste's + * DOM target. + * + *

Empty vs. absent

+ * + * The browser returns the empty string {@code ""} both when the requested MIME + * type is not present on the clipboard and when the user pasted an empty string + * of that type. There is no way to distinguish the two. {@code PasteEvent} + * collapses both into {@code null}, so callers can simply check + * {@code event.hasHtml()} (or {@code event.getHtml() != null}) without an extra + * {@code !isEmpty()} guard. + * + *

What is not on it

+ * + * Files and binary clipboard items (pasted screenshots, files dragged from the + * OS file picker, anything that arrives as {@code event.clipboardData.files} or + * a {@code DataTransferItem} of kind {@code "file"}) are not delivered by this + * event. File-paste support is tracked separately. + * + *

Browser caveats

+ * + *
    + *
  • The browser only fires {@code paste} when the target element is focused + * at the moment the user invokes paste. Non-editable elements (such as a plain + * {@code Div}) need to be made focusable — typically via + * {@code tabindex="0"} — before they will receive paste events.
  • + *
  • On editable targets ({@code }, {@code