Skip to content

Commit 1ac94ad

Browse files
authored
feat: public read API on ClipboardBinding (#24480)
Clipboard.onClick(button).read(onPayload, onError) — and the single-field convenience variants readText / readHtml — make the read side reachable from the same binding entry point as the write side, instead of forcing callers to instantiate the internal ReadFromClipboardAction themselves. ClipboardPayload moves out of trigger.internal into the public clipboard package so it can sit in the read methods' signatures without leaking an internal type, mirroring how ClipboardContent already lives there for the write side. The private bind() helper on ClipboardBinding is generalised to accept any Action so it can route both write and read actions. The read IT view is restructured into headed sections and gains two new buttons (read-text, read-html); the IT picks up matching scenarios that use the existing resolving-clipboard shim.
1 parent fe8fa21 commit 1ac94ad

7 files changed

Lines changed: 286 additions & 24 deletions

File tree

flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardBinding.java

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
import com.vaadin.flow.component.Component;
2424
import com.vaadin.flow.component.HasValue;
2525
import com.vaadin.flow.component.Tag;
26+
import com.vaadin.flow.component.trigger.internal.Action;
2627
import com.vaadin.flow.component.trigger.internal.ImageBlobInput;
2728
import com.vaadin.flow.component.trigger.internal.LiteralInput;
2829
import com.vaadin.flow.component.trigger.internal.PromiseAction.Error;
2930
import com.vaadin.flow.component.trigger.internal.PropertyInput;
31+
import com.vaadin.flow.component.trigger.internal.ReadFromClipboardAction;
3032
import com.vaadin.flow.component.trigger.internal.Trigger;
3133
import com.vaadin.flow.component.trigger.internal.WriteToClipboardAction;
3234
import com.vaadin.flow.dom.Element;
@@ -37,13 +39,19 @@
3739
/**
3840
* Fluent surface returned from {@link Clipboard#onClick}. Each {@code write*}
3941
* action attaches one {@link WriteToClipboardAction} to the underlying
40-
* {@link Trigger}.
42+
* {@link Trigger}; each {@code read*} action attaches one
43+
* {@link ReadFromClipboardAction}.
4144
* <p>
42-
* Actions come in two flavours: fire-and-forget (one argument) and observed
43-
* (with {@code onCopied}/{@code onError} callbacks). {@code onCopied} receives
44-
* the string that was copied; {@code onError} receives the browser's error.
45-
* Both consumers are required in the observed form — pass {@code s -> {}} or
46-
* {@code err -> {}} to opt out of one.
45+
* Write actions come in two flavours: fire-and-forget (one argument) and
46+
* observed (with {@code onCopied}/{@code onError} callbacks). {@code onCopied}
47+
* receives the string that was copied; {@code onError} receives the browser's
48+
* error. Both consumers are required in the observed form — pass
49+
* {@code s -> {}} or {@code err -> {}} to opt out of one.
50+
* <p>
51+
* Read actions always take both an {@code onPayload} consumer (receiving the
52+
* clipboard contents or {@code null} if empty) and an {@code onError} consumer
53+
* (receiving the browser's error, typically {@code "NotAllowedError"} when the
54+
* user denied the {@code clipboard-read} permission).
4755
*
4856
* <pre>{@code
4957
* Button copy = new Button("Copy");
@@ -52,6 +60,11 @@
5260
* Clipboard.onClick(copy).writeText(textField,
5361
* copied -> Notification.show("Copied " + copied),
5462
* err -> Notification.show("Failed: " + err.message()));
63+
*
64+
* Button paste = new Button("Paste");
65+
* Clipboard.onClick(paste).readText(
66+
* text -> Notification.show("Pasted " + text),
67+
* err -> Notification.show("Failed: " + err.message()));
5568
* }</pre>
5669
*/
5770
public final class ClipboardBinding implements Serializable {
@@ -312,7 +325,72 @@ public void write(ClipboardContent content,
312325
onError));
313326
}
314327

315-
private void bind(WriteToClipboardAction action) {
328+
/**
329+
* Reads the user's clipboard via {@code navigator.clipboard.read()} when
330+
* the underlying trigger fires and delivers the contents to
331+
* {@code onPayload}, or routes any failure to {@code onError}.
332+
* <p>
333+
* The Clipboard API requires the call to happen inside a short-lived user
334+
* gesture AND the user to grant the {@code clipboard-read} permission;
335+
* binding to a click trigger satisfies the gesture, but the browser may
336+
* still reject the read with {@code "NotAllowedError"} when the permission
337+
* is denied.
338+
*
339+
* @param onPayload
340+
* UI-thread callback receiving the clipboard contents, or
341+
* {@code null} if the clipboard was empty; not {@code null}
342+
* @param onError
343+
* UI-thread callback receiving the browser's error, not
344+
* {@code null}
345+
*/
346+
public void read(SerializableConsumer<@Nullable ClipboardPayload> onPayload,
347+
SerializableConsumer<Error> onError) {
348+
Objects.requireNonNull(onPayload, "onPayload must not be null");
349+
Objects.requireNonNull(onError, "onError must not be null");
350+
bind(new ReadFromClipboardAction(onPayload, onError));
351+
}
352+
353+
/**
354+
* Like {@link #read} but delivers only the {@code text/plain} field of the
355+
* clipboard payload to {@code onText}. {@code onText} receives {@code null}
356+
* if the clipboard was empty or had no {@code text/plain} representation.
357+
*
358+
* @param onText
359+
* UI-thread callback receiving the {@code text/plain} value, or
360+
* {@code null}; not {@code null}
361+
* @param onError
362+
* UI-thread callback receiving the browser's error, not
363+
* {@code null}
364+
*/
365+
public void readText(SerializableConsumer<@Nullable String> onText,
366+
SerializableConsumer<Error> onError) {
367+
Objects.requireNonNull(onText, "onText must not be null");
368+
Objects.requireNonNull(onError, "onError must not be null");
369+
bind(new ReadFromClipboardAction(
370+
p -> onText.accept(p == null ? null : p.text()), onError));
371+
}
372+
373+
/**
374+
* Like {@link #read} but delivers only the {@code text/html} field of the
375+
* clipboard payload to {@code onHtml}. {@code onHtml} receives {@code null}
376+
* if the clipboard was empty or had no {@code text/html} representation.
377+
*
378+
* @param onHtml
379+
* UI-thread callback receiving the {@code text/html} value, or
380+
* {@code null}; not {@code null}
381+
* @param onError
382+
* UI-thread callback receiving the browser's error, not
383+
* {@code null}
384+
*/
385+
public void readHtml(SerializableConsumer<@Nullable String> onHtml,
386+
SerializableConsumer<Error> onError) {
387+
Objects.requireNonNull(onHtml, "onHtml must not be null");
388+
Objects.requireNonNull(onError, "onError must not be null");
389+
bind(new ReadFromClipboardAction(
390+
p -> onHtml.accept(p == null ? null : p.html()), onError));
391+
}
392+
393+
private void bind(Action action) {
316394
trigger.triggers(action);
317395
}
318396
}

flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ClipboardPayload.java renamed to flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardPayload.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,16 @@
1313
* License for the specific language governing permissions and limitations under
1414
* the License.
1515
*/
16-
package com.vaadin.flow.component.trigger.internal;
16+
package com.vaadin.flow.component.clipboard;
1717

1818
import java.io.Serializable;
1919

2020
import org.jspecify.annotations.Nullable;
2121

2222
/**
23-
* Textual clipboard contents delivered to {@link ReadFromClipboardAction}'s
24-
* handler. Either field may be {@code null} if the corresponding MIME type was
25-
* not present on the clipboard item.
26-
* <p>
27-
* For internal use only. May be renamed or removed in a future release.
23+
* Textual clipboard contents delivered to {@link ClipboardBinding#read}'s
24+
* payload handler. Either field may be {@code null} if the corresponding MIME
25+
* type was not present on the clipboard item.
2826
*
2927
* @param text
3028
* {@code text/plain} contents, or {@code null} if not present

flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ReadFromClipboardAction.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import org.jspecify.annotations.Nullable;
1919

20+
import com.vaadin.flow.component.clipboard.ClipboardPayload;
2021
import com.vaadin.flow.dom.JsFunction;
2122
import com.vaadin.flow.function.SerializableConsumer;
2223

flow-server/src/test/java/com/vaadin/flow/component/clipboard/ClipboardTest.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@
1515
*/
1616
package com.vaadin.flow.component.clipboard;
1717

18+
import java.util.ArrayList;
1819
import java.util.List;
1920

21+
import org.jspecify.annotations.Nullable;
2022
import org.junit.jupiter.api.Test;
23+
import tools.jackson.databind.node.ArrayNode;
24+
import tools.jackson.databind.node.ObjectNode;
2125

2226
import com.vaadin.flow.component.AbstractField;
2327
import com.vaadin.flow.component.ClickNotifier;
@@ -27,9 +31,12 @@
2731
import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
2832
import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation;
2933
import com.vaadin.flow.dom.JsFunction;
34+
import com.vaadin.flow.internal.JacksonUtils;
35+
import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration;
3036
import com.vaadin.tests.util.MockUI;
3137

3238
import static org.junit.jupiter.api.Assertions.assertEquals;
39+
import static org.junit.jupiter.api.Assertions.assertNull;
3340
import static org.junit.jupiter.api.Assertions.assertSame;
3441
import static org.junit.jupiter.api.Assertions.assertThrows;
3542
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -196,6 +203,105 @@ void writeImage_nonImgComponent_throws() {
196203
() -> Clipboard.onClick(button).writeImage(button));
197204
}
198205

206+
@Test
207+
void read_emitsReadFromClipboardAction() {
208+
UI ui = new MockUI();
209+
TestButton button = new TestButton();
210+
ui.getElement().appendChild(button.getElement());
211+
212+
Clipboard.onClick(button).read(p -> {
213+
}, err -> {
214+
});
215+
216+
// Observed PromiseAction wraps the inner call; the inner JsFunction
217+
// delegates to the TS readPayload helper.
218+
JsFunction action = actionFn(ui);
219+
JsFunction inner = (JsFunction) action.getCaptures().get(1);
220+
assertEquals("return window.Vaadin.Flow.clipboard.readPayload()",
221+
inner.getBody());
222+
}
223+
224+
@Test
225+
void readText_adaptsPayloadConsumerToTextField() {
226+
UI ui = new MockUI();
227+
TestButton button = new TestButton();
228+
ui.getElement().appendChild(button.getElement());
229+
230+
List<@Nullable String> received = new ArrayList<>();
231+
Clipboard.onClick(button).readText(received::add, err -> {
232+
});
233+
234+
// Capture the channel once before the install JS gets drained, then
235+
// exercise both the payload-present and the null-payload paths.
236+
ReturnChannelRegistration channel = returnChannel(ui);
237+
238+
invokeSuccess(channel, "hello", "<b>hello</b>");
239+
assertEquals(List.of("hello"), received);
240+
241+
received.clear();
242+
invokeSuccessNull(channel);
243+
assertEquals(1, received.size());
244+
assertNull(received.get(0));
245+
}
246+
247+
@Test
248+
void readHtml_adaptsPayloadConsumerToHtmlField() {
249+
UI ui = new MockUI();
250+
TestButton button = new TestButton();
251+
ui.getElement().appendChild(button.getElement());
252+
253+
List<@Nullable String> received = new ArrayList<>();
254+
Clipboard.onClick(button).readHtml(received::add, err -> {
255+
});
256+
257+
ReturnChannelRegistration channel = returnChannel(ui);
258+
259+
invokeSuccess(channel, "hello", "<b>hello</b>");
260+
assertEquals(List.of("<b>hello</b>"), received);
261+
262+
received.clear();
263+
invokeSuccessNull(channel);
264+
assertEquals(1, received.size());
265+
assertNull(received.get(0));
266+
}
267+
268+
private static void invokeSuccess(ReturnChannelRegistration channel,
269+
String text, String html) {
270+
ObjectNode payload = JacksonUtils.createObjectNode();
271+
payload.put("text", text);
272+
payload.put("html", html);
273+
ObjectNode outcome = JacksonUtils.createObjectNode();
274+
outcome.put("ok", true);
275+
outcome.set("value", payload);
276+
ArrayNode args = JacksonUtils.createArrayNode();
277+
args.add(outcome);
278+
channel.invoke(args);
279+
}
280+
281+
private static void invokeSuccessNull(ReturnChannelRegistration channel) {
282+
ObjectNode outcome = JacksonUtils.createObjectNode();
283+
outcome.put("ok", true);
284+
outcome.set("value", JacksonUtils.nullNode());
285+
ArrayNode args = JacksonUtils.createArrayNode();
286+
args.add(outcome);
287+
channel.invoke(args);
288+
}
289+
290+
/**
291+
* The action's captures include the single return-channel registration (the
292+
* third arg of the observed wrapper). Pull it out so the test can
293+
* synthesise an outcome and verify the user-supplied consumer. Drains the
294+
* pending JS invocations as a side effect — call once per test.
295+
*/
296+
private static ReturnChannelRegistration returnChannel(UI ui) {
297+
List<ReturnChannelRegistration> channels = actionFn(ui).getCaptures()
298+
.stream().filter(o -> o instanceof ReturnChannelRegistration)
299+
.map(o -> (ReturnChannelRegistration) o).toList();
300+
assertEquals(1, channels.size(),
301+
"Expected exactly one captured return channel");
302+
return channels.get(0);
303+
}
304+
199305
/**
200306
* Returns the action JsFunction for a fire-and-forget binding: the install
201307
* JsFunction's $0 capture, which in fire-and-forget mode is the JsFunction

flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ReadFromClipboardActionTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import tools.jackson.databind.node.ObjectNode;
2525

2626
import com.vaadin.flow.component.UI;
27+
import com.vaadin.flow.component.clipboard.ClipboardPayload;
2728
import com.vaadin.flow.dom.JsFunction;
2829
import com.vaadin.flow.internal.JacksonUtils;
2930
import com.vaadin.tests.util.MockUI;

flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/TriggerReadFromClipboardView.java

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,38 +15,89 @@
1515
*/
1616
package com.vaadin.flow.uitest.ui;
1717

18+
import com.vaadin.flow.component.Component;
19+
import com.vaadin.flow.component.clipboard.Clipboard;
20+
import com.vaadin.flow.component.clipboard.ClipboardBinding;
1821
import com.vaadin.flow.component.html.Div;
22+
import com.vaadin.flow.component.html.H2;
23+
import com.vaadin.flow.component.html.Hr;
1924
import com.vaadin.flow.component.html.NativeButton;
20-
import com.vaadin.flow.component.trigger.internal.ClickTrigger;
21-
import com.vaadin.flow.component.trigger.internal.ReadFromClipboardAction;
25+
import com.vaadin.flow.component.html.Paragraph;
2226
import com.vaadin.flow.router.Route;
2327
import com.vaadin.flow.uitest.servlet.ViewTestLayout;
2428

2529
/**
26-
* Wires a {@link ClickTrigger} on a button to a {@link ReadFromClipboardAction}
27-
* that writes the received {@code ClipboardPayload} (or {@code "null"}) into a
28-
* status div, so the IT can assert both paths. The IT replaces
30+
* Buttons exercising {@link ClipboardBinding}'s read methods, grouped into
31+
* sections so the view doubles as a manual smoke-test page: a multi-format read
32+
* returning both {@code text/plain} and {@code text/html}, a text-only read,
33+
* and an html-only read. Each action's payload/error consumer writes the
34+
* outcome into the status {@link Div}. The IT replaces
2935
* {@code navigator.clipboard.read} with a recording shim so the assertions
30-
* don't depend on browser clipboard permissions.
36+
* don't depend on browser clipboard permissions; manual users copy something
37+
* into their system clipboard first, then click a button and read the result in
38+
* the status div.
3139
*/
3240
@Route(value = "com.vaadin.flow.uitest.ui.TriggerReadFromClipboardView", layout = ViewTestLayout.class)
3341
public class TriggerReadFromClipboardView extends AbstractDivView {
3442

3543
@Override
3644
protected void onShow() {
37-
NativeButton readButton = new NativeButton("Read");
38-
readButton.setId("read");
3945
Div status = new Div();
4046
status.setId("status");
4147

42-
add(readButton, status);
48+
NativeButton readButton = new NativeButton("Read clipboard");
49+
readButton.setId("read");
50+
addSection("Read both text/plain and text/html",
51+
"Pastes the current clipboard's text and html into the status"
52+
+ " line as \"text=...;html=...\" (or \"null\" if the"
53+
+ " clipboard is empty).",
54+
readButton);
55+
56+
NativeButton readTextButton = new NativeButton("Read text only");
57+
readTextButton.setId("read-text");
58+
addSection("Read only text/plain",
59+
"Pastes just the clipboard's text/plain field into the status"
60+
+ " line as \"text=...\" (or \"text=null\" if absent).",
61+
readTextButton);
62+
63+
NativeButton readHtmlButton = new NativeButton("Read html only");
64+
readHtmlButton.setId("read-html");
65+
addSection("Read only text/html",
66+
"Pastes just the clipboard's text/html field into the status"
67+
+ " line as \"html=...\" (or \"html=null\" if absent).",
68+
readHtmlButton);
69+
70+
add(new Hr(), new H2("Last action outcome"),
71+
new Paragraph("Each button's onPayload/onError callback writes"
72+
+ " here. \"error=<name>\" appears when the browser"
73+
+ " rejected the read (e.g. permission denied)."),
74+
status);
4375

44-
new ClickTrigger(readButton).triggers(new ReadFromClipboardAction(p -> {
76+
Clipboard.onClick(readButton).read(p -> {
4577
if (p == null) {
4678
status.setText("null");
4779
} else {
4880
status.setText("text=" + p.text() + ";html=" + p.html());
4981
}
50-
}, err -> status.setText("error=" + err.name())));
82+
}, err -> status.setText("error=" + err.name()));
83+
84+
Clipboard.onClick(readTextButton).readText(
85+
text -> status.setText("text=" + text),
86+
err -> status.setText("error=" + err.name()));
87+
88+
Clipboard.onClick(readHtmlButton).readHtml(
89+
html -> status.setText("html=" + html),
90+
err -> status.setText("error=" + err.name()));
91+
}
92+
93+
private void addSection(String heading, String description,
94+
Component... contents) {
95+
Div section = new Div();
96+
section.getStyle().set("margin", "1em 0").set("padding", "0.5em 0")
97+
.set("border-top", "1px solid #ccc");
98+
section.add(new H2(heading));
99+
section.add(new Paragraph(description));
100+
section.add(contents);
101+
add(section);
51102
}
52103
}

0 commit comments

Comments
 (0)