From 00b501c861ea2d37d40c539d4ddf78dd8587ac75 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Thu, 28 May 2026 16:42:14 +0300 Subject: [PATCH 1/8] feat: add Clipboard image copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds writeImage(Component) — fire-and-forget and observed flavours — on ClipboardBinding, plus image(Component) on ClipboardContent for the multi-format path. 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. Internals: - ImageBlobInput extends Action.Input; its toJs yields the source element verbatim. - WriteToClipboardAction gains a third (image) slot; the rendered helper call is writePayload($0(event), $1(event), $2(event)). - The TS helper writeClipboardPayload takes an HTMLImageElement third argument and re-encodes it via imageToPngBlob (canvas + toBlob). The resulting Promise is fed directly to ClipboardItem so the navigator.clipboard.write call stays synchronous inside the user gesture — Safari otherwise loses activation on the first await. Tests cover the image/png slot and the multi-format case with all three slots together. --- flow-client/src/main/frontend/Clipboard.ts | 70 ++++++++++-- .../component/clipboard/ClipboardBinding.java | 64 +++++++++-- .../component/clipboard/ClipboardContent.java | 34 +++++- .../trigger/internal/ImageBlobInput.java | 66 +++++++++++ .../trigger/internal/LiteralInput.java | 2 +- .../trigger/internal/SignalInput.java | 2 +- .../internal/WriteToClipboardAction.java | 63 +++++++---- .../component/clipboard/ClipboardTest.java | 48 ++++++++ .../trigger/internal/SignalInputTest.java | 4 +- .../internal/WriteToClipboardActionTest.java | 106 ++++++++++++++---- .../ui/TriggerWriteToClipboardView.java | 9 +- 11 files changed, 395 insertions(+), 73 deletions(-) create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java 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/ClipboardBinding.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardBinding.java index b1acfe6cbf0..1b0a16ea1d7 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,6 +22,7 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasValue; +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; @@ -66,7 +67,8 @@ public final class ClipboardBinding implements Serializable { */ public void writeText(String literal) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(new LiteralInput<>(literal), null)); + bind(new WriteToClipboardAction(new LiteralInput<>(literal), null, + null)); } /** @@ -86,7 +88,7 @@ public void writeText(String literal, SerializableConsumer<@Nullable String> onCopied, SerializableConsumer onError) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(new LiteralInput<>(literal), null, + bind(new WriteToClipboardAction(new LiteralInput<>(literal), null, null, onCopied, onError)); } @@ -107,7 +109,8 @@ public > void writeText( C source) { Objects.requireNonNull(source, "source must not be null"); bind(new WriteToClipboardAction( - new PropertyInput<>(source, "value", String.class), null)); + new PropertyInput<>(source, "value", String.class), null, + null)); } /** @@ -131,7 +134,7 @@ public > void writeText(C source, SerializableConsumer onError) { Objects.requireNonNull(source, "source must not be null"); bind(new WriteToClipboardAction( - new PropertyInput<>(source, "value", String.class), null, + new PropertyInput<>(source, "value", String.class), null, null, onCopied, onError)); } @@ -144,7 +147,8 @@ public > void writeText(C source, */ public void writeHtml(String literal) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(null, new LiteralInput<>(literal))); + bind(new WriteToClipboardAction(null, new LiteralInput<>(literal), + null)); } /** @@ -163,7 +167,50 @@ public void writeHtml(String literal, SerializableConsumer<@Nullable String> onCopied, SerializableConsumer onError) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(null, new LiteralInput<>(literal), + bind(new WriteToClipboardAction(null, new LiteralInput<>(literal), null, + 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(null, null, + 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(null, null, new ImageBlobInput(source), onCopied, onError)); } @@ -179,7 +226,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())); + content.getHtmlInput(), content.getImageInput())); } /** @@ -201,7 +248,8 @@ public void write(ClipboardContent content, SerializableConsumer onError) { Objects.requireNonNull(content, "content must not be null"); bind(new WriteToClipboardAction(content.getTextInput(), - content.getHtmlInput(), onCopied, onError)); + content.getHtmlInput(), content.getImageInput(), 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..acc99916559 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,6 +102,26 @@ public ClipboardContent html(String literal) { return this; } + /** + * 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; + } + Action.@Nullable Input getTextInput() { return textInput; } @@ -107,4 +129,8 @@ public ClipboardContent html(String literal) { Action.@Nullable Input getHtmlInput() { return htmlInput; } + + Action.@Nullable Input getImageInput() { + return imageInput; + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java new file mode 100644 index 00000000000..43e29b69f59 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java @@ -0,0 +1,66 @@ +/* + * 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 com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.JsFunction; + +/** + * Input that produces the source {@code } element of a component, for use + * as the image slot of {@link WriteToClipboardAction}. The TS helper + * ({@code window.Vaadin.Flow.clipboard.writePayload}) re-encodes it to + * {@code image/png} via a canvas round-trip — the only image MIME type every + * browser's asynchronous Clipboard API accepts on write. + *

+ * The Java type parameter is purely a marker: the value never crosses the + * network — the action calls the TS helper with the live {@link Element} + * reference and the canvas conversion happens entirely on the client. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +public class ImageBlobInput extends Action.Input { + + private final Element source; + + /** + * Creates an image input that yields the given component's root element as + * the source {@code }. + * + * @param source + * the component carrying the {@code } root element, not + * {@code null}; its root tag must be {@code img} + * @throws IllegalArgumentException + * if the source's root element is not an {@code } + */ + public ImageBlobInput(Component source) { + Element element = Objects.requireNonNull(source).getElement(); + if (!Tag.IMG.equals(element.getTag())) { + throw new IllegalArgumentException( + "source root element must be , was <" + + element.getTag() + ">"); + } + this.source = element; + } + + @Override + protected JsFunction toJs(Trigger trigger) { + return JsFunction.of("return $0", source); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java index cac726dbdb5..d2ba08b6ac2 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java @@ -28,7 +28,7 @@ * *
{@code
  * new ClickTrigger(button).triggers(new WriteToClipboardAction(
- *         new LiteralInput<>("hello"), null, copied -> {
+ *         new LiteralInput<>("hello"), null, null, copied -> {
  *         }, err -> {
  *         }));
  * }
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java index 31f78f0304a..771efe1a2cd 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java @@ -46,7 +46,7 @@ *
{@code
  * ValueSignal textSignal = new ValueSignal<>("Hello");
  * new ClickTrigger(copyButton).triggers(new WriteToClipboardAction(
- *         new SignalInput<>(this, textSignal), null));
+ *         new SignalInput<>(this, textSignal), null, null));
  * textSignal.set("Goodbye");
  * // Clicking the button after the set copies "Goodbye".
  * }
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java index d231882b34c..de9995d1ec6 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java @@ -22,8 +22,9 @@ /** * Writes a {@code ClipboardItem} to the user's clipboard via - * {@code navigator.clipboard.write}. Supports two concurrent text MIME types in - * one item: {@code text/plain} and {@code text/html}. Any combination is + * {@code navigator.clipboard.write}. Supports up to three concurrent MIME types + * in one item: {@code text/plain}, {@code text/html} and {@code image/png} + * (typically produced by an {@link ImageBlobInput}). Any combination is * allowed; at least one slot must be set. *

* The Clipboard API requires the {@code write} call to happen inside a @@ -34,10 +35,10 @@ * constructor for fire-and-forget, or the overload taking * {@code onCopied}/{@code onError} consumers. {@code onCopied} receives the * exact string that was copied — the {@code text/plain} value if present, - * otherwise the {@code text/html} value — useful when the input was a - * {@link PropertyInput} whose value is only known on the client. - * {@code onError} receives a {@link PromiseAction.Error} record with the - * browser's error name and message. + * otherwise the {@code text/html} value, otherwise {@code null} (image-only + * case) — useful when the input was a {@link PropertyInput} whose value is only + * known on the client. {@code onError} receives a {@link PromiseAction.Error} + * record with the browser's error name and message. *

* For internal use only. May be renamed or removed in a future release. */ @@ -45,14 +46,15 @@ public class WriteToClipboardAction extends PromiseAction { /** * Stand-in input that yields a JS {@code null} for a missing MIME slot, so - * the rendered call always reaches the TS helper with two arguments - * regardless of which slot was set on the server. + * the rendered call always reaches the TS helper with three arguments + * regardless of which slots were set on the server. */ private static final JsFunction NULL_INPUT_FN = JsFunction .of("return null"); private final Action.@Nullable Input textInput; private final Action.@Nullable Input htmlInput; + private final Action.@Nullable Input imageInput; /** * Creates a fire-and-forget clipboard-copy action. @@ -63,15 +65,21 @@ public class WriteToClipboardAction extends PromiseAction { * @param htmlInput * input producing the {@code text/html} payload, or {@code null} * to omit + * @param imageInput + * input producing the source {@code } for the + * {@code image/png} payload (typically an + * {@link ImageBlobInput}), or {@code null} to omit * @throws IllegalArgumentException - * if both inputs are {@code null} + * if all three inputs are {@code null} */ public WriteToClipboardAction(Action.@Nullable Input textInput, - Action.@Nullable Input htmlInput) { + Action.@Nullable Input htmlInput, + Action.@Nullable Input imageInput) { super(); - validate(textInput, htmlInput); + validate(textInput, htmlInput, imageInput); this.textInput = textInput; this.htmlInput = htmlInput; + this.imageInput = imageInput; } /** @@ -84,47 +92,56 @@ public WriteToClipboardAction(Action.@Nullable Input textInput, * @param htmlInput * input producing the {@code text/html} payload, or {@code null} * to omit + * @param imageInput + * input producing the source {@code } for the + * {@code image/png} payload, or {@code null} to omit * @param onCopied * invoked on the UI thread with the string that was copied after * the client reports the write resolved ({@code text/plain} if - * present, otherwise {@code text/html}), or {@code null} if the - * JS resolved with {@code undefined}; not {@code null} + * present, otherwise {@code text/html}, otherwise {@code null} + * in the image-only case), or {@code null} if the JS resolved + * with {@code undefined}; not {@code null} * @param onError * invoked on the UI thread with the browser's error after the * client reports the write rejected, not {@code null} * @throws IllegalArgumentException - * if both inputs are {@code null} + * if all three inputs are {@code null} */ public WriteToClipboardAction(Action.@Nullable Input textInput, Action.@Nullable Input htmlInput, + Action.@Nullable Input imageInput, SerializableConsumer<@Nullable String> onCopied, SerializableConsumer onError) { super(String.class, onCopied, onError); - validate(textInput, htmlInput); + validate(textInput, htmlInput, imageInput); this.textInput = textInput; this.htmlInput = htmlInput; + this.imageInput = imageInput; } private static void validate(Action.@Nullable Input text, - Action.@Nullable Input html) { - if (text == null && html == null) { + Action.@Nullable Input html, + Action.@Nullable Input image) { + if (text == null && html == null && image == null) { throw new IllegalArgumentException( - "At least one of textInput, htmlInput must be non-null"); + "At least one of textInput, htmlInput, imageInput must be non-null"); } } @Override protected JsFunction toPromiseJs(Trigger trigger) { - // Both slots are always present in the call; absent slots become a - // no-op input that returns null, so the TS helper sees null and skips - // that MIME type. Keeping the call shape uniform across all four + // All three slots are always present in the call; absent slots become + // a no-op input that returns null, so the TS helper sees null and + // skips that MIME type. Keeping the call shape uniform across all // combinations means no per-action JS assembly. JsFunction text = textInput != null ? textInput.toJs(trigger) : NULL_INPUT_FN; JsFunction html = htmlInput != null ? htmlInput.toJs(trigger) : NULL_INPUT_FN; + JsFunction image = imageInput != null ? imageInput.toJs(trigger) + : NULL_INPUT_FN; return JsFunction.of( - "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event))", - text, html).withArguments("event"); + "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event), $2(event))", + text, html, image).withArguments("event"); } } diff --git a/flow-server/src/test/java/com/vaadin/flow/component/clipboard/ClipboardTest.java b/flow-server/src/test/java/com/vaadin/flow/component/clipboard/ClipboardTest.java index 159ebb318b8..8f186134c6e 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/clipboard/ClipboardTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/clipboard/ClipboardTest.java @@ -53,6 +53,10 @@ protected void setPresentationValue(String newPresentationValue) { } } + @Tag("img") + static final class TestImage extends Component { + } + @Test void onClick_installsClickTrigger() { UI ui = new MockUI(); @@ -127,6 +131,42 @@ void write_multiFormat_packsBothTextAndHtml() { assertEquals("html", htmlInput.getCaptures().get(0)); } + @Test + void writeImage_capturesImageElementAsImageInput() { + UI ui = new MockUI(); + TestButton button = new TestButton(); + TestImage image = new TestImage(); + ui.getElement().appendChild(button.getElement(), image.getElement()); + + Clipboard.onClick(button).writeImage(image); + + // ImageBlobInput renders as `return $0` and captures the image + // element. The action puts it at slot 2. + JsFunction imageInput = (JsFunction) actionFn(ui).getCaptures().get(2); + assertEquals("return $0", imageInput.getBody()); + assertSame(image.getElement(), imageInput.getCaptures().get(0)); + } + + @Test + void write_multiFormat_packsAllThreeSlots() { + UI ui = new MockUI(); + TestButton button = new TestButton(); + TestImage image = new TestImage(); + ui.getElement().appendChild(button.getElement(), image.getElement()); + + Clipboard.onClick(button).write(ClipboardContent.create().text("plain") + .html("html").image(image)); + + JsFunction action = actionFn(ui); + assertEquals("plain", ((JsFunction) action.getCaptures().get(0)) + .getCaptures().get(0)); + assertEquals("html", ((JsFunction) action.getCaptures().get(1)) + .getCaptures().get(0)); + assertSame(image.getElement(), + ((JsFunction) action.getCaptures().get(2)).getCaptures() + .get(0)); + } + @Test void write_contentTextFromHasValue_emitsPropertyInputForValue() { UI ui = new MockUI(); @@ -148,6 +188,14 @@ void write_emptyContent_throws() { .onClick(button).write(ClipboardContent.create())); } + @Test + void writeImage_nonImgComponent_throws() { + TestButton button = new TestButton(); + // TestButton's root is , not . + assertThrows(IllegalArgumentException.class, + () -> Clipboard.onClick(button).writeImage(button)); + } + /** * Returns the action JsFunction for a fire-and-forget binding: the install * JsFunction's $0 capture, which in fire-and-forget mode is the JsFunction diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java index 672b5b4c1c0..2040ba74df6 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java @@ -86,7 +86,7 @@ void handlerJs_readsUniquePropertyOnOwnerElement() { ValueSignal signal = new ValueSignal<>("hello"); new DomEventTrigger(button, "click").triggers( new WriteToClipboardAction(new SignalInput<>(owner, signal), - null)); + null, null)); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -110,7 +110,7 @@ void signalChange_pushesValueToOwnerElementProperty() { ValueSignal signal = new ValueSignal<>("first"); new DomEventTrigger(button, "click").triggers( new WriteToClipboardAction(new SignalInput<>(owner, signal), - null)); + null, null)); // After triggers(), the effect has been installed and the initial // pass queued an executeJs that mirrors the signal to the owner. diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java index 2072242ceff..f25001c4b1f 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java @@ -33,12 +33,15 @@ import static com.vaadin.flow.component.trigger.internal.TriggerTestUtil.singleInstallFn; import static com.vaadin.flow.component.trigger.internal.TriggerTestUtil.singleReturnChannel; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; class WriteToClipboardActionTest { + private static final String HELPER_BODY = "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event), $2(event))"; + @Test - void fireAndForget_textOnly_callsHelperWithHtmlSlotReturningNull() { + void fireAndForget_textOnly_callsHelperWithHtmlAndImageSlotsReturningNull() { UI ui = new MockUI(); TagComponent button = new TagComponent("button"); TagComponent field = new TagComponent("input"); @@ -46,15 +49,13 @@ void fireAndForget_textOnly_callsHelperWithHtmlSlotReturningNull() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( - new PropertyInput<>(field, "value", String.class), + new PropertyInput<>(field, "value", String.class), null, null)); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals( - "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event))", - action.getBody()); + assertEquals(HELPER_BODY, action.getBody()); // $0 is the text input — a PropertyInput that reads from the field. JsFunction text = (JsFunction) action.getCaptures().get(0); @@ -64,6 +65,11 @@ void fireAndForget_textOnly_callsHelperWithHtmlSlotReturningNull() { JsFunction html = (JsFunction) action.getCaptures().get(1); assertEquals("return null", html.getBody()); assertEquals(List.of(), html.getCaptures()); + + // $2 is the image slot — also the no-op stand-in. + JsFunction image = (JsFunction) action.getCaptures().get(2); + assertEquals("return null", image.getBody()); + assertEquals(List.of(), image.getCaptures()); } @Test @@ -74,38 +80,38 @@ void fireAndForget_textAndHtml_capturesBothInputFunctions() { new DomEventTrigger(button, "click").triggers( new WriteToClipboardAction(new LiteralInput<>("plain"), - new LiteralInput<>("html"))); + new LiteralInput<>("html"), null)); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals( - "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event))", - action.getBody()); + assertEquals(HELPER_BODY, action.getBody()); JsFunction text = (JsFunction) action.getCaptures().get(0); assertEquals("plain", text.getCaptures().get(0)); JsFunction html = (JsFunction) action.getCaptures().get(1); assertEquals("html", html.getCaptures().get(0)); + + // image slot is the no-op stand-in. + JsFunction image = (JsFunction) action.getCaptures().get(2); + assertEquals("return null", image.getBody()); } @Test - void fireAndForget_htmlOnly_textSlotReturnsNull() { + void fireAndForget_htmlOnly_textAndImageSlotsReturnNull() { UI ui = new MockUI(); TagComponent button = new TagComponent("button"); ui.getElement().appendChild(button.getElement()); new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction(null, - new LiteralInput<>("hi"))); + new LiteralInput<>("hi"), null)); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals( - "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event))", - action.getBody()); + assertEquals(HELPER_BODY, action.getBody()); // $0 is the text slot — the no-op stand-in. JsFunction text = (JsFunction) action.getCaptures().get(0); @@ -114,6 +120,64 @@ void fireAndForget_htmlOnly_textSlotReturnsNull() { // $1 is the html literal input. JsFunction html = (JsFunction) action.getCaptures().get(1); assertEquals("hi", html.getCaptures().get(0)); + + // $2 is the image slot — the no-op stand-in. + JsFunction image = (JsFunction) action.getCaptures().get(2); + assertEquals("return null", image.getBody()); + } + + @Test + void fireAndForget_imageOnly_textAndHtmlSlotsReturnNull() { + UI ui = new MockUI(); + TagComponent button = new TagComponent("button"); + TagComponent img = new TagComponent("img"); + ui.getElement().appendChild(button.getElement(), img.getElement()); + + new DomEventTrigger(button, "click") + .triggers(new WriteToClipboardAction(null, null, + new ImageBlobInput(img))); + + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + JsFunction action = actionOf(singleInstallFn(ui)); + assertEquals(HELPER_BODY, action.getBody()); + + // $0 and $1 are the no-op stand-ins. + assertEquals("return null", + ((JsFunction) action.getCaptures().get(0)).getBody()); + assertEquals("return null", + ((JsFunction) action.getCaptures().get(1)).getBody()); + + // $2 is the image input — yields the source element verbatim, + // captured at $0 of its JsFunction. + JsFunction image = (JsFunction) action.getCaptures().get(2); + assertEquals("return $0", image.getBody()); + assertSame(img.getElement(), image.getCaptures().get(0)); + } + + @Test + void fireAndForget_allThreeSlots_eachInputCapturedInOrder() { + UI ui = new MockUI(); + TagComponent button = new TagComponent("button"); + TagComponent img = new TagComponent("img"); + ui.getElement().appendChild(button.getElement(), img.getElement()); + + new DomEventTrigger(button, "click").triggers( + new WriteToClipboardAction(new LiteralInput<>("plain"), + new LiteralInput<>("html"), + new ImageBlobInput(img))); + + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + JsFunction action = actionOf(singleInstallFn(ui)); + assertEquals(HELPER_BODY, action.getBody()); + + assertEquals("plain", ((JsFunction) action.getCaptures().get(0)) + .getCaptures().get(0)); + assertEquals("html", ((JsFunction) action.getCaptures().get(1)) + .getCaptures().get(0)); + assertSame(img.getElement(), ((JsFunction) action.getCaptures().get(2)) + .getCaptures().get(0)); } @Test @@ -126,7 +190,7 @@ void withCallbacks_actionFnWrapsInnerWithObserverAndChannel() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( new PropertyInput<>(field, "value", String.class), null, - copied -> { + null, copied -> { }, err -> { })); @@ -140,9 +204,7 @@ void withCallbacks_actionFnWrapsInnerWithObserverAndChannel() { assertEquals("$0($1(event), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); - assertEquals( - "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event))", - inner.getBody()); + assertEquals(HELPER_BODY, inner.getBody()); } @Test @@ -156,7 +218,7 @@ void onCopied_receivesTheStringFromTheResolvedPromise() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( new PropertyInput<>(field, "value", String.class), null, - copied::add, err -> { + null, copied::add, err -> { })); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -182,7 +244,7 @@ void onCopied_receivesNullWhenJsResolvedWithoutValue() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( new PropertyInput<>(field, "value", String.class), null, - copied::add, err -> { + null, copied::add, err -> { })); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -199,9 +261,9 @@ void onCopied_receivesNullWhenJsResolvedWithoutValue() { } @Test - void constructor_bothInputsNullRejected() { + void constructor_allInputsNullRejected() { assertThrows(IllegalArgumentException.class, - () -> new WriteToClipboardAction(null, null)); + () -> new WriteToClipboardAction(null, null, null)); } } diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java index 1f9529c27eb..9fe1422b61e 100644 --- a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java @@ -93,23 +93,24 @@ protected void onShow() { Action.Input value = new PropertyInput<>(field, "value", String.class); new ClickTrigger(copyButton).triggers(new WriteToClipboardAction(value, - null, copied -> status.setText("ok:" + copied), err -> status + null, null, copied -> status.setText("ok:" + copied), + err -> status .setText("err:" + err.name() + ":" + err.message()))); new ClickTrigger(copyStaticButton).triggers(new WriteToClipboardAction( - new LiteralInput<>(STATIC_TEXT), null, + new LiteralInput<>(STATIC_TEXT), null, null, copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message()))); new ClickTrigger(copyMultiButton).triggers(new WriteToClipboardAction( new LiteralInput<>(MULTI_TEXT), new LiteralInput<>(MULTI_HTML), - copied -> status.setText("ok:" + copied), err -> status + null, copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message()))); ValueSignal textSignal = new ValueSignal<>(SIGNAL_INITIAL_TEXT); signalDisplay.getElement().bindText(textSignal); new ClickTrigger(copySignalButton).triggers(new WriteToClipboardAction( - new SignalInput<>(this, textSignal), null, + new SignalInput<>(this, textSignal), null, null, copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message()))); changeSignalButton From 9e316ee164ca475b4c380af3b7225eab53b88035 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 29 May 2026 09:44:23 +0300 Subject: [PATCH 2/8] refactor: dedicated text/HTML and image constructors on WriteToClipboardAction Restores the original 2-arg (text, html) and 4-arg observed constructors that main had, and adds matching 1-arg (image) and 3-arg observed image constructors. The 3-arg / 5-arg multi-format constructors stay as the underlying implementation that the dedicated overloads delegate into via this(...). Callers in the typical text-only, html-only, or image-only shapes no longer need to pass null placeholders for the unused slots. ClipboardBinding's write* methods, SignalInput/LiteralInput Javadoc snippets, SignalInputTest and TriggerWriteToClipboardView are all adjusted to use the dedicated overloads. --- .../component/clipboard/ClipboardBinding.java | 22 ++-- .../trigger/internal/LiteralInput.java | 2 +- .../trigger/internal/SignalInput.java | 2 +- .../internal/WriteToClipboardAction.java | 119 ++++++++++++++++-- .../trigger/internal/SignalInputTest.java | 4 +- .../internal/WriteToClipboardActionTest.java | 54 ++++++-- .../ui/TriggerWriteToClipboardView.java | 9 +- 7 files changed, 169 insertions(+), 43 deletions(-) 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 1b0a16ea1d7..7ec022a1088 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 @@ -67,8 +67,7 @@ public final class ClipboardBinding implements Serializable { */ public void writeText(String literal) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(new LiteralInput<>(literal), null, - null)); + bind(new WriteToClipboardAction(new LiteralInput<>(literal), null)); } /** @@ -88,7 +87,7 @@ public void writeText(String literal, SerializableConsumer<@Nullable String> onCopied, SerializableConsumer onError) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(new LiteralInput<>(literal), null, null, + bind(new WriteToClipboardAction(new LiteralInput<>(literal), null, onCopied, onError)); } @@ -109,8 +108,7 @@ public > void writeText( C source) { Objects.requireNonNull(source, "source must not be null"); bind(new WriteToClipboardAction( - new PropertyInput<>(source, "value", String.class), null, - null)); + new PropertyInput<>(source, "value", String.class), null)); } /** @@ -134,7 +132,7 @@ public > void writeText(C source, SerializableConsumer onError) { Objects.requireNonNull(source, "source must not be null"); bind(new WriteToClipboardAction( - new PropertyInput<>(source, "value", String.class), null, null, + new PropertyInput<>(source, "value", String.class), null, onCopied, onError)); } @@ -147,8 +145,7 @@ public > void writeText(C source, */ public void writeHtml(String literal) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(null, new LiteralInput<>(literal), - null)); + bind(new WriteToClipboardAction(null, new LiteralInput<>(literal))); } /** @@ -167,7 +164,7 @@ public void writeHtml(String literal, SerializableConsumer<@Nullable String> onCopied, SerializableConsumer onError) { Objects.requireNonNull(literal, "literal must not be null"); - bind(new WriteToClipboardAction(null, new LiteralInput<>(literal), null, + bind(new WriteToClipboardAction(null, new LiteralInput<>(literal), onCopied, onError)); } @@ -188,8 +185,7 @@ public void writeHtml(String literal, */ public void writeImage(Component source) { Objects.requireNonNull(source, "source must not be null"); - bind(new WriteToClipboardAction(null, null, - new ImageBlobInput(source))); + bind(new WriteToClipboardAction(new ImageBlobInput(source))); } /** @@ -210,8 +206,8 @@ public void writeImage(Component source, SerializableConsumer<@Nullable String> onCopied, SerializableConsumer onError) { Objects.requireNonNull(source, "source must not be null"); - bind(new WriteToClipboardAction(null, null, new ImageBlobInput(source), - onCopied, onError)); + bind(new WriteToClipboardAction(new ImageBlobInput(source), onCopied, + onError)); } /** diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java index d2ba08b6ac2..cac726dbdb5 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/LiteralInput.java @@ -28,7 +28,7 @@ * *

{@code
  * new ClickTrigger(button).triggers(new WriteToClipboardAction(
- *         new LiteralInput<>("hello"), null, null, copied -> {
+ *         new LiteralInput<>("hello"), null, copied -> {
  *         }, err -> {
  *         }));
  * }
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java index 771efe1a2cd..31f78f0304a 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SignalInput.java @@ -46,7 +46,7 @@ *
{@code
  * ValueSignal textSignal = new ValueSignal<>("Hello");
  * new ClickTrigger(copyButton).triggers(new WriteToClipboardAction(
- *         new SignalInput<>(this, textSignal), null, null));
+ *         new SignalInput<>(this, textSignal), null));
  * textSignal.set("Goodbye");
  * // Clicking the button after the set copies "Goodbye".
  * }
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java index de9995d1ec6..d1f61a9ecde 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java @@ -15,6 +15,8 @@ */ package com.vaadin.flow.component.trigger.internal; +import java.util.Objects; + import org.jspecify.annotations.Nullable; import com.vaadin.flow.dom.JsFunction; @@ -31,14 +33,19 @@ * short-lived user gesture (click, key press, ...). Bind this action to a * trigger that fires during such a gesture. *

- * Outcome handling extends {@link PromiseAction}: use the no-arg outcome - * constructor for fire-and-forget, or the overload taking - * {@code onCopied}/{@code onError} consumers. {@code onCopied} receives the - * exact string that was copied — the {@code text/plain} value if present, - * otherwise the {@code text/html} value, otherwise {@code null} (image-only - * case) — useful when the input was a {@link PropertyInput} whose value is only - * known on the client. {@code onError} receives a {@link PromiseAction.Error} - * record with the browser's error name and message. + * Construction comes in three flavours, each available as fire-and-forget and + * as a with-outcome variant taking {@code onCopied}/{@code onError} consumers: + *

    + *
  • Text/HTML — the typical case for copying a string
  • + *
  • Image — the typical case for copying an image
  • + *
  • Multi-format — combine any of the three slots in one item
  • + *
+ * {@code onCopied} receives the exact string that was copied — the + * {@code text/plain} value if present, otherwise the {@code text/html} value, + * otherwise {@code null} (image-only case) — useful when the input was a + * {@link PropertyInput} whose value is only known on the client. + * {@code onError} receives a {@link PromiseAction.Error} record with the + * browser's error name and message. *

* For internal use only. May be renamed or removed in a future release. */ @@ -57,7 +64,92 @@ public class WriteToClipboardAction extends PromiseAction { private final Action.@Nullable Input imageInput; /** - * Creates a fire-and-forget clipboard-copy action. + * Creates a fire-and-forget text/HTML clipboard-copy action. + * + * @param textInput + * input producing the {@code text/plain} payload, or + * {@code null} to omit + * @param htmlInput + * input producing the {@code text/html} payload, or {@code null} + * to omit + * @throws IllegalArgumentException + * if both inputs are {@code null} + */ + public WriteToClipboardAction(Action.@Nullable Input textInput, + Action.@Nullable Input htmlInput) { + this(textInput, htmlInput, null); + } + + /** + * Creates a text/HTML clipboard-copy action whose outcome is reported back + * to the server. + * + * @param textInput + * input producing the {@code text/plain} payload, or + * {@code null} to omit + * @param htmlInput + * input producing the {@code text/html} payload, or {@code null} + * to omit + * @param onCopied + * invoked on the UI thread with the string that was copied after + * the client reports the write resolved ({@code text/plain} if + * present, otherwise {@code text/html}), or {@code null} if the + * JS resolved with {@code undefined}; not {@code null} + * @param onError + * invoked on the UI thread with the browser's error after the + * client reports the write rejected, not {@code null} + * @throws IllegalArgumentException + * if both inputs are {@code null} + */ + public WriteToClipboardAction(Action.@Nullable Input textInput, + Action.@Nullable Input htmlInput, + SerializableConsumer<@Nullable String> onCopied, + SerializableConsumer onError) { + this(textInput, htmlInput, null, onCopied, onError); + } + + /** + * Creates a fire-and-forget image clipboard-copy action. + * + * @param imageInput + * input producing the source {@code } for the + * {@code image/png} payload (typically an + * {@link ImageBlobInput}), not {@code null} + */ + public WriteToClipboardAction(Action.Input imageInput) { + this(null, null, Objects.requireNonNull(imageInput, + "imageInput must not be null")); + } + + /** + * Creates an image clipboard-copy action whose outcome is reported back to + * the server. {@code onCopied} receives {@code null} — the image-only write + * has no meaningful string value. + * + * @param imageInput + * input producing the source {@code } for the + * {@code image/png} payload (typically an + * {@link ImageBlobInput}), not {@code null} + * @param onCopied + * invoked on the UI thread with {@code null} after the client + * reports the write resolved, not {@code null} + * @param onError + * invoked on the UI thread with the browser's error after the + * client reports the write rejected, not {@code null} + */ + public WriteToClipboardAction(Action.Input imageInput, + SerializableConsumer<@Nullable String> onCopied, + SerializableConsumer onError) { + this(null, null, Objects.requireNonNull(imageInput, + "imageInput must not be null"), onCopied, onError); + } + + /** + * Creates a fire-and-forget multi-format clipboard-copy action. Prefer + * {@link #WriteToClipboardAction(Action.Input, Action.Input)} for the + * text/HTML case or {@link #WriteToClipboardAction(Action.Input)} for the + * image case; use this constructor when an item needs to carry more than + * one of the three MIME types. * * @param textInput * input producing the {@code text/plain} payload, or @@ -83,8 +175,13 @@ public WriteToClipboardAction(Action.@Nullable Input textInput, } /** - * Creates a clipboard-copy action whose outcome is reported back to the - * server. + * Creates a multi-format clipboard-copy action whose outcome is reported + * back to the server. Prefer + * {@link #WriteToClipboardAction(Action.Input, Action.Input, SerializableConsumer, SerializableConsumer)} + * for the text/HTML case or + * {@link #WriteToClipboardAction(Action.Input, SerializableConsumer, SerializableConsumer)} + * for the image case; use this constructor when an item needs to carry more + * than one of the three MIME types. * * @param textInput * input producing the {@code text/plain} payload, or diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java index 2040ba74df6..672b5b4c1c0 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/SignalInputTest.java @@ -86,7 +86,7 @@ void handlerJs_readsUniquePropertyOnOwnerElement() { ValueSignal signal = new ValueSignal<>("hello"); new DomEventTrigger(button, "click").triggers( new WriteToClipboardAction(new SignalInput<>(owner, signal), - null, null)); + null)); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -110,7 +110,7 @@ void signalChange_pushesValueToOwnerElementProperty() { ValueSignal signal = new ValueSignal<>("first"); new DomEventTrigger(button, "click").triggers( new WriteToClipboardAction(new SignalInput<>(owner, signal), - null, null)); + null)); // After triggers(), the effect has been installed and the initial // pass queued an executeJs that mirrors the signal to the owner. diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java index f25001c4b1f..37224ee7930 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java @@ -49,7 +49,7 @@ void fireAndForget_textOnly_callsHelperWithHtmlAndImageSlotsReturningNull() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( - new PropertyInput<>(field, "value", String.class), null, + new PropertyInput<>(field, "value", String.class), null)); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -80,7 +80,7 @@ void fireAndForget_textAndHtml_capturesBothInputFunctions() { new DomEventTrigger(button, "click").triggers( new WriteToClipboardAction(new LiteralInput<>("plain"), - new LiteralInput<>("html"), null)); + new LiteralInput<>("html"))); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -106,7 +106,7 @@ void fireAndForget_htmlOnly_textAndImageSlotsReturnNull() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction(null, - new LiteralInput<>("hi"), null)); + new LiteralInput<>("hi"))); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -134,8 +134,7 @@ void fireAndForget_imageOnly_textAndHtmlSlotsReturnNull() { ui.getElement().appendChild(button.getElement(), img.getElement()); new DomEventTrigger(button, "click") - .triggers(new WriteToClipboardAction(null, null, - new ImageBlobInput(img))); + .triggers(new WriteToClipboardAction(new ImageBlobInput(img))); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -190,7 +189,7 @@ void withCallbacks_actionFnWrapsInnerWithObserverAndChannel() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( new PropertyInput<>(field, "value", String.class), null, - null, copied -> { + copied -> { }, err -> { })); @@ -207,6 +206,31 @@ void withCallbacks_actionFnWrapsInnerWithObserverAndChannel() { assertEquals(HELPER_BODY, inner.getBody()); } + @Test + void withCallbacks_imageOnly_wrapsInnerWithObserverAndChannel() { + UI ui = new MockUI(); + TagComponent button = new TagComponent("button"); + TagComponent img = new TagComponent("img"); + ui.getElement().appendChild(button.getElement(), img.getElement()); + + new DomEventTrigger(button, "click").triggers( + new WriteToClipboardAction(new ImageBlobInput(img), copied -> { + }, err -> { + })); + + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + // The dedicated image observed constructor produces the same outer + // shape as the text/html one — only the inner $2 (image) slot differs. + JsFunction action = actionOf(singleInstallFn(ui)); + assertEquals("$0($1(event), $2)", action.getBody()); + + JsFunction inner = (JsFunction) action.getCaptures().get(1); + assertEquals(HELPER_BODY, inner.getBody()); + JsFunction image = (JsFunction) inner.getCaptures().get(2); + assertSame(img.getElement(), image.getCaptures().get(0)); + } + @Test void onCopied_receivesTheStringFromTheResolvedPromise() { UI ui = new MockUI(); @@ -218,7 +242,7 @@ void onCopied_receivesTheStringFromTheResolvedPromise() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( new PropertyInput<>(field, "value", String.class), null, - null, copied::add, err -> { + copied::add, err -> { })); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -244,7 +268,7 @@ void onCopied_receivesNullWhenJsResolvedWithoutValue() { new DomEventTrigger(button, "click") .triggers(new WriteToClipboardAction( new PropertyInput<>(field, "value", String.class), null, - null, copied::add, err -> { + copied::add, err -> { })); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -261,9 +285,19 @@ void onCopied_receivesNullWhenJsResolvedWithoutValue() { } @Test - void constructor_allInputsNullRejected() { + void constructor_textHtml_bothNullRejected() { + assertThrows(IllegalArgumentException.class, + () -> new WriteToClipboardAction(null, null)); + } + + @Test + void constructor_multiFormat_allInputsNullRejected() { + Action.@Nullable Input nullText = null; + Action.@Nullable Input nullHtml = null; + Action.@Nullable Input nullImage = null; assertThrows(IllegalArgumentException.class, - () -> new WriteToClipboardAction(null, null, null)); + () -> new WriteToClipboardAction(nullText, nullHtml, + nullImage)); } } diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java index 9fe1422b61e..1f9529c27eb 100644 --- a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java @@ -93,24 +93,23 @@ protected void onShow() { Action.Input value = new PropertyInput<>(field, "value", String.class); new ClickTrigger(copyButton).triggers(new WriteToClipboardAction(value, - null, null, copied -> status.setText("ok:" + copied), - err -> status + null, copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message()))); new ClickTrigger(copyStaticButton).triggers(new WriteToClipboardAction( - new LiteralInput<>(STATIC_TEXT), null, null, + new LiteralInput<>(STATIC_TEXT), null, copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message()))); new ClickTrigger(copyMultiButton).triggers(new WriteToClipboardAction( new LiteralInput<>(MULTI_TEXT), new LiteralInput<>(MULTI_HTML), - null, copied -> status.setText("ok:" + copied), err -> status + copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message()))); ValueSignal textSignal = new ValueSignal<>(SIGNAL_INITIAL_TEXT); signalDisplay.getElement().bindText(textSignal); new ClickTrigger(copySignalButton).triggers(new WriteToClipboardAction( - new SignalInput<>(this, textSignal), null, null, + new SignalInput<>(this, textSignal), null, copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message()))); changeSignalButton From 7e21bbaa65270c4437281e8022cb22de72ee99e3 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 29 May 2026 10:19:03 +0300 Subject: [PATCH 3/8] refactor: WriteToClipboardAction takes ClipboardContent for multi-format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClipboardContent is now a passive data holder with public slot getters; WriteToClipboardAction gains constructors that accept a ClipboardContent and read its slots. The text+html and image dedicated constructors delegate into private 3-arg / 5-arg primitives, so the previously public multi-format Action.Input constructors disappear from the API surface. ClipboardBinding.write(content[, callbacks]) now constructs the action as new WriteToClipboardAction(content[, …]). The two existing test cases that called the input-based 3-arg constructor directly now go through ClipboardContent. --- .../component/clipboard/ClipboardBinding.java | 7 +- .../component/clipboard/ClipboardContent.java | 15 +++- .../internal/WriteToClipboardAction.java | 82 +++++++++---------- .../internal/WriteToClipboardActionTest.java | 19 ++--- 4 files changed, 61 insertions(+), 62 deletions(-) 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 7ec022a1088..d49b8b3c345 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 @@ -221,8 +221,7 @@ public void writeImage(Component source, */ public void write(ClipboardContent content) { Objects.requireNonNull(content, "content must not be null"); - bind(new WriteToClipboardAction(content.getTextInput(), - content.getHtmlInput(), content.getImageInput())); + bind(new WriteToClipboardAction(content)); } /** @@ -243,9 +242,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(), content.getImageInput(), 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 acc99916559..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 @@ -122,15 +122,24 @@ public ClipboardContent image(Component source) { return this; } - Action.@Nullable Input getTextInput() { + /** + * @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; } - Action.@Nullable Input getImageInput() { + /** + * @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/trigger/internal/WriteToClipboardAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java index d1f61a9ecde..d097f882de2 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java @@ -19,6 +19,7 @@ import org.jspecify.annotations.Nullable; +import com.vaadin.flow.component.clipboard.ClipboardContent; import com.vaadin.flow.dom.JsFunction; import com.vaadin.flow.function.SerializableConsumer; @@ -38,7 +39,8 @@ *

    *
  • Text/HTML — the typical case for copying a string
  • *
  • Image — the typical case for copying an image
  • - *
  • Multi-format — combine any of the three slots in one item
  • + *
  • Multi-format via {@link ClipboardContent} — combine any of the three + * slots in one item
  • *
* {@code onCopied} receives the exact string that was copied — the * {@code text/plain} value if present, otherwise the {@code text/html} value, @@ -145,53 +147,29 @@ public WriteToClipboardAction(Action.Input imageInput, } /** - * Creates a fire-and-forget multi-format clipboard-copy action. Prefer - * {@link #WriteToClipboardAction(Action.Input, Action.Input)} for the - * text/HTML case or {@link #WriteToClipboardAction(Action.Input)} for the - * image case; use this constructor when an item needs to carry more than - * one of the three MIME types. + * Creates a fire-and-forget multi-format clipboard-copy action from a + * {@link ClipboardContent} describing the payload. Use + * {@code Clipboard.onClick(...).write(content)} as the typical entry point. * - * @param textInput - * input producing the {@code text/plain} payload, or - * {@code null} to omit - * @param htmlInput - * input producing the {@code text/html} payload, or {@code null} - * to omit - * @param imageInput - * input producing the source {@code } for the - * {@code image/png} payload (typically an - * {@link ImageBlobInput}), or {@code null} to omit + * @param content + * the clipboard payload, not {@code null}; must have at least + * one slot set * @throws IllegalArgumentException - * if all three inputs are {@code null} + * if {@code content} has no slots set */ - public WriteToClipboardAction(Action.@Nullable Input textInput, - Action.@Nullable Input htmlInput, - Action.@Nullable Input imageInput) { - super(); - validate(textInput, htmlInput, imageInput); - this.textInput = textInput; - this.htmlInput = htmlInput; - this.imageInput = imageInput; + public WriteToClipboardAction(ClipboardContent content) { + this(Objects.requireNonNull(content, "content must not be null") + .getTextInput(), content.getHtmlInput(), + content.getImageInput()); } /** - * Creates a multi-format clipboard-copy action whose outcome is reported - * back to the server. Prefer - * {@link #WriteToClipboardAction(Action.Input, Action.Input, SerializableConsumer, SerializableConsumer)} - * for the text/HTML case or - * {@link #WriteToClipboardAction(Action.Input, SerializableConsumer, SerializableConsumer)} - * for the image case; use this constructor when an item needs to carry more - * than one of the three MIME types. + * Creates a multi-format clipboard-copy action from a + * {@link ClipboardContent} whose outcome is reported back to the server. * - * @param textInput - * input producing the {@code text/plain} payload, or - * {@code null} to omit - * @param htmlInput - * input producing the {@code text/html} payload, or {@code null} - * to omit - * @param imageInput - * input producing the source {@code } for the - * {@code image/png} payload, or {@code null} to omit + * @param content + * the clipboard payload, not {@code null}; must have at least + * one slot set * @param onCopied * invoked on the UI thread with the string that was copied after * the client reports the write resolved ({@code text/plain} if @@ -202,9 +180,27 @@ public WriteToClipboardAction(Action.@Nullable Input textInput, * invoked on the UI thread with the browser's error after the * client reports the write rejected, not {@code null} * @throws IllegalArgumentException - * if all three inputs are {@code null} + * if {@code content} has no slots set */ - public WriteToClipboardAction(Action.@Nullable Input textInput, + public WriteToClipboardAction(ClipboardContent content, + SerializableConsumer<@Nullable String> onCopied, + SerializableConsumer onError) { + this(Objects.requireNonNull(content, "content must not be null") + .getTextInput(), content.getHtmlInput(), + content.getImageInput(), onCopied, onError); + } + + private WriteToClipboardAction(Action.@Nullable Input textInput, + Action.@Nullable Input htmlInput, + Action.@Nullable Input imageInput) { + super(); + validate(textInput, htmlInput, imageInput); + this.textInput = textInput; + this.htmlInput = htmlInput; + this.imageInput = imageInput; + } + + private WriteToClipboardAction(Action.@Nullable Input textInput, Action.@Nullable Input htmlInput, Action.@Nullable Input imageInput, SerializableConsumer<@Nullable String> onCopied, diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java index 37224ee7930..b296fc137a5 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java @@ -25,6 +25,7 @@ import tools.jackson.databind.node.ObjectNode; import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.clipboard.ClipboardContent; import com.vaadin.flow.dom.JsFunction; import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.tests.util.MockUI; @@ -155,16 +156,16 @@ void fireAndForget_imageOnly_textAndHtmlSlotsReturnNull() { } @Test - void fireAndForget_allThreeSlots_eachInputCapturedInOrder() { + void fireAndForget_allThreeSlotsViaContent_eachInputCapturedInOrder() { UI ui = new MockUI(); TagComponent button = new TagComponent("button"); TagComponent img = new TagComponent("img"); ui.getElement().appendChild(button.getElement(), img.getElement()); - new DomEventTrigger(button, "click").triggers( - new WriteToClipboardAction(new LiteralInput<>("plain"), - new LiteralInput<>("html"), - new ImageBlobInput(img))); + ClipboardContent content = ClipboardContent.create().text("plain") + .html("html").image(img); + new DomEventTrigger(button, "click") + .triggers(new WriteToClipboardAction(content)); ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); @@ -291,13 +292,9 @@ void constructor_textHtml_bothNullRejected() { } @Test - void constructor_multiFormat_allInputsNullRejected() { - Action.@Nullable Input nullText = null; - Action.@Nullable Input nullHtml = null; - Action.@Nullable Input nullImage = null; + void constructor_emptyContent_rejected() { assertThrows(IllegalArgumentException.class, - () -> new WriteToClipboardAction(nullText, nullHtml, - nullImage)); + () -> new WriteToClipboardAction(ClipboardContent.create())); } } From 6bfd565214f4f56314ea8bafd36929cd81e0a17b Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 29 May 2026 11:06:32 +0300 Subject: [PATCH 4/8] feat: writeImage(DownloadHandler) for server-defined images Adds writeImage(DownloadHandler) and its observed counterpart on ClipboardBinding so server-defined image bytes can be copied to the clipboard without the caller having to add a hidden Image to the page themselves. The overload appends a display:none child to the trigger host, bound to the handler via the same setAttribute path Image.setSrc(DownloadHandler) uses, then routes it through ImageBlobInput. ImageBlobInput gains an Element-accepting constructor so the binding can hand it the freshly built element without wrapping it in a Component. The browser begins fetching the image as soon as the binding is set up, so the bytes are typically decoded before the user clicks. If the click races the load, ImageBlobInput's canvas converter falls back to the 's load event before drawing. --- .../component/clipboard/ClipboardBinding.java | 63 +++++++++++++++++++ .../trigger/internal/ImageBlobInput.java | 25 ++++++-- 2 files changed, 83 insertions(+), 5 deletions(-) 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 d49b8b3c345..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,13 +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*} @@ -210,6 +214,65 @@ public void writeImage(Component source, 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}. diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java index 43e29b69f59..074a655f07e 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ImageBlobInput.java @@ -50,13 +50,28 @@ public class ImageBlobInput extends Action.Input { * if the source's root element is not an {@code } */ public ImageBlobInput(Component source) { - Element element = Objects.requireNonNull(source).getElement(); - if (!Tag.IMG.equals(element.getTag())) { + this(Objects.requireNonNull(source, "source must not be null") + .getElement()); + } + + /** + * Creates an image input that yields the given element as the source + * {@code }. + * + * @param source + * the source {@code } element, not {@code null}; its tag + * must be {@code img} + * @throws IllegalArgumentException + * if the source element is not an {@code } + */ + public ImageBlobInput(Element source) { + Objects.requireNonNull(source, "source must not be null"); + if (!Tag.IMG.equals(source.getTag())) { throw new IllegalArgumentException( - "source root element must be , was <" - + element.getTag() + ">"); + "source element must be , was <" + source.getTag() + + ">"); } - this.source = element; + this.source = source; } @Override From 8ce807664112d250dbed2defa32c0eef9d8aabfb Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 29 May 2026 11:20:30 +0300 Subject: [PATCH 5/8] test: IT coverage for image clipboard slots Adds three IT scenarios to TriggerWriteToClipboardView/IT: - writeImage(Image) with an in-DOM data-URL - write(ClipboardContent.text + image) packing both into one ClipboardItem - writeImage(DownloadHandler) with a server-served PNG generated at view-class load via ImageIO The recording shim in the IT awaits Promise entries from the ClipboardItem and normalises them to {type, size}, so the assertions can inspect the resulting image/png blob without dealing with binary content. --- .../ui/TriggerWriteToClipboardView.java | 78 +++++++++++- .../uitest/ui/TriggerWriteToClipboardIT.java | 116 +++++++++++++++++- 2 files changed, 185 insertions(+), 9 deletions(-) diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java index 1f9529c27eb..43586f04c15 100644 --- a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java @@ -15,17 +15,33 @@ */ package com.vaadin.flow.uitest.ui; +import javax.imageio.ImageIO; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; + +import com.vaadin.flow.component.clipboard.Clipboard; +import com.vaadin.flow.component.clipboard.ClipboardContent; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Image; import com.vaadin.flow.component.html.Input; import com.vaadin.flow.component.html.NativeButton; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.trigger.internal.Action; import com.vaadin.flow.component.trigger.internal.ClickTrigger; +import com.vaadin.flow.component.trigger.internal.ImageBlobInput; import com.vaadin.flow.component.trigger.internal.LiteralInput; import com.vaadin.flow.component.trigger.internal.PropertyInput; import com.vaadin.flow.component.trigger.internal.SignalInput; import com.vaadin.flow.component.trigger.internal.WriteToClipboardAction; import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadResponse; import com.vaadin.flow.signals.local.ValueSignal; import com.vaadin.flow.uitest.servlet.ViewTestLayout; @@ -35,10 +51,13 @@ * one copies a fixed string with embedded escape characters to verify JSON * encoding round-trips, one packs both {@code text/plain} and {@code text/html} * into a single {@link com.vaadin.flow.component.html.Div ClipboardItem} to - * verify the multi-format path resolves with the text value, and one copies the + * verify the multi-format path resolves with the text value, one copies the * current value of a server-side {@link ValueSignal} (via {@link SignalInput}) * — a second button mutates the signal so the IT can verify the copied value - * tracks the server signal. A {@link Span} bound to the signal via + * tracks the server signal, one copies an {@link Image} as {@code image/png} + * (via {@link ImageBlobInput}), one packs text and image into one + * {@link ClipboardContent}, and one copies an image served by a + * {@link DownloadHandler}. A {@link Span} bound to the signal via * {@code bindText} acts as the sync point. Each action's success/error * consumers write the outcome into the status {@link Div}. The IT replaces * {@code navigator.clipboard.write} and {@code ClipboardItem} with recording @@ -67,6 +86,29 @@ public class TriggerWriteToClipboardView extends AbstractDivView { */ static final String SIGNAL_UPDATED_TEXT = "signal updated"; + /** A 4x4 red PNG generated at class load and reused across image cases. */ + static final byte[] PNG_BYTES = generatePng(); + + /** Data-URL form of {@link #PNG_BYTES} for the in-DOM Image source. */ + static final String PNG_DATA_URL = "data:image/png;base64," + + Base64.getEncoder().encodeToString(PNG_BYTES); + + private static byte[] generatePng() { + try { + BufferedImage img = new BufferedImage(4, 4, + BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.RED); + g.fillRect(0, 0, 4, 4); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return baos.toByteArray(); + } catch (IOException e) { + throw new ExceptionInInitializerError(e); + } + } + @Override protected void onShow() { Input field = new Input(); @@ -82,13 +124,25 @@ protected void onShow() { NativeButton changeSignalButton = new NativeButton( "Change signal value"); changeSignalButton.setId("change-signal"); + NativeButton copyImageButton = new NativeButton("Copy image"); + copyImageButton.setId("copy-image"); + NativeButton copyMultiImageButton = new NativeButton( + "Copy text + image"); + copyMultiImageButton.setId("copy-multi-image"); + NativeButton copyImageHandlerButton = new NativeButton( + "Copy image via handler"); + copyImageHandlerButton.setId("copy-image-handler"); + Image sourceImage = new Image(PNG_DATA_URL, "test source"); + sourceImage.setId("source-image"); Span signalDisplay = new Span(); signalDisplay.setId("signal-value"); Div status = new Div(); status.setId("status"); add(field, copyButton, copyStaticButton, copyMultiButton, - copySignalButton, changeSignalButton, signalDisplay, status); + copySignalButton, changeSignalButton, copyImageButton, + copyMultiImageButton, copyImageHandlerButton, sourceImage, + signalDisplay, status); Action.Input value = new PropertyInput<>(field, "value", String.class); @@ -114,5 +168,23 @@ protected void onShow() { .setText("err:" + err.name() + ":" + err.message()))); changeSignalButton .addClickListener(e -> textSignal.set(SIGNAL_UPDATED_TEXT)); + + new ClickTrigger(copyImageButton).triggers(new WriteToClipboardAction( + new ImageBlobInput(sourceImage), + copied -> status.setText("ok:" + copied), err -> status + .setText("err:" + err.name() + ":" + err.message()))); + + Clipboard.onClick(copyMultiImageButton).write( + ClipboardContent.create().text(MULTI_TEXT).image(sourceImage), + copied -> status.setText("ok:" + copied), err -> status + .setText("err:" + err.name() + ":" + err.message())); + + DownloadHandler imageHandler = DownloadHandler + .fromInputStream(event -> new DownloadResponse( + new ByteArrayInputStream(PNG_BYTES), "test.png", + "image/png", PNG_BYTES.length)); + Clipboard.onClick(copyImageHandlerButton).writeImage(imageHandler, + copied -> status.setText("ok:" + copied), err -> status + .setText("err:" + err.name() + ":" + err.message())); } } diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java index ec241f1ff3c..765bbc78a2b 100644 --- a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java @@ -143,6 +143,99 @@ public void clickCopiesSignalValue_andTracksSignalAfterChange() { .equals(status.getText())); } + @Test + public void clickCopiesImage_emitsImagePngBlobIntoClipboardItem() { + open(); + installResolvingClipboardShim(); + + // Wait for the source to finish loading so naturalWidth/ + // naturalHeight are populated before the canvas converter runs. + waitUntil( + d -> Boolean.TRUE.equals(((JavascriptExecutor) d).executeScript( + "var i = document.getElementById('source-image');" + + "return i && i.complete && i.naturalWidth > 0;"))); + + WebElement button = findElement(By.id("copy-image")); + WebElement status = findElement(By.id("status")); + + button.click(); + + Object written = waitUntil(d -> ((JavascriptExecutor) d) + .executeScript("return window.__written;")); + java.util.Map entries = (java.util.Map) written; + java.util.Map imageEntry = (java.util.Map) entries + .get("image/png"); + Assert.assertNotNull("image/png entry expected", imageEntry); + Assert.assertEquals("image/png", imageEntry.get("type")); + Assert.assertTrue("blob should have non-zero size", + ((Number) imageEntry.get("size")).longValue() > 0); + + // Image-only write resolves with null per the dedicated image + // constructor's contract; onCopied receives "null". + waitUntil(d -> "ok:null".equals(status.getText())); + } + + @Test + public void clickCopiesTextAndImage_packsBothIntoOneClipboardItem() { + open(); + installResolvingClipboardShim(); + + waitUntil( + d -> Boolean.TRUE.equals(((JavascriptExecutor) d).executeScript( + "var i = document.getElementById('source-image');" + + "return i && i.complete && i.naturalWidth > 0;"))); + + WebElement button = findElement(By.id("copy-multi-image")); + WebElement status = findElement(By.id("status")); + + button.click(); + + Object written = waitUntil(d -> ((JavascriptExecutor) d) + .executeScript("return window.__written;")); + java.util.Map entries = (java.util.Map) written; + Assert.assertEquals(TriggerWriteToClipboardView.MULTI_TEXT, + entries.get("text/plain")); + java.util.Map imageEntry = (java.util.Map) entries + .get("image/png"); + Assert.assertNotNull("image/png entry expected", imageEntry); + Assert.assertEquals("image/png", imageEntry.get("type")); + + // text wins over image as the onCopied value. + waitUntil(d -> ("ok:" + TriggerWriteToClipboardView.MULTI_TEXT) + .equals(status.getText())); + } + + @Test + public void clickCopiesImageViaDownloadHandler_emitsImagePngBlob() { + open(); + installResolvingClipboardShim(); + + // The binding has attached a hidden child to the host button; + // wait for that one to load too. + waitUntil( + d -> Boolean.TRUE.equals(((JavascriptExecutor) d).executeScript( + "var b = document.getElementById('copy-image-handler');" + + "var i = b && b.querySelector('img');" + + "return i && i.complete && i.naturalWidth > 0;"))); + + WebElement button = findElement(By.id("copy-image-handler")); + WebElement status = findElement(By.id("status")); + + button.click(); + + Object written = waitUntil(d -> ((JavascriptExecutor) d) + .executeScript("return window.__written;")); + java.util.Map entries = (java.util.Map) written; + java.util.Map imageEntry = (java.util.Map) entries + .get("image/png"); + Assert.assertNotNull("image/png entry expected", imageEntry); + Assert.assertEquals("image/png", imageEntry.get("type")); + Assert.assertTrue("blob should have non-zero size", + ((Number) imageEntry.get("size")).longValue() > 0); + + waitUntil(d -> "ok:null".equals(status.getText())); + } + @Test public void writeRejection_propagatesAsFailureWithNameAndMessage() { open(); @@ -162,17 +255,28 @@ public void writeRejection_propagatesAsFailureWithNameAndMessage() { } private void installResolvingClipboardShim() { - // ClipboardItem is stubbed to record its entries map (the WriteAction - // emits string values for text/plain and text/html). navigator - // .clipboard.write resolves immediately and stores the first item's - // entries on window.__written for the assertions to read. + // ClipboardItem is stubbed to record its entries map. The shim handles + // three value shapes: string values (text/plain and text/html, emitted + // verbatim by the action), Blob values, and Promise values (the + // image/png slot, which is fed as a promise so navigator.clipboard + // .write stays synchronous inside the user gesture). Promises and + // Blobs are normalised to {type, size} so the assertions can inspect + // the resulting blob without dealing with binary content. ((JavascriptExecutor) getDriver()) .executeScript("window.__written = null;" + "window.ClipboardItem = function(items) { return { items: items }; };" + "Object.defineProperty(navigator, 'clipboard', {" + " configurable: true, value: {" - + " write: items => { window.__written = items[0].items; return Promise.resolve(); }" - + " }" + "});"); + + " write: async items => {" + + " const entries = items[0].items;" + + " const resolved = {};" + + " for (const k of Object.keys(entries)) {" + + " const v = entries[k];" + + " if (typeof v === 'string') { resolved[k] = v; }" + + " else if (v instanceof Blob) { resolved[k] = {type: v.type, size: v.size}; }" + + " else { const b = await v; resolved[k] = {type: b.type, size: b.size}; }" + + " }" + " window.__written = resolved;" + + " }" + " }" + "});"); } private void installRejectingClipboardShim() { From 898e960a6b0ef112640e0cc6997c8f73074ef95e Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 29 May 2026 11:28:37 +0300 Subject: [PATCH 6/8] test: section the clipboard view for manual smoke-testing Restructures TriggerWriteToClipboardView into headed sections, each with a one-line description of what should land in the clipboard so a manual tester can paste into an external app and verify. Adds: - visible 32x32 image sources (was 4x4; too small to see) - a distinct blue image for writeImage(DownloadHandler), so a pasted result makes it obvious which button was used - a "Copy text + html + image" button exercising all three slots in one ClipboardItem, with a matching IT case --- .../ui/TriggerWriteToClipboardView.java | 153 ++++++++++++++---- .../uitest/ui/TriggerWriteToClipboardIT.java | 32 ++++ 2 files changed, 151 insertions(+), 34 deletions(-) diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java index 43586f04c15..41b0c2a7d6f 100644 --- a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardView.java @@ -25,12 +25,16 @@ import java.io.IOException; import java.util.Base64; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.clipboard.Clipboard; import com.vaadin.flow.component.clipboard.ClipboardContent; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Hr; import com.vaadin.flow.component.html.Image; import com.vaadin.flow.component.html.Input; import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.trigger.internal.Action; import com.vaadin.flow.component.trigger.internal.ClickTrigger; @@ -46,22 +50,18 @@ import com.vaadin.flow.uitest.servlet.ViewTestLayout; /** - * Buttons exercising {@link WriteToClipboardAction}: one copies the current - * value of an {@link Input} as {@code text/plain} (via {@link PropertyInput}), - * one copies a fixed string with embedded escape characters to verify JSON - * encoding round-trips, one packs both {@code text/plain} and {@code text/html} - * into a single {@link com.vaadin.flow.component.html.Div ClipboardItem} to - * verify the multi-format path resolves with the text value, one copies the - * current value of a server-side {@link ValueSignal} (via {@link SignalInput}) - * — a second button mutates the signal so the IT can verify the copied value - * tracks the server signal, one copies an {@link Image} as {@code image/png} - * (via {@link ImageBlobInput}), one packs text and image into one - * {@link ClipboardContent}, and one copies an image served by a - * {@link DownloadHandler}. A {@link Span} bound to the signal via - * {@code bindText} acts as the sync point. Each action's success/error - * consumers write the outcome into the status {@link Div}. The IT replaces - * {@code navigator.clipboard.write} and {@code ClipboardItem} with recording - * shims so the assertions don't depend on browser clipboard permissions. + * Buttons exercising {@link WriteToClipboardAction}, grouped into sections so + * the view doubles as a manual smoke-test page. The sections cover text inputs + * (an input field's current value, a literal string with escape characters, + * combined plain text and HTML), a server-side {@link ValueSignal}, and the + * three image flavours: an {@link Image} already on the page, the same image + * packed with text into one {@link ClipboardContent}, and a separate image + * served by a {@link DownloadHandler} (intentionally a different colour so + * pasted output makes it obvious which button was used). Each action's + * success/error consumers write the outcome into the status {@link Div}. The IT + * replaces {@code navigator.clipboard.write} and {@code ClipboardItem} with + * recording shims so the assertions don't depend on browser clipboard + * permissions; manual users can paste into any external app to verify. */ @Route(value = "com.vaadin.flow.uitest.ui.TriggerWriteToClipboardView", layout = ViewTestLayout.class) public class TriggerWriteToClipboardView extends AbstractDivView { @@ -86,20 +86,27 @@ public class TriggerWriteToClipboardView extends AbstractDivView { */ static final String SIGNAL_UPDATED_TEXT = "signal updated"; - /** A 4x4 red PNG generated at class load and reused across image cases. */ - static final byte[] PNG_BYTES = generatePng(); + /** Red 32x32 PNG used by the in-DOM Image source. */ + static final byte[] PNG_BYTES = generatePng(Color.RED); + + /** + * Blue 32x32 PNG served by the DownloadHandler — different colour from + * {@link #PNG_BYTES} so a manual paste makes it obvious which button was + * used. + */ + static final byte[] HANDLER_PNG_BYTES = generatePng(Color.BLUE); /** Data-URL form of {@link #PNG_BYTES} for the in-DOM Image source. */ static final String PNG_DATA_URL = "data:image/png;base64," + Base64.getEncoder().encodeToString(PNG_BYTES); - private static byte[] generatePng() { + private static byte[] generatePng(Color color) { try { - BufferedImage img = new BufferedImage(4, 4, + BufferedImage img = new BufferedImage(32, 32, BufferedImage.TYPE_INT_RGB); Graphics2D g = img.createGraphics(); - g.setColor(Color.RED); - g.fillRect(0, 0, 4, 4); + g.setColor(color); + g.fillRect(0, 0, 32, 32); g.dispose(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(img, "png", baos); @@ -111,38 +118,99 @@ private static byte[] generatePng() { @Override protected void onShow() { + Div status = new Div(); + status.setId("status"); + + // --- Text sections --------------------------------------------- + Input field = new Input(); field.setId("source"); NativeButton copyButton = new NativeButton("Copy input value"); copyButton.setId("copy"); + addSection("Copy input value as text/plain", + "Pastes the current value of the input field below.", field, + copyButton); + NativeButton copyStaticButton = new NativeButton("Copy static text"); copyStaticButton.setId("copy-static"); + addSection("Copy a literal string as text/plain", + "Pastes the literal: " + STATIC_TEXT.replace("\n", "\\n") + + " (quotes and trailing newline included).", + copyStaticButton); + NativeButton copyMultiButton = new NativeButton("Copy text + html"); copyMultiButton.setId("copy-multi"); + addSection("Copy plain text and HTML together", + "Pastes \"" + MULTI_TEXT + "\" into a plain editor or " + + MULTI_HTML + " into a rich-text editor.", + copyMultiButton); + + // --- Signal section -------------------------------------------- + NativeButton copySignalButton = new NativeButton("Copy signal value"); copySignalButton.setId("copy-signal"); NativeButton changeSignalButton = new NativeButton( "Change signal value"); changeSignalButton.setId("change-signal"); + Span signalDisplay = new Span(); + signalDisplay.setId("signal-value"); + addSection("Copy a server-side signal value as text/plain", + "Pastes the signal's current value. Use \"Change signal value\"" + + " to mutate it; the value below updates in lockstep" + + " with what the next copy click will produce.", + copySignalButton, changeSignalButton, new Span(" current: "), + signalDisplay); + + // --- Image sections -------------------------------------------- + + Image sourceImage = new Image(PNG_DATA_URL, "test source"); + sourceImage.setId("source-image"); NativeButton copyImageButton = new NativeButton("Copy image"); copyImageButton.setId("copy-image"); + addSection("Copy an already on the page as image/png", + "Pastes this red square into an image-capable target" + + " (e.g. a chat app or document editor).", + sourceImage, copyImageButton); + NativeButton copyMultiImageButton = new NativeButton( "Copy text + image"); copyMultiImageButton.setId("copy-multi-image"); + addSection("Copy text and an image into one ClipboardItem", + "Pastes \"" + MULTI_TEXT + + "\" into a plain editor or the red square (same" + + " as above) into an image-capable target.", + copyMultiImageButton); + + NativeButton copyAllSlotsButton = new NativeButton( + "Copy text + html + image"); + copyAllSlotsButton.setId("copy-all-slots"); + addSection("Copy all three slots into one ClipboardItem", + "Pastes \"" + MULTI_TEXT + "\" into a plain editor, " + + MULTI_HTML + + " into a rich-text editor, or the red square into an" + + " image-capable target — whichever representation" + + " the paste target prefers.", + copyAllSlotsButton); + NativeButton copyImageHandlerButton = new NativeButton( "Copy image via handler"); copyImageHandlerButton.setId("copy-image-handler"); - Image sourceImage = new Image(PNG_DATA_URL, "test source"); - sourceImage.setId("source-image"); - Span signalDisplay = new Span(); - signalDisplay.setId("signal-value"); - Div status = new Div(); - status.setId("status"); + addSection("Copy a server-served image as image/png", + "Pastes a blue square — different bytes from the red one" + + " above, served by a DownloadHandler on the server." + + " The the binding creates is hidden, so what" + + " you copy is intentionally not visible on the page.", + copyImageHandlerButton); + + // --- Outcome status -------------------------------------------- - add(field, copyButton, copyStaticButton, copyMultiButton, - copySignalButton, changeSignalButton, copyImageButton, - copyMultiImageButton, copyImageHandlerButton, sourceImage, - signalDisplay, status); + add(new Hr(), new H2("Last action outcome"), + new Paragraph("Each button's onCopied/onError callback writes" + + " here. \"ok:\" means the write resolved;" + + " for image-only writes is \"null\"."), + status); + + // --- Wire up triggers ------------------------------------------ Action.Input value = new PropertyInput<>(field, "value", String.class); @@ -179,12 +247,29 @@ protected void onShow() { copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message())); + Clipboard.onClick(copyAllSlotsButton).write( + ClipboardContent.create().text(MULTI_TEXT).html(MULTI_HTML) + .image(sourceImage), + copied -> status.setText("ok:" + copied), err -> status + .setText("err:" + err.name() + ":" + err.message())); + DownloadHandler imageHandler = DownloadHandler .fromInputStream(event -> new DownloadResponse( - new ByteArrayInputStream(PNG_BYTES), "test.png", - "image/png", PNG_BYTES.length)); + new ByteArrayInputStream(HANDLER_PNG_BYTES), + "handler.png", "image/png", HANDLER_PNG_BYTES.length)); Clipboard.onClick(copyImageHandlerButton).writeImage(imageHandler, copied -> status.setText("ok:" + copied), err -> status .setText("err:" + err.name() + ":" + err.message())); } + + private void addSection(String heading, String description, + Component... contents) { + Div section = new Div(); + section.getStyle().set("margin", "1em 0").set("padding", "0.5em 0") + .set("border-top", "1px solid #ccc"); + section.add(new H2(heading)); + section.add(new Paragraph(description)); + section.add(contents); + add(section); + } } diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java index 765bbc78a2b..058387dedc9 100644 --- a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerWriteToClipboardIT.java @@ -205,6 +205,38 @@ public void clickCopiesTextAndImage_packsBothIntoOneClipboardItem() { .equals(status.getText())); } + @Test + public void clickCopiesAllThreeSlots_packsTextHtmlAndImageIntoOneClipboardItem() { + open(); + installResolvingClipboardShim(); + + waitUntil( + d -> Boolean.TRUE.equals(((JavascriptExecutor) d).executeScript( + "var i = document.getElementById('source-image');" + + "return i && i.complete && i.naturalWidth > 0;"))); + + WebElement button = findElement(By.id("copy-all-slots")); + WebElement status = findElement(By.id("status")); + + button.click(); + + Object written = waitUntil(d -> ((JavascriptExecutor) d) + .executeScript("return window.__written;")); + java.util.Map entries = (java.util.Map) written; + Assert.assertEquals(TriggerWriteToClipboardView.MULTI_TEXT, + entries.get("text/plain")); + Assert.assertEquals(TriggerWriteToClipboardView.MULTI_HTML, + entries.get("text/html")); + java.util.Map imageEntry = (java.util.Map) entries + .get("image/png"); + Assert.assertNotNull("image/png entry expected", imageEntry); + Assert.assertEquals("image/png", imageEntry.get("type")); + + // text wins over html and image as the onCopied value. + waitUntil(d -> ("ok:" + TriggerWriteToClipboardView.MULTI_TEXT) + .equals(status.getText())); + } + @Test public void clickCopiesImageViaDownloadHandler_emitsImagePngBlob() { open(); From f90145757a903d7a8d83b79add8724825a643e9e Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 29 May 2026 22:20:52 +0300 Subject: [PATCH 7/8] feat: add Clipboard.onPaste for server-side paste handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Clipboard.onPaste(Component, listener) — and a PasteOptions overload — that forwards the browser's native paste event to a server-side listener as a PasteEvent carrying text/plain, text/html, the source Component, and the closest Flow-tracked target Element. Pass any Component for scope: the listener fires for pastes targeting that component or its descendants. For UI-wide scope, pass the UI. Internals: - The listener is a plain Element.addEventListener("paste", ...) wrapper. Two addEventData JS expressions pull text/plain and text/html out of event.clipboardData; `?.` guards synthetic events without a DataTransfer and `|| null` collapses the browser's "" to JSON null at the wire boundary so callers don't need !isEmpty() guards. - PasteOptions.defaults() installs a setFilter that walks event.composedPath() and rejects pastes whose target (or any ancestor in the composed path, including through open shadow DOMs like 's inner ) is an input, textarea, or contenteditable element. Filtering happens client-side, so skipped pastes never round-trip. PasteOptions.includingInputFields() is the no-filter form and is the default when no options are passed. - getTargetElement() is populated via mapEventTargetElement(): the browser walks event.target's DOM ancestors to find the closest state-node element. Using composedPath via that mechanism means the result reflects DOM hierarchy, not the server-side state tree (which can diverge for virtual children, slotted content, etc.). Callers reach the enclosing Component via Element.getComponent(). - No UI.getCurrent dependency: the DOM listener binds directly to the component's element and is applied on attach, so callers from a background thread can register without needing the UI lock to set up. Out of scope for this commit: file/image paste, copy/cut listeners, and a preventDefault hook. Tests cover the wiring (component + UI hosts, with and without a current UI), and a TriggerPasteIT dispatches synthetic ClipboardEvents with a populated DataTransfer to verify text-only, html-only, both, neither, empty-string collapse, and the editable-target filter (a paste on a sibling is skipped by the default options and fires only with includingInputFields()). --- .../flow/component/clipboard/Clipboard.java | 150 ++++++++++++++++ .../flow/component/clipboard/PasteEvent.java | 161 ++++++++++++++++++ .../component/clipboard/PasteOptions.java | 61 +++++++ .../component/clipboard/ClipboardTest.java | 70 ++++++++ .../flow/uitest/ui/TriggerPasteView.java | 82 +++++++++ .../vaadin/flow/uitest/ui/TriggerPasteIT.java | 129 ++++++++++++++ 6 files changed, 653 insertions(+) create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/clipboard/PasteEvent.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/clipboard/PasteOptions.java create mode 100644 flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerPasteView.java create mode 100644 flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/TriggerPasteIT.java 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..e33e9f0328e 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 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 + * {@link com.vaadin.flow.component.html.Div 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 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/PasteEvent.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/PasteEvent.java new file mode 100644 index 00000000000..6757ef0913a --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/PasteEvent.java @@ -0,0 +1,161 @@ +/* + * 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 + * {@link com.vaadin.flow.component.html.Div Div}) need to be made focusable + * — typically via {@code tabindex="0"} — before they will receive + * paste events.
  • + *
  • On editable targets ({@code }, {@code