From 3edb15c4174fdc2709193519654b015149cdb67a Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Wed, 14 Oct 2026 20:26:04 -0300 Subject: [PATCH] M1: copy and paste through the system clipboard in the browser The web build's Services never wrote to the clipboard, so Cmd+V after Cmd+C got the paste event with whatever text the clipboard held before and made a text layer of it instead of pasting the copied layers. WebServices now writes copies (text and PNG) through egui's output, which eframe hands to the browser clipboard during the copy event. --- apps/figcraft-web/src/web.rs | 14 ++- crates/ui-egui/src/lib.rs | 19 +++ crates/ui-egui/src/web_clipboard_tests.rs | 141 ++++++++++++++++++++++ docs/hardware-parity.md | 5 +- 4 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/web_clipboard_tests.rs diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 63689038..5bb20905 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,7 @@ pub fn start() { // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); - let fig = FigApp::new(session, Box::new(WebServices { reduce_motion })); + let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); Ok(Box::new(WebShell { fig })) }), ) @@ -69,14 +69,22 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard -/// goes through egui's own web clipboard handling. +/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the +/// system clipboard through egui's output; pastes arrive as paste events with their text (a page +/// can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, + ctx: egui::Context, } impl Services for WebServices { + fn clipboard_set_text(&mut self, s: &str) { + figcraft_ui_egui::egui_copy_text(&self.ctx, s); + } + fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { + figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba); + } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; if let Err(e) = window.open_with_url_and_target(url, "_blank") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..3fe8beb8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -150,6 +150,8 @@ mod titlebar_tests; #[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] +mod web_clipboard_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -213,6 +215,23 @@ pub trait Services { pub struct NoServices; impl Services for NoServices {} +/// Put `text` on the system clipboard through egui's output, which the browser shell writes while +/// it handles the copy event (the only time a page may write it). For shells without a clipboard +/// of their own. +pub fn egui_copy_text(ctx: &egui::Context, text: &str) { + ctx.copy_text(text.to_string()); +} + +/// Put a straight-RGBA image on the system clipboard through egui's output (see +/// [`egui_copy_text`]); a buffer that doesn't match its size is dropped. +pub fn egui_copy_png(ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) { + let (w, h) = (w as usize, h as usize); + if w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) { + return; + } + ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba)); +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum RightTab { Design, diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs new file mode 100644 index 00000000..a7787120 --- /dev/null +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -0,0 +1,141 @@ +//! Copy and paste as the browser does them: Cmd+C arrives as a copy event and the copy reaches the +//! system clipboard only through egui's output; Cmd+V arrives as the key and a paste event holding +//! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that +//! was there before as a text layer instead of the copied layers. + +use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::NodeKind; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services, egui_copy_png, egui_copy_text}; + +/// The browser shell's clipboard: writes go out as egui output commands. +struct Browser(egui::Context); + +impl Services for Browser { + fn clipboard_set_text(&mut self, s: &str) { + egui_copy_text(&self.0, s); + } + fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { + egui_copy_png(&self.0, w, h, rgba); + } +} + +struct Page { + ctx: egui::Context, + app: FigApp, + time: f64, + /// The system clipboard's text. + clipboard: String, +} + +impl Page { + fn new() -> Self { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap(); + let ctx = egui::Context::default(); + let app = FigApp::new(s, Box::new(Browser(ctx.clone()))); + let mut p = Self { ctx, app, time: 0.0, clipboard: "text copied in another app".into() }; + for _ in 0..3 { + p.frame(vec![]); + } + p + } + + /// One frame; copies in its output land on the clipboard, as eframe writes them. + fn frame(&mut self, events: Vec) -> Vec { + self.frame_then(events, |_| {}) + } + + /// One frame that runs `then` after the app's own UI (a menu item clicked during the frame). + fn frame_then(&mut self, events: Vec, then: impl FnOnce(&mut FigApp)) -> Vec { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut then = Some(then); + let mut out = self.ctx.run_ui(input, |ui| { + app.ui(ui); + if let Some(f) = then.take() { + f(app); + } + }); + out.textures_delta.clear(); + for c in &out.platform_output.commands { + if let OutputCommand::CopyText(t) = c { + self.clipboard = t.clone(); + } + } + out.platform_output.commands + } + + fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: Modifiers::MAC_CMD | Modifiers::COMMAND } + } + + fn copy(&mut self) { + self.frame(vec![Self::key(Key::C, true), Event::Copy]); + self.frame(vec![Self::key(Key::C, false)]); + } + + fn paste(&mut self) { + let text = self.clipboard.clone(); + self.frame(vec![Self::key(Key::V, true), Event::Paste(text)]); + self.frame(vec![Self::key(Key::V, false)]); + } + + fn count(&self, kind: fn(&NodeKind) -> bool) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count() + } +} + +fn rect(k: &NodeKind) -> bool { + matches!(k, NodeKind::Rectangle) +} + +fn text(k: &NodeKind) -> bool { + matches!(k, NodeKind::Text(_)) +} + +#[test] +fn cmd_c_then_cmd_v_pastes_the_copied_layers() { + let mut p = Page::new(); + p.copy(); + assert_ne!(p.clipboard, "text copied in another app", "the copy reached the system clipboard"); + p.paste(); + assert_eq!(p.count(rect), 2, "the rectangle was pasted"); + assert_eq!(p.count(text), 0, "no text layer from the clipboard's old text"); + p.paste(); + assert_eq!(p.count(rect), 3, "and again"); +} + +#[test] +fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { + let mut p = Page::new(); + p.copy(); + p.clipboard = "hello from another app".into(); + p.paste(); + assert_eq!(p.count(rect), 1); + assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); +} + +#[test] +fn copy_as_png_puts_an_image_on_the_clipboard() { + let mut p = Page::new(); + let out = p.frame_then(vec![], |app| crate::menus::invoke(app, "edit.copyAsPng")); + let img = out.iter().find_map(|c| if let OutputCommand::CopyImage(i) = c { Some(i.clone()) } else { None }); + let img = img.expect("an image was copied"); + assert!(img.size[0] > 0 && img.size[1] > 0); +} + +#[test] +fn a_mismatched_image_buffer_is_dropped() { + let ctx = egui::Context::default(); + for (w, h, rgba) in [(4, 4, &[0u8; 10][..]), (u32::MAX, u32::MAX, &[][..])] { + let mut out = ctx.run_ui(RawInput::default(), |ui| egui_copy_png(ui.ctx(), w, h, rgba)); + out.textures_delta.clear(); + assert!(out.platform_output.commands.is_empty(), "{w}x{h}"); + } +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 8995db25..a990d75b 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -43,13 +43,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser | +| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |