Merge #375: Copy and paste through the system clipboard in the browser (@mateusvillain)

This commit is contained in:
bflatastic
2026-10-15 15:09:38 +07:00
4 changed files with 174 additions and 5 deletions
+11 -3
View File
@@ -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<web_sys::MediaQueryList>,
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") {
+19
View File
@@ -152,6 +152,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;
@@ -215,6 +217,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,
+141
View File
@@ -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<Event>) -> Vec<OutputCommand> {
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<Event>, then: impl FnOnce(&mut FigApp)) -> Vec<OutputCommand> {
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}");
}
}
+3 -2
View File
@@ -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 |