From 05bc89827b39337f22b0478c8a234abaa7243a01 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 16:46:02 -0700 Subject: [PATCH] =?UTF-8?q?Drops:=20libraries,=20presets=20and=20plug-ins?= =?UTF-8?q?=20open=20on=20a=20drop,=20as=20File=20=E2=80=BA=20Open=20opens?= =?UTF-8?q?=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a document open, the desktop app ran File › Place on a swatch or graphic style library, a Libraries panel file, a presets file or a plug-in dropped on it. A desktop drop has no position, and without one the app opened only documents and ran File › Place on every other file. On the web, the app also ran File › Place on these files when they were dropped on the canvas. File › Place can't read them, and the drop failed with "Couldn't place X: ... not a format VectorCraft reads". VectorcraftApp::drop_target now returns Open for these files, with or without a drop position, and they open as File › Open opens them: plug-ins are installed, presets are imported, swatch and graphic style libraries open in the library panel, and a Libraries panel file is added to the Libraries panel as a new library. A dropped library, presets file or plug-in that has a path is added to Open Recent Files when it opens, as File › Open adds it. io::opens_as returns the kind of each of these files from its extension, and drop_target and io::open_bytes both call it. The rules from #472 still apply to documents, pictures and text. On the web the browser gives no file name before the drop, so the app still outlines the canvas while such a file is dragged over it. Tests: drop_target returns Open for each of the eleven extensions, with no position and with a position on the canvas. A .gpl dropped through the desktop drop path loads in the library panel and is added to Open Recent Files, and a .vclibrary dropped the same way becomes a new library in the Libraries panel. Docs: docs/control-protocol.md (file.place), docs/development.md (Drag-and-drop), docs/target-app-parity.md (Evidence, UI). --- apps/vectorcraft-web/src/web.rs | 3 +- crates/ui-egui/src/io.rs | 61 +++++++++++++++++++++++++------ crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/place.rs | 13 +++++-- crates/ui-egui/src/tests_place.rs | 55 +++++++++++++++++++++++++++- docs/control-protocol.md | 2 +- docs/development.md | 2 +- docs/target-app-parity.md | 1 + 8 files changed, 118 insertions(+), 23 deletions(-) diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index b9ca1432..ba2e697f 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -308,7 +308,8 @@ fn notice(lines: &[&str]) { } /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously) -/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened. +/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened +/// (libraries, presets and plug-ins open wherever they are dropped). /// When the graphics are lost it hands the app to a new runner on a new canvas. struct WebShell { /// `None` once handed on. diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index e7391a5c..21f7f373 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -19,31 +19,25 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft" /// and flattener, PDF, print and perspective grid presets files are imported. → `document.open`'s result for a document opened now (its /// `warnings` say what didn't come in as it was), else null. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { - let ext = fileio::extension(name); + let opens = opens_as(name); // A WebAssembly plug-in is installed. - if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { + if opens == Some(OpensAs::Plugin) { let r = app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?; app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name))); return Ok(Value::Null); } - let presets = [ - (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), - (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), - (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), - (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), - ]; - if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { + if let Some(OpensAs::Presets { import, what }) = opens { let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); app.status(format!("Imported {what}: {}", names.join(", "))); return Ok(Value::Null); } - if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) { + if opens == Some(OpensAs::Library) { return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null); } - let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()); - if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT { + let swatches = opens == Some(OpensAs::SwatchLibrary); + if swatches || opens == Some(OpensAs::StyleLibrary) { let p = match path { Some(path) => serde_json::json!({ "path": path }), // `.ase` swatch libraries are binary. @@ -63,6 +57,49 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti open_document(app, name, bytes, path, &Value::Null) } +/// The kinds of file File → Open installs, imports or shows in the library panel or the Libraries +/// panel ([`opens_as`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OpensAs { + /// A WebAssembly plug-in: installed. + Plugin, + /// Flattener, PDF, print or perspective grid presets: imported with command `import`, and + /// `what` names them in the status bar. + Presets { import: &'static str, what: &'static str }, + /// A Libraries panel file: added to the Libraries panel as a new library. + Library, + /// A swatch library: opened in the library panel. + SwatchLibrary, + /// A graphic style library: opened in the library panel. + StyleLibrary, +} + +/// The kind of file named `name` (by its extension) that File → Open installs, imports or shows in +/// the library panel or the Libraries panel ([`open_bytes`]), else `None`. A drop opens these files +/// the same way, wherever they are dropped ([`VectorcraftApp::drop_target`]). +pub fn opens_as(name: &str) -> Option { + let ext = fileio::extension(name); + let presets = [ + (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), + (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), + (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), + (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), + ]; + if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { + Some(OpensAs::Plugin) + } else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { + Some(OpensAs::Presets { import, what }) + } else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) { + Some(OpensAs::Library) + } else if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()) { + Some(OpensAs::SwatchLibrary) + } else if ext == vectorcraft_doc::style_libs::STYLES_EXT { + Some(OpensAs::StyleLibrary) + } else { + None + } +} + /// Open a document through the engine loader with the `document.open` options in `p` → /// `document.open`'s result. pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bc89297c..bf511ef2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1001,8 +1001,8 @@ impl VectorcraftApp { self.take_dropped_files(ctx); } - /// Files dropped on the window: documents opened, pictures and text placed on the canvas - /// ([`Self::drop_target`]). + /// Files dropped on the window: documents, libraries, presets and plug-ins opened, pictures + /// and text placed on the canvas ([`Self::drop_target`]). #[cfg(not(target_arch = "wasm32"))] fn take_dropped_files(&mut self, ctx: &egui::Context) { let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift)); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 468c2dce..7f41b6fe 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -42,8 +42,9 @@ pub struct DropAt { pub enum DropTarget { /// The canvas of the document it was dropped on. Place(DropAt), - /// Opened as a document: no document is open, the drop missed the canvas (the tab bar), or it - /// is a document dropped where the platform doesn't say. + /// Opened as File → Open opens it: no document is open, the drop missed the canvas (the tab + /// bar), it is a document dropped where the platform doesn't say, or it is a library, a presets + /// file or a plug-in. Open, } @@ -300,10 +301,12 @@ impl VectorcraftApp { /// Where file `name` dropped with the pointer at `pos` (screen points) goes: as in Illustrator, /// onto the canvas of the open document (Shift embeds it), else opened. Where the platform /// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its - /// own and a picture or text is placed in the middle of the view. + /// own and a picture or text is placed in the middle of the view. A library, a presets file or + /// a plug-in opens wherever it is dropped, as File → Open opens it ([`io::opens_as`]). pub fn drop_target(&self, name: &str, pos: Option, shift: bool) -> DropTarget { let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open }; let at = match pos { + _ if io::opens_as(name).is_some() => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), None if is_document(name) => return DropTarget::Open, @@ -386,7 +389,9 @@ pub fn drain(app: &mut VectorcraftApp) { } } -/// Files dragged over the window: the canvas is outlined where they would be placed. +/// Files dragged over the window: the canvas is outlined where they would be placed. On the web +/// the browser gives a dragged file's type but not its name, so the canvas is outlined for any +/// file dragged over it, including libraries, presets and plug-ins, which open when dropped. pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) { let pos = drag_pos(ctx); let placed = ctx.input(|i| { diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index d08044e4..1483830e 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -108,8 +108,8 @@ fn laid_out(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect { app.canvas_rect.expect("the canvas is laid out") } -/// Where the platform tells where files were dropped (the web), they go as in Illustrator: placed -/// at the pointer on the canvas whatever they are, opened off it (the tab bar). +/// Where the platform tells where files were dropped (the web), documents, pictures and text go +/// as in Illustrator: placed at the pointer on the canvas, opened off it (the tab bar). #[test] fn a_drop_with_a_position_places_on_the_canvas_and_opens_off_it() { let mut app = app(); @@ -141,6 +141,57 @@ fn a_drop_without_a_position_opens_documents_and_places_pictures() { } } +/// Swatch libraries and color books, graphic style libraries, Libraries panel files, presets and +/// plug-ins open as File → Open opens them, dropped with or without a position, on the canvas too. +#[test] +fn libraries_presets_and_plugins_open_wherever_they_are_dropped() { + let mut app = app(); + let ctx = egui::Context::default(); + let rect = laid_out(&mut app, &ctx); + let on_canvas = rect.center() + vec2(60.0, -40.0); + let libraries = ["a.vcswatches", "a.gpl", "a.ase", "a.acb", "a.vcstyles", "a.vclibrary"]; + let presets_and_plugins = ["a.vcflattener", "a.vcpdfpresets", "a.vcprintpresets", "a.vcperspective", "a.wasm"]; + for name in libraries.into_iter().chain(presets_and_plugins) { + assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name}"); + assert_eq!(app.drop_target(name, Some(on_canvas), true), DropTarget::Open, "{name} on the canvas"); + } +} + +/// A swatch library dropped on the window while a document is open loads in the library panel, +/// as File → Open loads it. +#[test] +fn a_dropped_swatch_library_opens_in_the_library_panel() { + let mut app = app(); + let ctx = egui::Context::default(); + laid_out(&mut app, &ctx); + let gpl = temp_file("dropped.gpl", b"GIMP Palette\nName: Dropped\n#\n255 0 0\tRed\n 0 128 255\tSky\n"); + frame(&mut app, &ctx, vec![], &[&gpl], false); + let id = format!("loaded/{gpl}"); + assert_eq!(app.ui.library_panel.as_ref().map(|o| o.id.as_str()), Some(id.as_str()), "{}", app.ui.status); + let lib = app.session.execute("swatch.library.get", &json!({"library": id})).unwrap(); + let names: Vec<&str> = lib["swatches"].as_array().unwrap().iter().filter_map(|w| w["name"].as_str()).collect(); + assert_eq!((lib["name"].as_str(), names), (Some("Dropped"), vec!["Red", "Sky"])); + assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed"); + assert_eq!(app.ui.recent_files.first(), Some(&gpl), "added to Open Recent Files as File → Open adds it"); +} + +/// A Libraries panel file dropped on the window while a document is open is added to the +/// Libraries panel as a new library, as File → Open adds it. +#[test] +fn a_dropped_libraries_panel_file_is_added_as_a_new_library() { + let mut app = app(); + let ctx = egui::Context::default(); + laid_out(&mut app, &ctx); + let file = json!({"name": "Dropped", "colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}]}); + let path = temp_file("dropped.vclibrary", file.to_string().as_bytes()); + frame(&mut app, &ctx, vec![], &[&path], false); + assert_eq!(app.ui.status, "Imported library Dropped"); + let lib = app.session.execute("library.get", &json!({})).unwrap(); + let colors: Vec<&str> = lib["colors"].as_array().unwrap().iter().filter_map(|c| c["name"].as_str()).collect(); + assert_eq!((lib["name"].as_str(), colors), (Some("Dropped"), vec!["Red"])); + assert_eq!((app.session.documents().len(), images(&app, 0)), (1, 0), "neither opened as a document nor placed"); +} + #[test] fn a_dropped_png_is_placed_in_the_middle_of_the_view() { let mut app = app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 98dfcb12..5e51beb9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -69,7 +69,7 @@ the two can still win that race. | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | | `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | -| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | +| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`) and plug-ins (`.wasm`) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | | `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `.html` (client-side ``) or `.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | diff --git a/docs/development.md b/docs/development.md index 9fd22721..6a396a1e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: - **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame. - **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. -- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files dropped on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.) +- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.) - **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery//`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab. - **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it: 1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d6d1f5b8..4e0a47af 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -381,6 +381,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). + - Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences.