Skip to main content

logicaffeine_web/ui/
clipboard.rs

1//! Handing text to the reader's clipboard.
2//!
3//! The Clipboard API exists only on pages served over HTTPS (or from localhost), and a browser may
4//! refuse a write even there. Safari grants the clipboard only inside the click that asked, so the
5//! write starts at once, in the handler, never in a task that runs a turn later (Collin,
6//! 2026-11-02: the hero's Copy did nothing in Safari). Where the API is missing, the text is
7//! copied the old way, by the copy command, still inside the click, from a textarea the reader
8//! never sees: selecting the visible box painted its selection across every line's whitespace
9//! (Collin, 2026-11-02). A page that offers a copy says so when the copy does not happen, and
10//! selects the text instead, so the reader's own copy key finishes the job.
11
12use std::future::Future;
13use std::pin::Pin;
14
15/// Put `text` on the clipboard. The write starts now, in the caller's turn; the future says whether
16/// the browser took it. When the Clipboard API is not there, the copy command does it at once.
17pub fn copy(text: &str) -> Pin<Box<dyn Future<Output = bool>>> {
18    #[cfg(target_arch = "wasm32")]
19    {
20        use wasm_bindgen::{JsCast, JsValue};
21        let Some(window) = web_sys::window() else { return Box::pin(std::future::ready(false)) };
22        let navigator = window.navigator();
23        let api = match js_sys::Reflect::get(&navigator, &JsValue::from_str("clipboard")) {
24            Ok(value) if !value.is_undefined() && !value.is_null() => Some(value.unchecked_into::<web_sys::Clipboard>()),
25            _ => None,
26        };
27        match api {
28            Some(clipboard) => {
29                // Started here, inside the gesture; awaited by the caller.
30                let promise = clipboard.write_text(text);
31                Box::pin(async move { wasm_bindgen_futures::JsFuture::from(promise).await.is_ok() })
32            }
33            None => Box::pin(std::future::ready(copy_by_command(text))),
34        }
35    }
36    #[cfg(not(target_arch = "wasm32"))]
37    {
38        let _ = text;
39        Box::pin(std::future::ready(false))
40    }
41}
42
43/// Copy `text` with the browser's copy command, from a textarea placed off screen for the moment
44/// it takes: the way to the clipboard on a page with no Clipboard API, inside a click. Nothing the
45/// reader sees is selected. True when the browser says it copied.
46pub fn copy_by_command(text: &str) -> bool {
47    #[cfg(target_arch = "wasm32")]
48    {
49        use wasm_bindgen::{JsCast, JsValue};
50        let Some(window) = web_sys::window() else { return false };
51        let Some(document) = window.document() else { return false };
52        let Some(body) = document.body() else { return false };
53        let Ok(element) = document.create_element("textarea") else { return false };
54        let Ok(area) = element.dyn_into::<web_sys::HtmlTextAreaElement>() else { return false };
55        area.set_value(text);
56        area.set_read_only(true);
57        let _ = area.set_attribute("aria-hidden", "true");
58        let _ = area.set_attribute("style", "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;margin:0");
59        if body.append_child(&area).is_err() {
60            return false;
61        }
62        let _ = area.focus();
63        area.select();
64        // `document.execCommand("copy")`, reached by name: web_sys does not bind the old command.
65        let copied = js_sys::Reflect::get(&document, &JsValue::from_str("execCommand"))
66            .ok()
67            .and_then(|f| f.dyn_into::<js_sys::Function>().ok())
68            .and_then(|f| f.call1(&document, &JsValue::from_str("copy")).ok())
69            .is_some_and(|v| v.is_truthy());
70        area.remove();
71        if let Ok(Some(selection)) = window.get_selection() {
72            let _ = selection.remove_all_ranges();
73        }
74        copied
75    }
76    #[cfg(not(target_arch = "wasm32"))]
77    {
78        let _ = text;
79        false
80    }
81}
82
83/// Focus the element `id` and select the text inside it, from its first character to its last,
84/// so the reader can copy it by hand. The selection runs over the text, not the box around it.
85pub fn select_contents(id: &str) {
86    #[cfg(target_arch = "wasm32")]
87    {
88        use wasm_bindgen::JsCast;
89        let Some(window) = web_sys::window() else { return };
90        let Some(document) = window.document() else { return };
91        let Some(element) = document.get_element_by_id(id) else { return };
92        if let Some(focusable) = element.dyn_ref::<web_sys::HtmlElement>() {
93            let _ = focusable.focus();
94        }
95        let (Ok(Some(selection)), Ok(range)) = (window.get_selection(), document.create_range()) else { return };
96        let Ok(walker) = document.create_tree_walker_with_what_to_show(&element, 4 /* NodeFilter.SHOW_TEXT */) else { return };
97        let mut first: Option<web_sys::Node> = None;
98        let mut last: Option<web_sys::Node> = None;
99        while let Ok(Some(node)) = walker.next_node() {
100            if first.is_none() {
101                first = Some(node.clone());
102            }
103            last = Some(node);
104        }
105        let (Some(first), Some(last)) = (first, last) else { return };
106        let end = last.text_content().map_or(0, |t| t.encode_utf16().count() as u32);
107        if range.set_start(&first, 0).is_err() || range.set_end(&last, end).is_err() {
108            return;
109        }
110        let _ = selection.remove_all_ranges();
111        let _ = selection.add_range(&range);
112    }
113    #[cfg(not(target_arch = "wasm32"))]
114    let _ = id;
115}