Part of #472: Escape leaves Presentation Mode; dropped documents open as tabs; .ai off-artboard note

Presentation Mode hides the menus and panels, had no Escape and was saved with the
preferences, so it came back on every restart with no way out. Escape now leaves it (as in
Illustrator), F from it goes back to Normal Screen Mode, and the screen mode is no longer
saved: the app always starts in Normal Screen Mode, and preferences saved in Presentation
Mode by older versions open normally. ui.inspect reports `screenMode` on its own.

Desktop file drags carry no pointer position or modifiers (winit 0.30 on Windows, macOS
and X11), so every drop on a window with a document open was taken for the middle of the
canvas and placed. Where the platform doesn't say, a dropped document (native, SVG, PDF,
.ai, EPS, DXF, EMF/WMF) now opens as a tab of its own and a picture or text is placed in
the middle of the view; pictures dropped together with documents are placed first, so the
last document opened is shown. The web, whose drag events give the position, keeps
Illustrator's choice: placed on the canvas, opened off it (the tab bar).

Illustrator's PDF-compatible part leaves out art outside the artboards (it lives in the
private data alone, which isn't read), so it can't open; importing a file with that
private data now says so in a note. Off-page art a PDF does draw already opens on the
pasteboard; a test keeps it so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 18:01:12 +07:00
1 parent 44e4e63f22
commit e98a880e75
16 files changed
+249 -59

No files matched your search

+4 -4
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -42,7 +42,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
@@ -129,7 +129,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles: the dragged edges align with the other objects; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446).
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472).
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
@@ -235,7 +235,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back).
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Art off the page that a PDF does draw opens on the pasteboard.
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
+3 -2
View File
@@ -349,11 +349,12 @@ impl eframe::App for WebShell {
if !dropped.is_empty() {
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let target = app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
match target {
+6
View File
@@ -103,6 +103,11 @@ fn group_layer(
Some(l)
}
/// The note on a file carrying an editor's private data (#472): its PDF part, the one read, holds
/// only the art on its artboards (art on the pasteboard is in the private data alone).
pub(crate) const OFF_ARTBOARD_NOTE: &str =
"only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open";
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
let original = crate::pages::open(bytes, opts.password.as_deref())?;
@@ -177,6 +182,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let ai = crate::pages::has_private_data(page);
if ai {
drop_page_fill(&mut parts, xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop)));
b.warn(OFF_ARTBOARD_NOTE);
}
// A file with several artboards writes, on each page, the art of its neighbours that
// reaches into the page's box: art lying wholly outside this page is theirs (each page
+14
View File
@@ -434,6 +434,20 @@ fn import_handmade_pdf() {
assert!(close(l[2].geometric_bounds().unwrap(), Rect::new(150.0, 270.0, 190.0, 300.0), 1e-6));
}
/// Art off the page opens on the pasteboard round its artboard, not cut away (#472).
#[test]
fn import_keeps_art_outside_the_page() {
let content = "0 0 1 rg 10 10 50 30 re f 0 1 0 rg -300 -300 50 50 re f 1 0 0 rg 500 500 20 20 re f";
let d = import(&handmade_pdf(content, "[0 0 200 300]")).unwrap();
assert!(close(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 300.0), 1e-9));
let bounds: Vec<Rect> = leaves(&d).iter().map(|n| n.geometric_bounds().unwrap()).collect();
let want = [Rect::new(10.0, 260.0, 60.0, 290.0), Rect::new(-300.0, 550.0, -250.0, 600.0), Rect::new(500.0, -220.0, 520.0, -200.0)];
assert_eq!(bounds.len(), want.len(), "{bounds:?}");
for (b, w) in bounds.iter().zip(want) {
assert!(close(*b, w, 1e-6), "{b:?} vs {w:?}");
}
}
#[test]
fn import_invalid_dash_array_strokes_solid() {
// A dash array with a negative value is invalid (PDF 32000-1 §8.4.3.6): the line is solid. A valid
+14
View File
@@ -151,6 +151,20 @@ fn page_fill(before: &str, after: &str, ai: bool) -> Document {
import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None)).unwrap()
}
/// A file with an editor's private data says that art off its artboards isn't in the part read
/// (#472); a plain PDF has nothing to say.
#[test]
fn an_ai_file_notes_that_its_art_off_the_artboards_is_not_read() {
for ai in [false, true] {
let page = PdfPage {
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
};
let r = import_with_report(&pdf_with_catalog(&[page], &[], "", None), &ImportOptions::default()).unwrap();
assert_eq!(r.warnings, if ai { vec![crate::import::OFF_ARTBOARD_NOTE.to_string()] } else { vec![] });
}
}
fn path_count(d: &Document) -> usize {
let mut n = 0;
d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. })));
+2
View File
@@ -77,6 +77,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"tool": app.session.tool_id(),
"toolOptions": app.session.tool_options(),
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
// Not saved with the UI state, so not in `ui`: 0 normal … 3 Presentation Mode.
"screenMode": app.ui.screen_mode,
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"window": [r.width(), r.height()],
+1 -1
View File
@@ -3065,7 +3065,7 @@ axis Both Ambos
@msg Corners edits a live rectangle Esquinas edita un rectángulo interactivo
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
@msg Couldn't place {_1}: the document it was dropped on was closed No se pudo colocar {_1}: se cerró el documento en el que se soltó
@msg Couldn't place {name}: the document it was dropped on was closed No se pudo colocar {name}: se cerró el documento en el que se soltó
@msg Couldn't read {name}: {e} No se pudo leer {name}: {e}
@msg Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e}
@msg Create Outlines: select text objects Crear contornos: selecciona objetos de texto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9 -5
View File
@@ -110,6 +110,8 @@ mod tests_save;
#[cfg(test)]
mod tests_saveext;
#[cfg(test)]
mod tests_screenmode;
#[cfg(test)]
mod tests_scrub;
#[cfg(test)]
mod tests_selectall;
@@ -790,26 +792,28 @@ impl VectorcraftApp {
self.take_dropped_files(ctx);
}
/// Files dropped on the window: placed where they were dropped on the canvas, else opened.
/// Files dropped on the window: documents 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, pos, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos(), i.modifiers.shift));
let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift));
if dropped.is_empty() {
return;
}
let target = self.drop_target(pos, shift);
let pos = place::drag_pos(ctx);
let mut files = vec![];
for f in dropped {
let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty());
let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name);
let target = self.drop_target(&name, pos, shift);
// A file placed by its path is read by the engine.
let bytes = if path.is_some() && target != place::DropTarget::Open { Ok(vec![]) } else { f.bytes() };
match bytes {
Ok(b) => files.push((name, path, b)),
Ok(b) => files.push((target, (name, path, b))),
Err(e) => self.status(format!("Couldn't read {name}: {e}")),
}
}
place::drop_files(self, files, target);
place::drop_files(self, files);
}
/// Fonts installed or removed while the app was in the background are listed when it comes
+4 -1
View File
@@ -974,7 +974,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"view.screenMode" => {
app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) {
Some(m) => m.min(3) as u8,
None => (app.ui.screen_mode + 1) % 3,
// F cycles the three screen modes; from Presentation Mode (not in the cycle) it
// goes back to Normal.
None if app.ui.screen_mode >= 2 => 0,
None => app.ui.screen_mode + 1,
};
Ok(json!(app.ui.screen_mode))
}
+50 -23
View File
@@ -37,15 +37,22 @@ pub struct DropAt {
pub embed: bool,
}
/// Where files dropped on the window go.
/// Where a file dropped on the window goes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DropTarget {
/// The canvas of the document they were dropped on.
/// The canvas of the document it was dropped on.
Place(DropAt),
/// Opened as documents: no document is open, or the drop missed the canvas (the tab bar).
/// 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.
Open,
}
/// Does a file named `name` open as a document of its own (vector art: native, SVG, PDF, `.ai`,
/// EPS, DXF, metafiles) rather than being a picture or text to place?
pub fn is_document(name: &str) -> bool {
fileio::format_for_name(name).is_some_and(|f| f.read && !f.raster)
}
/// The app's Place state (not saved with the preferences).
#[derive(Default)]
pub struct PlaceState {
@@ -281,33 +288,47 @@ pub fn paint_cursor(app: &mut VectorcraftApp, ctx: &egui::Context, painter: &egu
// ---------- drops ----------
impl VectorcraftApp {
/// Where files dropped with the pointer at `pos` (screen points; `None` when the platform
/// doesn't tell) go: onto the canvas of the open document (Shift embeds them), else opened. An
/// unknown position counts as the middle of the view.
pub fn drop_target(&self, pos: Option<egui::Pos2>, shift: bool) -> DropTarget {
/// 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.
pub fn drop_target(&self, name: &str, pos: Option<egui::Pos2>, 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 {
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,
None => v.center,
};
DropTarget::Place(DropAt { doc: st.uid, at, embed: shift })
}
}
/// Files dropped on the window (`(name, path, bytes)`): placed on the canvas or opened (and added
/// to Open Recent Files), as `target` says. Files for a canvas go to the document they were
/// dropped on, which becomes active again; none are placed when it has closed since.
pub fn drop_files(app: &mut VectorcraftApp, files: Vec<(String, Option<String>, Vec<u8>)>, target: DropTarget) {
if let DropTarget::Place(d) = target {
let Some(i) = app.session.documents().iter().position(|st| st.uid == d.doc) else {
let names: Vec<&str> = files.iter().map(|(name, ..)| name.as_str()).collect();
app.status(format!("Couldn't place {}: the document it was dropped on was closed", names.join(", ")));
return;
};
app.session.set_active(i);
}
for (name, path, bytes) in files {
/// Where the pointer is during a file drag over the window, as far as egui knows: nowhere on the
/// desktop, whose drags carry no position (the one egui had before is stale; files dropped there
/// go by their kind, see [`VectorcraftApp::drop_target`]). On the web, egui's own position (for
/// the highlight: the web host follows its drags itself for drops).
pub fn drag_pos(ctx: &egui::Context) -> Option<egui::Pos2> {
if cfg!(target_arch = "wasm32") { ctx.input(|i| i.pointer.latest_pos()) } else { None }
}
/// A file dropped on the window: its name, path (when it has one) and bytes.
pub type DropFile = (String, Option<String>, Vec<u8>);
/// Files dropped on the window, each placed on a canvas or opened (and added to Open Recent
/// Files) as its target says: the placed ones first, so the last document opened is the one
/// shown. A file for a canvas goes to the document it was dropped on, which becomes active again;
/// it isn't placed when that document has closed since.
pub fn drop_files(app: &mut VectorcraftApp, mut files: Vec<(DropTarget, DropFile)>) {
files.sort_by_key(|(target, _)| *target == DropTarget::Open);
for (target, (name, path, bytes)) in files {
if let DropTarget::Place(d) = target {
let Some(i) = app.session.documents().iter().position(|st| st.uid == d.doc) else {
app.status(format!("Couldn't place {name}: the document it was dropped on was closed"));
continue;
};
app.session.set_active(i);
}
let r = match target {
DropTarget::Place(DropAt { at, embed, .. }) => {
let mut p = match &path {
@@ -338,7 +359,7 @@ pub fn drain(app: &mut VectorcraftApp) {
let mut picked = vec![];
for a in arrived {
match a.drop {
Some(d) => drop_files(app, vec![(a.name, None, a.bytes)], DropTarget::Place(d)),
Some(d) => drop_files(app, vec![(DropTarget::Place(d), (a.name, None, a.bytes))]),
None => {
picked.push(json!({ "name": a.name }));
app.place.picked.retain(|(n, _)| *n != a.name);
@@ -353,8 +374,14 @@ pub fn drain(app: &mut VectorcraftApp) {
/// Files dragged over the window: the canvas is outlined where they would be placed.
pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) {
let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty());
if !hovering || matches!(app.drop_target(ctx.input(|i| i.pointer.latest_pos()), false), DropTarget::Open) {
let pos = drag_pos(ctx);
let placed = ctx.input(|i| {
i.raw.hovered_files.iter().any(|f| {
let name = f.path.as_deref().map(|p| p.to_string_lossy()).unwrap_or_default();
app.drop_target(&name, pos, false) != DropTarget::Open
})
});
if !placed {
return;
}
let t = Tokens::get(ctx);
+4 -1
View File
@@ -222,7 +222,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
crate::canvas::apply_requests(app, r);
}
if k == Key::Escape && !busy && !claimed {
if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) {
// Escape leaves Presentation Mode first: it hides the menus and panels.
if app.ui.screen_mode == 3 {
let _ = app.run("view.presentation", json!({}));
} else if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) {
let _ = app.run("object.pattern.done", json!({}));
} else if app.session.active().is_some_and(|d| d.isolation.is_some()) {
let _ = app.run("object.exitIsolation", json!({}));
+4 -1
View File
@@ -278,7 +278,10 @@ pub struct UiState {
pub status: String,
pub palette_open: bool,
pub palette_query: String,
/// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation.
/// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation. Not saved:
/// the app always starts in Normal Screen Mode, with its menus and panels (#472: a saved
/// Presentation Mode came back on restart with no way out).
#[serde(skip)]
pub screen_mode: u8,
/// Draw Normal / Behind / Inside.
pub draw_mode: u8,
+71 -18
View File
@@ -10,6 +10,7 @@ use vectorcraft_engine::Session;
use crate::VectorcraftApp;
use crate::canvas::Xf;
use crate::place::{DropAt, DropTarget};
/// A `w`×`h` red PNG declaring `ppi`.
fn png(w: u32, h: u32, ppi: f64) -> Vec<u8> {
@@ -77,42 +78,94 @@ fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node {
n
}
/// A tiny SVG document.
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="50" height="40"><rect width="20" height="10"/></svg>"#;
/// Two frames: fonts, then the canvas lays out → the canvas.
fn laid_out(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
frame(app, ctx, vec![], &[], false);
frame(app, ctx, vec![], &[], false);
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).
#[test]
fn a_dropped_png_is_centred_at_the_pointer() {
fn a_drop_with_a_position_places_on_the_canvas_and_opens_off_it() {
let mut app = app();
let ctx = egui::Context::default();
// Two frames: fonts, then the canvas lays out.
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![], &[], false);
let rect = app.canvas_rect.expect("the canvas is laid out");
let rect = laid_out(&mut app, &ctx);
let pos = rect.center() + vec2(60.0, -40.0);
let path = temp_file("drop.png", &png(20, 10, 72.0));
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], false);
let n = selected_image(&app);
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
let uid = app.session.active().unwrap().uid;
for name in ["a.png", "a.svg", "a.ai", "a.vectorcraft"] {
assert_eq!(app.drop_target(name, Some(pos), true), DropTarget::Place(DropAt { doc: uid, at: want, embed: true }), "{name}");
assert_eq!(app.drop_target(name, Some(Pos2::new(300.0, rect.top() - 10.0)), false), DropTarget::Open, "{name} on the tab bar");
}
}
/// Desktop drags carry no position (#472): a dropped document opens as a tab of its own, a
/// picture or text is placed in the middle of the view.
#[test]
fn a_drop_without_a_position_opens_documents_and_places_pictures() {
let mut app = app();
let ctx = egui::Context::default();
laid_out(&mut app, &ctx);
let center = app.view().unwrap().center;
let uid = app.session.active().unwrap().uid;
for name in ["a.svg", "a.svgz", "a.pdf", "a.ai", "a.eps", "a.vectorcraft", "a.drawcraft", "a.vctemplate", "a.dxf", "a.emf"] {
assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name}");
}
for name in ["a.png", "a.jpg", "a.tiff", "a.webp", "a.txt"] {
assert_eq!(app.drop_target(name, None, false), DropTarget::Place(DropAt { doc: uid, at: center, embed: false }), "{name}");
}
}
#[test]
fn a_dropped_png_is_placed_in_the_middle_of_the_view() {
let mut app = app();
let ctx = egui::Context::default();
laid_out(&mut app, &ctx);
let path = temp_file("drop.png", &png(20, 10, 72.0));
// Where egui last saw the pointer is stale during a desktop drag: it doesn't count.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0))], &[&path], false);
let n = selected_image(&app);
let want = app.view().unwrap().center;
let c = n.geometric_bounds().unwrap().center();
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}");
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_ref().map(|l| l.path.as_str()) == Some(path.as_str())), "linked by default");
assert_eq!(app.session.documents().len(), 1, "placed, not opened");
// Shift embeds.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], true);
frame(&mut app, &ctx, vec![], &[&path], true);
assert!(matches!(&selected_image(&app).kind, NodeKind::Image(im) if im.link.is_none()));
}
/// #472: a document dropped on the window opens as a new tab, not into the open document; the
/// pictures dropped with it are placed in the document that was open, before it opens.
#[test]
fn files_dropped_off_the_canvas_or_with_no_document_open_and_are_recent() {
fn a_dropped_document_opens_as_a_tab_of_its_own() {
let mut app = app();
let ctx = egui::Context::default();
laid_out(&mut app, &ctx);
let svg = temp_file("dropped.svg", SVG.as_bytes());
let pic = temp_file("with-it.png", &png(8, 8, 72.0));
frame(&mut app, &ctx, vec![], &[&svg, &pic], false);
assert_eq!(app.session.documents().len(), 2, "opened, not placed");
assert_eq!(app.session.active_index(), Some(1), "the document opened is shown");
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(svg.as_str()));
assert_eq!((images(&app, 0), images(&app, 1)), (1, 0), "the picture is placed in the document that was open");
assert_eq!(app.ui.recent_files.first(), Some(&svg));
}
#[test]
fn files_dropped_with_no_document_open_open_and_are_recent() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let path = temp_file("open-me.png", &png(8, 8, 72.0));
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))], &[&path], false);
frame(&mut app, &ctx, vec![], &[&path], false);
assert_eq!(app.session.documents().len(), 1, "no document: the drop opens");
assert_eq!(app.ui.recent_files.first(), Some(&path));
// Over the tab bar (above the canvas): opened too.
frame(&mut app, &ctx, vec![], &[], false);
let top = app.canvas_rect.unwrap().top();
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(300.0, top - 10.0))], &[&path], false);
assert_eq!(app.session.documents().len(), 2);
}
/// The images in open document `i`.
@@ -134,8 +187,8 @@ fn a_file_read_after_another_document_became_active_lands_where_it_was_dropped()
frame(&mut app, &ctx, vec![], &[], false);
let rect = app.canvas_rect.expect("the canvas is laid out");
let pos = rect.center() + vec2(60.0, -40.0);
let drop_on_active = |app: &VectorcraftApp| match app.drop_target(Some(pos), false) {
crate::place::DropTarget::Place(d) => d,
let drop_on_active = |app: &VectorcraftApp| match app.drop_target("late.png", Some(pos), false) {
DropTarget::Place(d) => d,
t => panic!("{t:?}"),
};
let arrive =
+60
View File
@@ -0,0 +1,60 @@
//! Screen modes and Presentation Mode (#472): Escape leaves Presentation Mode, F cycles the three
//! screen modes, and no screen mode is saved with the preferences.
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
/// One headless frame of the whole window with `events`.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
fn escape() -> Vec<egui::Event> {
[true, false]
.map(|pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() })
.to_vec()
}
#[test]
fn escape_leaves_presentation_mode() {
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
assert_eq!(app.run("view.presentation", json!({})).unwrap(), json!(3));
frame(&mut app, &ctx, vec![]);
assert_eq!(crate::control::inspect(&app, &ctx)["screenMode"], json!(3), "agents see the screen mode");
frame(&mut app, &ctx, escape());
assert_eq!(app.ui.screen_mode, 0, "Escape is back in Normal Screen Mode");
// Escape in Normal Screen Mode leaves it so.
frame(&mut app, &ctx, escape());
assert_eq!(app.ui.screen_mode, 0);
}
#[test]
fn f_cycles_the_screen_modes_and_leaves_presentation_mode() {
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
let modes: Vec<u64> = (0..4).map(|_| app.run("view.screenMode", json!({})).unwrap().as_u64().unwrap()).collect();
assert_eq!(modes, [1, 2, 0, 1]);
app.run("view.presentation", json!({})).unwrap();
assert_eq!(app.run("view.screenMode", json!({})).unwrap(), json!(0), "from Presentation Mode, F goes back to Normal");
}
#[test]
fn the_screen_mode_is_not_saved() {
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
app.run("view.presentation", json!({})).unwrap();
let saved = serde_json::to_value(&app.ui).unwrap();
assert!(saved.get("screen_mode").is_none(), "{saved}");
// Preferences an older version saved in Presentation Mode start in Normal Screen Mode.
let old: crate::state::UiState = serde_json::from_value(json!({ "screen_mode": 3 })).unwrap();
assert_eq!(old.screen_mode, 0);
}
+2 -2
View File
@@ -18,7 +18,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf, background saves and exports still running |
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), view, canvas rect, window size, perf, background saves and exports still running |
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
@@ -36,7 +36,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
| `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 canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. 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. 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 `<stem>.html` (client-side `<map>`) or `<stem>.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 |
+1 -1
View File
@@ -20,7 +20,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 `<a download>`, 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). 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 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.)
- **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery/<area>/<name>`, 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).