diff --git a/flow-server/src/main/java/com/vaadin/flow/component/UI.java b/flow-server/src/main/java/com/vaadin/flow/component/UI.java index 175ecae6928..44d9e7903f6 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/UI.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/UI.java @@ -108,6 +108,7 @@ * @since 1.0 */ @JsModule("@vaadin/common-frontend/ConnectionIndicator.js") +@JsModule("./clipboard.js") public class UI extends Component implements PollNotifier, HasComponents, RouterLayout { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Clipboard.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Clipboard.java new file mode 100644 index 00000000000..06ba4a2d601 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Clipboard.java @@ -0,0 +1,549 @@ +/* + * 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.page; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import tools.jackson.databind.JsonNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.dom.DomListenerRegistration; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.internal.nodefeature.ReturnChannelMap; +import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration; +import com.vaadin.flow.server.Command; +import com.vaadin.flow.server.StreamRegistration; +import com.vaadin.flow.server.StreamResource; +import com.vaadin.flow.server.streams.UploadHandler; +import com.vaadin.flow.server.streams.UploadMetadata; +import com.vaadin.flow.shared.Registration; + +/** + * Provides access to the browser Clipboard API. + *

+ * This interface offers two categories of clipboard operations: + *

+ *

+ * Usage pattern follows {@link WebStorage} — static methods that use + * {@link UI#getCurrentOrThrow()} by default. + */ +public interface Clipboard extends Serializable { + + // --- Client-side copy (reliable, no round-trip) --- + + /** + * Sets up a client-side click handler on the trigger component that copies + * the given text to the clipboard when clicked. + *

+ * The copy operation executes entirely on the client side within the click + * event handler, so it satisfies the user gesture requirement in all + * browsers. + * + * @param trigger + * the component whose clicks trigger the copy + * @param text + * the text to copy to the clipboard + * @return a {@link ClipboardCopy} handle for updating the text or removing + * the handler + */ + static ClipboardCopy copyOnClick(Component trigger, String text) { + Objects.requireNonNull(trigger, "Trigger component must not be null"); + + Element element = trigger.getElement(); + element.setProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY, + text != null ? text : ""); + + element.executeJs( + "window.Vaadin.Flow.clipboard.setupCopyOnClick(this)"); + + Registration cleanup = Registration.once(() -> { + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)"); + }); + + return new ClipboardCopy(element, cleanup); + } + + /** + * Sets up a client-side click handler on the trigger component that copies + * the given text to the clipboard, with success and error callbacks. + *

+ * The copy operation executes on the client side. The callbacks are invoked + * on the server after the clipboard operation completes or fails. + * + * @param trigger + * the component whose clicks trigger the copy + * @param text + * the text to copy to the clipboard + * @param onSuccess + * callback invoked on the server when the copy succeeds + * @param onError + * callback invoked on the server when the copy fails + * @return a {@link ClipboardCopy} handle for updating the text or removing + * the handler + */ + static ClipboardCopy copyOnClick(Component trigger, String text, + Command onSuccess, Command onError) { + Objects.requireNonNull(trigger, "Trigger component must not be null"); + + Element element = trigger.getElement(); + element.setProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY, + text != null ? text : ""); + + ReturnChannelRegistration successChannel = element.getNode() + .getFeature(ReturnChannelMap.class).registerChannel(args -> { + if (onSuccess != null) { + onSuccess.execute(); + } + }); + + ReturnChannelRegistration errorChannel = element.getNode() + .getFeature(ReturnChannelMap.class).registerChannel(args -> { + if (onError != null) { + onError.execute(); + } + }); + + element.executeJs( + "window.Vaadin.Flow.clipboard.setupCopyOnClickWithCallbacks(this, $0, $1)", + successChannel, errorChannel); + + Registration cleanup = Registration.once(() -> { + successChannel.remove(); + errorChannel.remove(); + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)"); + }); + + return new ClipboardCopy(element, cleanup); + } + + /** + * Sets up a client-side click handler on the trigger component that copies + * the current value of the source component to the clipboard. + *

+ * The source component's value is read client-side (from the DOM element's + * {@code value} or {@code textContent} property) at click time, so no + * server round-trip is needed. + * + * @param trigger + * the component whose clicks trigger the copy + * @param source + * the component whose value to copy + * @return a {@link ClipboardCopy} handle for removing the handler + */ + static ClipboardCopy copyOnClick(Component trigger, Component source) { + Objects.requireNonNull(trigger, "Trigger component must not be null"); + Objects.requireNonNull(source, "Source component must not be null"); + + Element element = trigger.getElement(); + + element.executeJs( + "window.Vaadin.Flow.clipboard.setupCopyOnClickFromSource(this, $0)", + source.getElement()); + + Registration cleanup = Registration.once(() -> { + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)"); + }); + + return new ClipboardCopy(element, cleanup); + } + + /** + * Sets up a client-side click handler on the trigger component that copies + * the image from the given image component to the clipboard. + *

+ * The image source component should have a {@code src} attribute (e.g. an + * {@code } element). The image is fetched client-side from the + * element's {@code src} attribute and written to the clipboard as a blob. + * + * @param trigger + * the component whose clicks trigger the copy + * @param imageSource + * the component with a {@code src} attribute pointing to an + * image + * @return a {@link ClipboardCopy} handle for removing the handler + */ + static ClipboardCopy copyImageOnClick(Component trigger, + Component imageSource) { + Objects.requireNonNull(trigger, "Trigger component must not be null"); + Objects.requireNonNull(imageSource, + "Image source component must not be null"); + + Element element = trigger.getElement(); + + element.executeJs( + "window.Vaadin.Flow.clipboard.setupCopyImageOnClick(this, $0)", + imageSource.getElement()); + + Registration cleanup = Registration.once(() -> { + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)"); + }); + + return new ClipboardCopy(element, cleanup); + } + + // --- Server-initiated clipboard operations (round-trip, browser-dependent) + // --- + + /** + * Writes the given text to the clipboard. + *

+ * Browser compatibility note: This method involves a server + * round-trip and may not work in Firefox or Safari, which require clipboard + * operations to be performed within a user gesture (click handler call + * stack). Use {@link #copyOnClick(Component, String)} for reliable + * cross-browser clipboard writes. + * + * @param text + * the text to write to the clipboard + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeText(String text) { + return writeText(UI.getCurrentOrThrow(), text); + } + + /** + * Writes the given text to the clipboard using the specified UI. + * + * @param ui + * the UI to use + * @param text + * the text to write to the clipboard + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeText(UI ui, String text) { + return ui.getPage().executeJs( + "return window.Vaadin.Flow.clipboard.writeText($0)", text); + } + + /** + * Writes the given text to the clipboard with success and error callbacks. + *

+ * Browser compatibility note: This method involves a server + * round-trip and may not work in Firefox or Safari. + * + * @param text + * the text to write + * @param onSuccess + * callback invoked when the write succeeds + * @param onError + * callback invoked with an error message when the write fails + */ + static void writeText(String text, Command onSuccess, + SerializableConsumer onError) { + writeText(UI.getCurrentOrThrow(), text, onSuccess, onError); + } + + /** + * Writes the given text to the clipboard with success and error callbacks, + * using the specified UI. + * + * @param ui + * the UI to use + * @param text + * the text to write + * @param onSuccess + * callback invoked when the write succeeds + * @param onError + * callback invoked with an error message when the write fails + */ + static void writeText(UI ui, String text, Command onSuccess, + SerializableConsumer onError) { + PendingJavaScriptResult result = writeText(ui, text); + result.then(JsonNode.class, value -> { + if (onSuccess != null) { + onSuccess.execute(); + } + }, error -> { + if (onError != null) { + onError.accept(error); + } + }); + } + + /** + * Reads text from the clipboard. + *

+ * Browser compatibility note: This method involves a server + * round-trip. The browser may prompt the user for permission to read the + * clipboard. + * + * @param callback + * callback invoked with the clipboard text + */ + static void readText(SerializableConsumer callback) { + readText(UI.getCurrentOrThrow(), callback); + } + + /** + * Reads text from the clipboard using the specified UI. + * + * @param ui + * the UI to use + * @param callback + * callback invoked with the clipboard text + */ + static void readText(UI ui, SerializableConsumer callback) { + Objects.requireNonNull(callback, "Callback must not be null"); + ui.getPage().executeJs("return window.Vaadin.Flow.clipboard.readText()") + .then(String.class, callback); + } + + /** + * Writes an image from the given URL to the clipboard. + *

+ * The browser fetches the image from the URL client-side and writes it to + * the clipboard as a PNG blob. + *

+ * Browser compatibility note: This method involves a server + * round-trip for delivering the JavaScript command. + * + * @param imageUrl + * the URL of the image to copy + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeImage(String imageUrl) { + return writeImage(UI.getCurrentOrThrow(), imageUrl); + } + + /** + * Writes an image from the given URL to the clipboard using the specified + * UI. + * + * @param ui + * the UI to use + * @param imageUrl + * the URL of the image to copy + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeImage(UI ui, String imageUrl) { + return ui.getPage().executeJs( + "return window.Vaadin.Flow.clipboard.writeImage($0)", imageUrl); + } + + /** + * Writes an image from the given {@link StreamResource} to the clipboard. + *

+ * The stream resource is registered in the session and the browser fetches + * it client-side. + * + * @param resource + * the stream resource providing the image data + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeImage(StreamResource resource) { + return writeImage(UI.getCurrentOrThrow(), resource); + } + + /** + * Writes an image from the given {@link StreamResource} to the clipboard + * using the specified UI. + * + * @param ui + * the UI to use + * @param resource + * the stream resource providing the image data + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeImage(UI ui, StreamResource resource) { + Objects.requireNonNull(resource, "Resource must not be null"); + + StreamRegistration registration = ui.getSession().getResourceRegistry() + .registerResource(resource); + + String url = registration.getResourceUri().toString(); + return writeImage(ui, url); + } + + // --- Event listeners --- + + /** + * Adds a listener for paste events on the given component. + *

+ * The listener receives text, HTML, and file data from the paste event. + * Pasted files are transferred to the server via the upload mechanism. + * + * @param target + * the component to listen for paste events on + * @param listener + * the paste event listener + * @return a registration for removing the listener + */ + static Registration addPasteListener(Component target, + SerializableConsumer listener) { + Objects.requireNonNull(target, "Target component must not be null"); + Objects.requireNonNull(listener, "Listener must not be null"); + + Element element = target.getElement(); + + // Shared state for coordinating upload callbacks and the return channel + PasteState pasteState = new PasteState(listener); + + // Register an upload handler for receiving pasted files + element.setAttribute("__clipboard-paste-upload", UploadHandler + .inMemory((UploadMetadata metadata, byte[] data) -> { + pasteState.addFile(new ClipboardFile(metadata.fileName(), + metadata.contentType(), metadata.contentLength(), + data)); + })); + + // Register a return channel for receiving text/html/file count + ReturnChannelRegistration channel = element.getNode() + .getFeature(ReturnChannelMap.class).registerChannel(args -> { + String text = args.get(0).isNull() ? null + : args.get(0).asText(); + String html = args.get(1).isNull() ? null + : args.get(1).asText(); + int fileCount = args.get(2).asInt(); + pasteState.setTextData(text, html, fileCount); + }); + + // Install the paste event handler + element.executeJs( + "window.Vaadin.Flow.clipboard.setupPasteListener(this, $0)", + channel); + + return Registration.once(() -> { + channel.remove(); + element.removeAttribute("__clipboard-paste-upload"); + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupPasteListener(this)"); + }); + } + + /** + * Adds a listener for copy events on the given component. + * + * @param target + * the component to listen for copy events on + * @param listener + * the copy event listener + * @return a registration for removing the listener + */ + static Registration addCopyListener(Component target, + SerializableConsumer listener) { + return addClipboardEventListener(target, "copy", listener); + } + + /** + * Adds a listener for cut events on the given component. + * + * @param target + * the component to listen for cut events on + * @param listener + * the cut event listener + * @return a registration for removing the listener + */ + static Registration addCutListener(Component target, + SerializableConsumer listener) { + return addClipboardEventListener(target, "cut", listener); + } + + // --- Internal helpers --- + + /** + * Adds a listener for copy or cut events using the standard + * DomListenerRegistration pattern. + */ + private static Registration addClipboardEventListener(Component target, + String eventType, SerializableConsumer listener) { + Objects.requireNonNull(target, "Target component must not be null"); + Objects.requireNonNull(listener, "Listener must not be null"); + + Element element = target.getElement(); + + DomListenerRegistration reg = element.addEventListener(eventType, + domEvent -> { + JsonNode data = domEvent.getEventData(); + String textKey = "event.clipboardData.getData('text/plain')"; + String htmlKey = "event.clipboardData.getData('text/html')"; + + String text = data.has(textKey) + && !data.get(textKey).isNull() + ? data.get(textKey).asText() + : null; + String html = data.has(htmlKey) + && !data.get(htmlKey).isNull() + ? data.get(htmlKey).asText() + : null; + + listener.accept(new ClipboardEvent(eventType, text, html, + List.of())); + }); + reg.addEventData("event.clipboardData.getData('text/plain')"); + reg.addEventData("event.clipboardData.getData('text/html')"); + + return reg; + } + + /** + * Coordinates the asynchronous arrival of text/HTML metadata and uploaded + * file data for a paste event. + */ + class PasteState implements Serializable { + private final SerializableConsumer listener; + private String text; + private String html; + private int expectedFileCount = -1; + private final List files = new ArrayList<>(); + private boolean dispatched = false; + + PasteState(SerializableConsumer listener) { + this.listener = listener; + } + + synchronized void setTextData(String text, String html, int fileCount) { + this.text = text; + this.html = html; + this.expectedFileCount = fileCount; + this.dispatched = false; + this.files.clear(); + tryDispatch(); + } + + synchronized void addFile(ClipboardFile file) { + files.add(file); + tryDispatch(); + } + + private void tryDispatch() { + if (dispatched || expectedFileCount < 0) { + return; + } + if (files.size() >= expectedFileCount) { + dispatched = true; + listener.accept(new ClipboardEvent("paste", text, html, + new ArrayList<>(files))); + } + } + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardCopy.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardCopy.java new file mode 100644 index 00000000000..748021b6eeb --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardCopy.java @@ -0,0 +1,72 @@ +/* + * 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.page; + +import java.io.Serializable; + +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.shared.Registration; + +/** + * Handle for a client-side click-to-copy registration created by + * {@link Clipboard#copyOnClick(com.vaadin.flow.component.Component, String)}. + *

+ * Allows updating the text that will be copied and removing the click handler + * when no longer needed. + */ +public class ClipboardCopy implements Registration, Serializable { + + static final String CLIPBOARD_TEXT_PROPERTY = "__clipboardText"; + + private final Element triggerElement; + private final Registration cleanupRegistration; + + /** + * Creates a new clipboard copy handle. + * + * @param triggerElement + * the element that has the click handler installed + * @param cleanupRegistration + * the registration to call when removing the click handler + */ + ClipboardCopy(Element triggerElement, Registration cleanupRegistration) { + this.triggerElement = triggerElement; + this.cleanupRegistration = cleanupRegistration; + } + + /** + * Updates the text that will be copied to the clipboard when the trigger + * element is clicked. + *

+ * The new value is pushed to the client-side so the copy operation can + * execute without a server round-trip. + * + * @param text + * the new text to copy, or an empty string if {@code null} + */ + public void setValue(String text) { + triggerElement.setProperty(CLIPBOARD_TEXT_PROPERTY, + text != null ? text : ""); + } + + /** + * Removes the client-side click handler and cleans up associated resources. + */ + @Override + public void remove() { + cleanupRegistration.remove(); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardEvent.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardEvent.java new file mode 100644 index 00000000000..8cc950c2dba --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardEvent.java @@ -0,0 +1,118 @@ +/* + * 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.page; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * Represents a clipboard event (paste, copy, or cut) received from the browser. + *

+ * A clipboard event may contain text, HTML content, files, or a combination of + * these. + */ +public class ClipboardEvent implements Serializable { + + private final String type; + private final String text; + private final String html; + private final List files; + + /** + * Creates a new clipboard event. + * + * @param type + * the event type ("paste", "copy", or "cut") + * @param text + * the plain text content, or {@code null} if none + * @param html + * the HTML content, or {@code null} if none + * @param files + * the list of pasted files, or an empty list if none + */ + ClipboardEvent(String type, String text, String html, + List files) { + this.type = type; + this.text = text; + this.html = html; + this.files = files != null ? Collections.unmodifiableList(files) + : Collections.emptyList(); + } + + /** + * Gets the event type. + * + * @return the event type ("paste", "copy", or "cut") + */ + public String getType() { + return type; + } + + /** + * Gets the plain text content from the clipboard event. + * + * @return the plain text, or {@code null} if no text was available + */ + public String getText() { + return text; + } + + /** + * Gets the HTML content from the clipboard event. + * + * @return the HTML content, or {@code null} if no HTML was available + */ + public String getHtml() { + return html; + } + + /** + * Checks whether this event contains plain text data. + * + * @return {@code true} if text data is available + */ + public boolean hasText() { + return text != null && !text.isEmpty(); + } + + /** + * Checks whether this event contains HTML data. + * + * @return {@code true} if HTML data is available + */ + public boolean hasHtml() { + return html != null && !html.isEmpty(); + } + + /** + * Checks whether this event contains file data. + * + * @return {@code true} if file data is available + */ + public boolean hasFiles() { + return !files.isEmpty(); + } + + /** + * Gets the list of files from the clipboard event. + * + * @return an unmodifiable list of clipboard files, never {@code null} + */ + public List getFiles() { + return files; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardFile.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardFile.java new file mode 100644 index 00000000000..b5ea28248ca --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardFile.java @@ -0,0 +1,87 @@ +/* + * 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.page; + +import java.io.Serializable; + +/** + * Represents a file received from a clipboard paste event. + *

+ * Contains the file name, MIME type, size, and raw byte data of the pasted + * file. + */ +public class ClipboardFile implements Serializable { + + private final String fileName; + private final String contentType; + private final long size; + private final byte[] data; + + /** + * Creates a new clipboard file. + * + * @param fileName + * the name of the file + * @param contentType + * the MIME type of the file + * @param size + * the size of the file in bytes + * @param data + * the raw byte data of the file + */ + ClipboardFile(String fileName, String contentType, long size, byte[] data) { + this.fileName = fileName; + this.contentType = contentType; + this.size = size; + this.data = data; + } + + /** + * Gets the name of the file. + * + * @return the file name + */ + public String getName() { + return fileName; + } + + /** + * Gets the MIME type of the file. + * + * @return the MIME type, e.g. "image/png" + */ + public String getMimeType() { + return contentType; + } + + /** + * Gets the size of the file in bytes. + * + * @return the file size + */ + public long getSize() { + return size; + } + + /** + * Gets the raw byte data of the file. + * + * @return the file data + */ + public byte[] getData() { + return data; + } +} diff --git a/flow-server/src/main/resources/META-INF/frontend/clipboard.js b/flow-server/src/main/resources/META-INF/frontend/clipboard.js new file mode 100644 index 00000000000..d238dac9ce6 --- /dev/null +++ b/flow-server/src/main/resources/META-INF/frontend/clipboard.js @@ -0,0 +1,146 @@ +/* + * 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. + */ + +window.Vaadin = window.Vaadin || {}; +window.Vaadin.Flow = window.Vaadin.Flow || {}; +window.Vaadin.Flow.clipboard = { + setupCopyOnClick: function (element) { + if (!element.__clipboardClickHandler) { + element.__clipboardClickHandler = function () { + var text = element.__clipboardText || ''; + navigator.clipboard.writeText(text); + }; + element.addEventListener('click', element.__clipboardClickHandler); + } + }, + + setupCopyOnClickWithCallbacks: function (element, onSuccess, onError) { + if (element.__clipboardClickHandler) { + element.removeEventListener('click', element.__clipboardClickHandler); + } + element.__clipboardClickHandler = function () { + var text = element.__clipboardText || ''; + navigator.clipboard.writeText(text).then( + function () { + onSuccess(); + }, + function () { + onError(); + } + ); + }; + element.addEventListener('click', element.__clipboardClickHandler); + }, + + setupCopyOnClickFromSource: function (element, sourceElement) { + element.__clipboardSourceElement = sourceElement; + if (element.__clipboardClickHandler) { + element.removeEventListener('click', element.__clipboardClickHandler); + } + element.__clipboardClickHandler = function () { + var src = element.__clipboardSourceElement; + var text = src ? (src.value !== undefined && src.value !== null ? String(src.value) : src.textContent || '') : ''; + navigator.clipboard.writeText(text); + }; + element.addEventListener('click', element.__clipboardClickHandler); + }, + + setupCopyImageOnClick: function (element, imageElement) { + element.__clipboardImageElement = imageElement; + if (element.__clipboardClickHandler) { + element.removeEventListener('click', element.__clipboardClickHandler); + } + element.__clipboardClickHandler = function () { + var img = element.__clipboardImageElement; + if (!img || !img.src) return; + fetch(img.src) + .then(function (r) { + return r.blob(); + }) + .then(function (blob) { + var type = blob.type || 'image/png'; + var pngBlob = blob; + if (type !== 'image/png') { + pngBlob = new Blob([blob], { type: 'image/png' }); + } + return navigator.clipboard.write([new ClipboardItem(Object.fromEntries([[pngBlob.type, pngBlob]]))]); + }); + }; + element.addEventListener('click', element.__clipboardClickHandler); + }, + + cleanupCopyOnClick: function (element) { + if (element.__clipboardClickHandler) { + element.removeEventListener('click', element.__clipboardClickHandler); + delete element.__clipboardClickHandler; + delete element.__clipboardText; + delete element.__clipboardSourceElement; + delete element.__clipboardImageElement; + } + }, + + writeText: function (text) { + return navigator.clipboard.writeText(text); + }, + + readText: function () { + return navigator.clipboard.readText(); + }, + + writeImage: function (url) { + return fetch(url) + .then(function (r) { + return r.blob(); + }) + .then(function (blob) { + return navigator.clipboard.write([new ClipboardItem(Object.fromEntries([[blob.type, blob]]))]); + }); + }, + + setupPasteListener: function (element, channel) { + if (element.__clipboardPasteHandler) { + element.removeEventListener('paste', element.__clipboardPasteHandler); + } + element.__clipboardPasteHandler = async function (e) { + e.preventDefault(); + var text = e.clipboardData.getData('text/plain') || null; + var html = e.clipboardData.getData('text/html') || null; + var files = []; + for (var i = 0; i < e.clipboardData.items.length; i++) { + if (e.clipboardData.items[i].kind === 'file') { + files.push(e.clipboardData.items[i].getAsFile()); + } + } + var uploadUrl = element.getAttribute('__clipboard-paste-upload'); + if (uploadUrl && files.length > 0) { + for (var i = 0; i < files.length; i++) { + var fd = new FormData(); + fd.append('file', files[i], files[i].name || 'pasted-file'); + await fetch(uploadUrl, { method: 'POST', body: fd }); + } + } + channel(text, html, files.length); + }; + element.addEventListener('paste', element.__clipboardPasteHandler); + }, + + cleanupPasteListener: function (element) { + if (element.__clipboardPasteHandler) { + element.removeEventListener('paste', element.__clipboardPasteHandler); + delete element.__clipboardPasteHandler; + } + } +}; diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardCopyTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardCopyTest.java new file mode 100644 index 00000000000..922d4baa03b --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardCopyTest.java @@ -0,0 +1,78 @@ +/* + * 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.page; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.Assert; +import org.junit.Test; + +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.shared.Registration; + +public class ClipboardCopyTest { + + @Test + public void setValue_updatesProperty() { + Element element = new Element("button"); + ClipboardCopy copy = new ClipboardCopy(element, + Registration.once(() -> { + })); + + copy.setValue("hello"); + Assert.assertEquals("hello", + element.getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY)); + + copy.setValue("world"); + Assert.assertEquals("world", + element.getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY)); + } + + @Test + public void setValueNull_setsEmptyString() { + Element element = new Element("button"); + ClipboardCopy copy = new ClipboardCopy(element, + Registration.once(() -> { + })); + + copy.setValue(null); + Assert.assertEquals("", + element.getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY)); + } + + @Test + public void remove_callsCleanupRegistration() { + AtomicBoolean cleaned = new AtomicBoolean(false); + Element element = new Element("button"); + ClipboardCopy copy = new ClipboardCopy(element, + Registration.once(() -> cleaned.set(true))); + + copy.remove(); + Assert.assertTrue(cleaned.get()); + } + + @Test + public void remove_calledTwice_cleanupRunsOnlyOnce() { + int[] count = { 0 }; + Element element = new Element("button"); + ClipboardCopy copy = new ClipboardCopy(element, + Registration.once(() -> count[0]++)); + + copy.remove(); + copy.remove(); + Assert.assertEquals(1, count[0]); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardEventTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardEventTest.java new file mode 100644 index 00000000000..6314992097d --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardEventTest.java @@ -0,0 +1,122 @@ +/* + * 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.page; + +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; + +public class ClipboardEventTest { + + @Test + public void textEvent_hasCorrectValues() { + ClipboardEvent event = new ClipboardEvent("paste", "hello", null, + List.of()); + + Assert.assertEquals("paste", event.getType()); + Assert.assertEquals("hello", event.getText()); + Assert.assertNull(event.getHtml()); + Assert.assertTrue(event.hasText()); + Assert.assertFalse(event.hasHtml()); + Assert.assertFalse(event.hasFiles()); + Assert.assertTrue(event.getFiles().isEmpty()); + } + + @Test + public void htmlEvent_hasCorrectValues() { + ClipboardEvent event = new ClipboardEvent("paste", "plain", + "bold", List.of()); + + Assert.assertTrue(event.hasText()); + Assert.assertTrue(event.hasHtml()); + Assert.assertEquals("bold", event.getHtml()); + } + + @Test + public void emptyTextAndHtml_hasTextReturnsFalse() { + ClipboardEvent event = new ClipboardEvent("paste", "", "", List.of()); + + Assert.assertFalse(event.hasText()); + Assert.assertFalse(event.hasHtml()); + } + + @Test + public void nullTextAndHtml_hasTextReturnsFalse() { + ClipboardEvent event = new ClipboardEvent("paste", null, null, + List.of()); + + Assert.assertFalse(event.hasText()); + Assert.assertFalse(event.hasHtml()); + } + + @Test + public void eventWithFiles_hasFilesReturnsTrue() { + ClipboardFile file = new ClipboardFile("test.png", "image/png", 100, + new byte[100]); + ClipboardEvent event = new ClipboardEvent("paste", null, null, + List.of(file)); + + Assert.assertTrue(event.hasFiles()); + Assert.assertEquals(1, event.getFiles().size()); + Assert.assertEquals("test.png", event.getFiles().get(0).getName()); + } + + @Test + public void filesListIsUnmodifiable() { + ClipboardEvent event = new ClipboardEvent("paste", null, null, List + .of(new ClipboardFile("f.txt", "text/plain", 5, new byte[5]))); + + Assert.assertThrows(UnsupportedOperationException.class, () -> event + .getFiles() + .add(new ClipboardFile("x.txt", "text/plain", 1, new byte[1]))); + } + + @Test + public void nullFilesList_becomesEmptyList() { + ClipboardEvent event = new ClipboardEvent("paste", "text", null, null); + + Assert.assertNotNull(event.getFiles()); + Assert.assertTrue(event.getFiles().isEmpty()); + Assert.assertFalse(event.hasFiles()); + } + + @Test + public void copyEvent_hasCorrectType() { + ClipboardEvent event = new ClipboardEvent("copy", "copied text", null, + List.of()); + Assert.assertEquals("copy", event.getType()); + } + + @Test + public void cutEvent_hasCorrectType() { + ClipboardEvent event = new ClipboardEvent("cut", "cut text", null, + List.of()); + Assert.assertEquals("cut", event.getType()); + } + + @Test + public void clipboardFile_hasCorrectValues() { + byte[] data = { 1, 2, 3, 4, 5 }; + ClipboardFile file = new ClipboardFile("photo.jpg", "image/jpeg", 5, + data); + + Assert.assertEquals("photo.jpg", file.getName()); + Assert.assertEquals("image/jpeg", file.getMimeType()); + Assert.assertEquals(5, file.getSize()); + Assert.assertArrayEquals(data, file.getData()); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardTest.java new file mode 100644 index 00000000000..987b9750bb0 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardTest.java @@ -0,0 +1,364 @@ +/* + * 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.page; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.shared.Registration; +import com.vaadin.tests.util.MockUI; + +public class ClipboardTest { + + @Tag("button") + private static class TestButton extends Component { + } + + @Tag("input") + private static class TestTextField extends Component { + } + + @Tag("div") + private static class TestDiv extends Component { + } + + private MockUI ui; + + @Before + public void setUp() { + ui = new MockUI(); + } + + @After + public void tearDown() { + ui = null; + } + + // --- copyOnClick(Component, String) --- + + @Test + public void copyOnClick_setsPropertyAndInstallsHandler() { + TestButton button = new TestButton(); + ui.add(button); + + ClipboardCopy handle = Clipboard.copyOnClick(button, "test text"); + + Assert.assertNotNull(handle); + Assert.assertEquals("test text", button.getElement() + .getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY)); + + List invocations = ui + .dumpPendingJsInvocations(); + Assert.assertFalse("Should have JS invocations", invocations.isEmpty()); + + String js = invocations.get(invocations.size() - 1).getInvocation() + .getExpression(); + Assert.assertTrue("Should call setupCopyOnClick", + js.contains("clipboard.setupCopyOnClick")); + } + + @Test + public void copyOnClick_nullText_setsEmptyProperty() { + TestButton button = new TestButton(); + ui.add(button); + + Clipboard.copyOnClick(button, (String) null); + + Assert.assertEquals("", button.getElement() + .getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY)); + } + + @Test(expected = NullPointerException.class) + public void copyOnClick_nullTrigger_throws() { + Clipboard.copyOnClick(null, "text"); + } + + @Test + public void copyOnClick_remove_executesCleanupJs() { + TestButton button = new TestButton(); + ui.add(button); + + ClipboardCopy handle = Clipboard.copyOnClick(button, "text"); + // Drain setup invocations + ui.dumpPendingJsInvocations(); + + handle.remove(); + + List invocations = ui + .dumpPendingJsInvocations(); + Assert.assertFalse("Should have cleanup JS invocations", + invocations.isEmpty()); + String js = invocations.get(0).getInvocation().getExpression(); + Assert.assertTrue("Should call cleanupCopyOnClick", + js.contains("clipboard.cleanupCopyOnClick")); + } + + @Test + public void copyOnClick_updateValue_changesProperty() { + TestButton button = new TestButton(); + ui.add(button); + + ClipboardCopy handle = Clipboard.copyOnClick(button, "initial"); + + Assert.assertEquals("initial", button.getElement() + .getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY)); + + handle.setValue("updated"); + + Assert.assertEquals("updated", button.getElement() + .getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY)); + } + + // --- copyOnClick(Component, String, Command, Command) --- + + @Test + public void copyOnClickWithCallbacks_installsHandlerWithChannels() { + TestButton button = new TestButton(); + ui.add(button); + + AtomicBoolean successCalled = new AtomicBoolean(false); + AtomicBoolean errorCalled = new AtomicBoolean(false); + + ClipboardCopy handle = Clipboard.copyOnClick(button, "text", + () -> successCalled.set(true), () -> errorCalled.set(true)); + + Assert.assertNotNull(handle); + + List invocations = ui + .dumpPendingJsInvocations(); + Assert.assertFalse(invocations.isEmpty()); + + // Find the invocation that sets up the click handler + boolean foundClipboardSetup = invocations.stream() + .anyMatch(inv -> inv.getInvocation().getExpression() + .contains("clipboard.setupCopyOnClickWithCallbacks")); + Assert.assertTrue("Should call setupCopyOnClickWithCallbacks", + foundClipboardSetup); + } + + // --- copyOnClick(Component, Component) --- + + @Test + public void copyOnClickWithSource_installsHandler() { + TestButton button = new TestButton(); + TestTextField source = new TestTextField(); + ui.add(button); + ui.add(source); + + ClipboardCopy handle = Clipboard.copyOnClick(button, source); + + Assert.assertNotNull(handle); + + List invocations = ui + .dumpPendingJsInvocations(); + Assert.assertFalse(invocations.isEmpty()); + + boolean foundSourceSetup = invocations.stream() + .anyMatch(inv -> inv.getInvocation().getExpression() + .contains("clipboard.setupCopyOnClickFromSource")); + Assert.assertTrue("Should call setupCopyOnClickFromSource", + foundSourceSetup); + } + + @Test(expected = NullPointerException.class) + public void copyOnClickWithSource_nullSource_throws() { + TestButton button = new TestButton(); + Clipboard.copyOnClick(button, (Component) null); + } + + // --- writeText --- + + @Test + public void writeText_executesJs() { + PendingJavaScriptResult result = Clipboard.writeText(ui, "hello"); + + Assert.assertNotNull(result); + + List invocations = ui + .dumpPendingJsInvocations(); + Assert.assertFalse(invocations.isEmpty()); + + PendingJavaScriptInvocation invocation = invocations + .get(invocations.size() - 1); + String js = invocation.getInvocation().getExpression(); + Assert.assertTrue("Should call clipboard.writeText", + js.contains("clipboard.writeText($0)")); + } + + // --- readText --- + + @Test + public void readText_executesJs() { + AtomicReference result = new AtomicReference<>(); + Clipboard.readText(ui, result::set); + + List invocations = ui + .dumpPendingJsInvocations(); + Assert.assertFalse(invocations.isEmpty()); + + boolean foundReadText = invocations.stream() + .anyMatch(inv -> inv.getInvocation().getExpression() + .contains("clipboard.readText()")); + Assert.assertTrue("Should call readText", foundReadText); + } + + @Test(expected = NullPointerException.class) + public void readText_nullCallback_throws() { + Clipboard.readText(ui, null); + } + + // --- writeImage --- + + @Test + public void writeImage_withUrl_executesJs() { + PendingJavaScriptResult result = Clipboard.writeImage(ui, + "/images/chart.png"); + + Assert.assertNotNull(result); + + List invocations = ui + .dumpPendingJsInvocations(); + Assert.assertFalse(invocations.isEmpty()); + + boolean foundWriteImage = invocations.stream() + .anyMatch(inv -> inv.getInvocation().getExpression() + .contains("clipboard.writeImage($0)")); + Assert.assertTrue("Should call clipboard.writeImage", foundWriteImage); + } + + // --- addCopyListener --- + + @Test + public void addCopyListener_registersEventListener() { + TestDiv div = new TestDiv(); + ui.add(div); + + AtomicReference eventRef = new AtomicReference<>(); + Registration reg = Clipboard.addCopyListener(div, eventRef::set); + + Assert.assertNotNull(reg); + } + + @Test + public void addCutListener_registersEventListener() { + TestDiv div = new TestDiv(); + ui.add(div); + + AtomicReference eventRef = new AtomicReference<>(); + Registration reg = Clipboard.addCutListener(div, eventRef::set); + + Assert.assertNotNull(reg); + } + + @Test(expected = NullPointerException.class) + public void addCopyListener_nullTarget_throws() { + Clipboard.addCopyListener(null, event -> { + }); + } + + @Test(expected = NullPointerException.class) + public void addCopyListener_nullListener_throws() { + TestDiv div = new TestDiv(); + Clipboard.addCopyListener(div, null); + } + + // --- addPasteListener --- + // Note: Full integration tests for addPasteListener require a session + // with a real StreamResourceRegistry (needed by setAttribute with + // ElementRequestHandler). The PasteState coordination and null-argument + // validation are tested below. + + @Test(expected = NullPointerException.class) + public void addPasteListener_nullTarget_throws() { + Clipboard.addPasteListener(null, event -> { + }); + } + + @Test(expected = NullPointerException.class) + public void addPasteListener_nullListener_throws() { + TestDiv div = new TestDiv(); + Clipboard.addPasteListener(div, null); + } + + // --- PasteState coordination --- + + @Test + public void pasteState_noFiles_dispatchesImmediately() { + AtomicReference eventRef = new AtomicReference<>(); + Clipboard.PasteState state = new Clipboard.PasteState(eventRef::set); + + state.setTextData("hello", "hello", 0); + + Assert.assertNotNull(eventRef.get()); + Assert.assertEquals("paste", eventRef.get().getType()); + Assert.assertEquals("hello", eventRef.get().getText()); + Assert.assertEquals("hello", eventRef.get().getHtml()); + Assert.assertTrue(eventRef.get().getFiles().isEmpty()); + } + + @Test + public void pasteState_withFiles_waitsForAllFiles() { + AtomicReference eventRef = new AtomicReference<>(); + Clipboard.PasteState state = new Clipboard.PasteState(eventRef::set); + + state.setTextData("text", null, 2); + Assert.assertNull("Should not dispatch yet", eventRef.get()); + + state.addFile(new ClipboardFile("file1.png", "image/png", 100, + new byte[100])); + Assert.assertNull("Should not dispatch yet", eventRef.get()); + + state.addFile(new ClipboardFile("file2.jpg", "image/jpeg", 200, + new byte[200])); + Assert.assertNotNull("Should dispatch after all files arrived", + eventRef.get()); + Assert.assertEquals(2, eventRef.get().getFiles().size()); + Assert.assertEquals("text", eventRef.get().getText()); + } + + @Test + public void pasteState_filesArriveBefore_textData() { + AtomicReference eventRef = new AtomicReference<>(); + Clipboard.PasteState state = new Clipboard.PasteState(eventRef::set); + + // Files arrive first (before setTextData is called) + state.addFile( + new ClipboardFile("f.txt", "text/plain", 10, new byte[10])); + Assert.assertNull("Should not dispatch without text data", + eventRef.get()); + + // Text data arrives, but it resets the file list + state.setTextData("text", null, 1); + + // The file was added before setTextData, which clears the list, + // so it's not counted. We need to add it again. + Assert.assertNull("File added before setTextData was cleared", + eventRef.get()); + + state.addFile( + new ClipboardFile("f.txt", "text/plain", 10, new byte[10])); + Assert.assertNotNull("Should dispatch now", eventRef.get()); + } +}