diff --git a/ROADMAP.md b/ROADMAP.md index cb45e6af..6d5de4ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -93,6 +93,7 @@ native speakers' reviews of the nine language drafts need people. | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -131,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. - **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged. @@ -325,6 +327,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | | 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | diff --git a/crates/collab/src/board_sync_tests.rs b/crates/collab/src/board_sync_tests.rs new file mode 100644 index 00000000..3dbff8bb --- /dev/null +++ b/crates/collab/src/board_sync_tests.rs @@ -0,0 +1,45 @@ +//! Boards sync: the file kind reaches joiners in their snapshot, and a layer's board metadata (a +//! connector's ends, a sticky's author) syncs as one property, through both encodings. + +use figcraft_doc::{BoardItem, Connector, Document, End, FileKind, NodeKind, Routing, Side}; + +use crate::change::{Change, DocProp, Prop}; +use crate::replica::{Mode, Replica}; +use crate::{diff, message}; + +#[test] +fn a_boards_kind_and_its_objects_metadata_sync() { + let mut doc = Document::new("Board"); + doc.kind = FileKind::Board; + let page = doc.first_page().unwrap(); + let a = doc.create(page, NodeKind::Frame, "Sticky", 0.0, 0.0, 200.0, 200.0).unwrap(); + doc.node_mut(a).unwrap().board = Some(BoardItem::Sticky { author: "Ada".into() }); + let host = Replica::new(doc); + // A joiner's copy, built from the snapshot, is a board with the sticky's metadata. + let snap = host.snapshot().unwrap(); + assert!(snap.iter().any(|c| matches!(c, Change::SetDoc { prop: DocProp::Kind, .. }))); + let joiner = Replica::from_snapshot(&snap).unwrap(); + assert_eq!(joiner.document().kind, FileKind::Board); + assert_eq!(joiner.document().node(a).unwrap().board, host.document().node(a).unwrap().board); + // Re-attaching a connector's end is one property write, the same after the wire. + let mut edited = host.document().clone(); + let c = edited.create(page, NodeKind::Vector { network: Default::default() }, "Connector", 0.0, 0.0, 1.0, 1.0).unwrap(); + edited.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector { + from: End::Node { node: a, side: Side::Auto }, + to: End::Point { x: 5.0, y: 5.0 }, + routing: Routing::Elbow, + })); + let mut r = host.clone(); + r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap(); + let mut moved = r.document().clone(); + moved.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector { + from: End::Node { node: a, side: Side::Top }, + to: End::Point { x: 9.0, y: 1.0 }, + routing: Routing::Straight, + })); + let d = diff(&r, &moved).unwrap(); + assert!(matches!(d.changes.as_slice(), [Change::Set { prop: Prop::Board, .. }]), "{:?}", d.changes); + let wire: Vec = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap(); + r.apply(&wire, Mode::Strict).unwrap(); + assert_eq!(r.document(), &moved); +} diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 0608ba67..57bb9cf2 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -181,6 +181,7 @@ node_props! { Annotations => annotations, CodeLinks => code_links, DevResources => dev_resources, + Board => board, } /// A document-level property. Lists of entries with ids (styles, comments, variable collections, @@ -219,6 +220,8 @@ pub enum DocProp { Variables, /// Variable ids in order. VariableOrder, + /// A design file or a board. + Kind, } /// A document-level entry, by id. diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 535c333e..9b35e05d 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -213,6 +213,7 @@ doc_scalars! { ImagePresets => image_presets, AnnotationCategories => annotation_categories, Libraries => libraries, + Kind => kind, } /// Keyed maps (no order): changed and added entries are put, missing ones removed. diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 2c693bc4..fa271f35 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -123,6 +123,8 @@ impl Recompute for NoRecompute { fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet) {} } +#[cfg(test)] +mod board_sync_tests; #[cfg(test)] mod client_tests; #[cfg(test)] diff --git a/crates/doc/src/board.rs b/crates/doc/src/board.rs new file mode 100644 index 00000000..28ed0c1d --- /dev/null +++ b/crates/doc/src/board.rs @@ -0,0 +1,433 @@ +//! Boards: a file kind for whiteboarding, and the small piece of metadata board objects carry. +//! +//! Board objects are ordinary nodes (frames, vectors, text) with a [`BoardItem`] on them, so +//! rendering, hit testing, undo, saving, export, copy and paste and live sessions treat them like +//! any other layer. The metadata says what the object is for: which text a sticky note fits, what +//! a connector joins, who placed a stamp. + +use figcraft_color::Color; +use figcraft_geom::kurbo::{Ellipse, RoundedRect}; +use figcraft_geom::{BezPath, Point, Rect, Shape}; +use serde::{Deserialize, Serialize}; + +use crate::NodeId; + +/// What a file is for: designing screens, or a board to think together on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FileKind { + #[default] + Design, + Board, +} + +impl FileKind { + pub fn is_design(&self) -> bool { + *self == FileKind::Design + } + pub fn id(self) -> &'static str { + match self { + FileKind::Design => "design", + FileKind::Board => "board", + } + } +} + +/// What a board object is. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum BoardItem { + /// A sticky note: a frame whose first text layer is the note (its size shrinks as the note + /// grows) and, below it, who wrote it. + Sticky { + #[serde(default, skip_serializing_if = "String::is_empty")] + author: String, + }, + /// A shape with text: a frame holding the outline (a vector) and a centred text. + Shape { shape: BoardShape }, + /// A line between two objects or points (a vector, re-routed when they move). + Connector(Connector), + /// The label in the middle of connector `connector` (a frame holding its text). + Label { connector: NodeId }, + /// A stamp, and who placed it. + Stamp { + stamp: Stamp, + #[serde(default, skip_serializing_if = "String::is_empty")] + author: String, + }, + /// A marker or highlighter stroke (what the eraser takes). + Ink, +} + +impl BoardItem { + /// Objects selected and moved as a whole: a click on their text or outline picks them, and + /// they never take other layers in. + pub fn is_atom(&self) -> bool { + matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. }) + } + + /// Objects whose first text layer is their text (a double-click or Enter edits it). + pub fn has_text(&self) -> bool { + matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. }) + } + + /// Who made it (stickies and stamps; empty when unknown). + pub fn author(&self) -> &str { + match self { + BoardItem::Sticky { author } | BoardItem::Stamp { author, .. } => author, + _ => "", + } + } + + /// Point the layers this object refers to (a connector's ends, a label's connector) at their + /// copies in `map`; the others stay. + pub fn remap(&mut self, map: &std::collections::BTreeMap) { + match self { + BoardItem::Connector(c) => { + for end in [&mut c.from, &mut c.to] { + if let End::Node { node, .. } = end + && let Some(m) = map.get(node) + { + *node = *m; + } + } + } + BoardItem::Label { connector } => { + if let Some(m) = map.get(connector) { + *connector = *m; + } + } + _ => {} + } + } + + pub fn kind_id(&self) -> &'static str { + match self { + BoardItem::Sticky { .. } => "sticky", + BoardItem::Shape { .. } => "shape", + BoardItem::Connector(_) => "connector", + BoardItem::Label { .. } => "label", + BoardItem::Stamp { .. } => "stamp", + BoardItem::Ink => "ink", + } + } +} + +/// The colours stickies, shapes and connectors come in: soft tints that dark text reads on. +pub const BOARD_COLORS: [(&str, Color); 8] = [ + ("yellow", Color::from_rgb8_const(0xFF, 0xE7, 0x8A)), + ("orange", Color::from_rgb8_const(0xFF, 0xC9, 0x94)), + ("red", Color::from_rgb8_const(0xFF, 0xAE, 0xA6)), + ("pink", Color::from_rgb8_const(0xF7, 0xB9, 0xDA)), + ("violet", Color::from_rgb8_const(0xD4, 0xC2, 0xFC)), + ("blue", Color::from_rgb8_const(0xAC, 0xD3, 0xFA)), + ("green", Color::from_rgb8_const(0xC2, 0xE8, 0xA6)), + ("gray", Color::from_rgb8_const(0xE3, 0xE1, 0xDD)), +]; + +/// A board colour by name ("yellow") or as hex ("#FFE78A"). +pub fn board_color(s: &str) -> Option { + let s = s.trim(); + BOARD_COLORS.iter().find(|(n, _)| n.eq_ignore_ascii_case(s)).map(|(_, c)| *c).or_else(|| Color::from_hex(s)) +} + +/// The page colour boards start with: a warm light grey the colours stand out on. +pub const BOARD_PAGE: Color = Color::from_rgb8_const(0xF4, 0xF3, 0xF0); + +/// The outlines shapes with text come in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoardShape { + #[default] + Rectangle, + RoundedRectangle, + Ellipse, + Diamond, + Triangle, + SpeechBubble, + Chevron, +} + +impl BoardShape { + pub const ALL: [BoardShape; 7] = [ + BoardShape::Rectangle, + BoardShape::RoundedRectangle, + BoardShape::Ellipse, + BoardShape::Diamond, + BoardShape::Triangle, + BoardShape::SpeechBubble, + BoardShape::Chevron, + ]; + + pub fn id(self) -> &'static str { + match self { + BoardShape::Rectangle => "rectangle", + BoardShape::RoundedRectangle => "roundedRectangle", + BoardShape::Ellipse => "ellipse", + BoardShape::Diamond => "diamond", + BoardShape::Triangle => "triangle", + BoardShape::SpeechBubble => "speechBubble", + BoardShape::Chevron => "chevron", + } + } + + pub fn from_id(s: &str) -> Option { + BoardShape::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s)) + } + + pub fn label(self) -> &'static str { + match self { + BoardShape::Rectangle => "Rectangle", + BoardShape::RoundedRectangle => "Rounded rectangle", + BoardShape::Ellipse => "Ellipse", + BoardShape::Diamond => "Diamond", + BoardShape::Triangle => "Triangle", + BoardShape::SpeechBubble => "Speech bubble", + BoardShape::Chevron => "Chevron", + } + } + + /// The outline in a `w` × `h` box at the origin. + pub fn path(self, w: f64, h: f64) -> BezPath { + let poly = |pts: &[(f64, f64)]| { + let mut p = BezPath::new(); + for (i, (x, y)) in pts.iter().enumerate() { + let q = Point::new(x * w, y * h); + if i == 0 { p.move_to(q) } else { p.line_to(q) } + } + p.close_path(); + p + }; + let r = w.min(h); + match self { + BoardShape::Rectangle => Rect::new(0.0, 0.0, w, h).to_path(0.1), + BoardShape::RoundedRectangle => RoundedRect::new(0.0, 0.0, w, h, r * 0.16).to_path(0.1), + BoardShape::Ellipse => Ellipse::from_rect(Rect::new(0.0, 0.0, w, h)).to_path(0.1), + BoardShape::Diamond => poly(&[(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)]), + BoardShape::Triangle => poly(&[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)]), + BoardShape::Chevron => poly(&[(0.0, 0.0), (0.72, 0.0), (1.0, 0.5), (0.72, 1.0), (0.0, 1.0), (0.28, 0.5)]), + BoardShape::SpeechBubble => { + // A rounded body over the bottom fifth, with a tail down to the left. + let (b, c) = (h * 0.8, r * 0.14); + let mut p = BezPath::new(); + p.move_to((c, 0.0)); + p.line_to((w - c, 0.0)); + p.quad_to((w, 0.0), (w, c)); + p.line_to((w, b - c)); + p.quad_to((w, b), (w - c, b)); + p.line_to((w * 0.42, b)); + p.line_to((w * 0.18, h)); + p.line_to((w * 0.24, b)); + p.line_to((c, b)); + p.quad_to((0.0, b), (0.0, b - c)); + p.line_to((0.0, c)); + p.quad_to((0.0, 0.0), (c, 0.0)); + p.close_path(); + p + } + } + } + + /// Where the text goes, as insets of the box (top, right, bottom, left, in fractions of its + /// size): inside the outline's widest part. + pub fn text_insets(self) -> [f64; 4] { + match self { + BoardShape::Rectangle | BoardShape::RoundedRectangle => [0.08, 0.08, 0.08, 0.08], + BoardShape::Ellipse => [0.16, 0.16, 0.16, 0.16], + BoardShape::Diamond => [0.25, 0.25, 0.25, 0.25], + BoardShape::Triangle => [0.45, 0.22, 0.06, 0.22], + BoardShape::SpeechBubble => [0.08, 0.08, 0.28, 0.08], + BoardShape::Chevron => [0.12, 0.3, 0.12, 0.3], + } + } +} + +/// The stamps: round badges with a mark. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stamp { + #[default] + ThumbsUp, + Heart, + PlusOne, + Star, + Question, + Check, +} + +/// One part of a stamp's mark: SVG path data on the badge's 40-unit box, filled or stroked. +pub struct MarkPart { + pub d: &'static str, + pub filled: bool, +} + +/// A stamp badge's size. +pub const STAMP_SIZE: f64 = 40.0; + +impl Stamp { + pub const ALL: [Stamp; 6] = [Stamp::ThumbsUp, Stamp::Heart, Stamp::PlusOne, Stamp::Star, Stamp::Question, Stamp::Check]; + + pub fn id(self) -> &'static str { + match self { + Stamp::ThumbsUp => "thumbsUp", + Stamp::Heart => "heart", + Stamp::PlusOne => "plusOne", + Stamp::Star => "star", + Stamp::Question => "question", + Stamp::Check => "check", + } + } + + pub fn from_id(s: &str) -> Option { + Stamp::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s)) + } + + pub fn label(self) -> &'static str { + match self { + Stamp::ThumbsUp => "Thumbs up", + Stamp::Heart => "Heart", + Stamp::PlusOne => "Plus one", + Stamp::Star => "Star", + Stamp::Question => "Question", + Stamp::Check => "Check", + } + } + + /// The badge's colour (the mark is white on it). + pub fn color(self) -> Color { + match self { + Stamp::ThumbsUp => Color::from_rgb8_const(0x3B, 0x82, 0xE8), + Stamp::Heart => Color::from_rgb8_const(0xE5, 0x48, 0x5F), + Stamp::PlusOne => Color::from_rgb8_const(0x2E, 0xA0, 0x63), + Stamp::Star => Color::from_rgb8_const(0xEE, 0x9B, 0x1A), + Stamp::Question => Color::from_rgb8_const(0x84, 0x5E, 0xD8), + Stamp::Check => Color::from_rgb8_const(0x14, 0x96, 0x8C), + } + } + + /// The mark, drawn for FigCraft from lines, arcs and simple outlines. + pub fn mark(self) -> &'static [MarkPart] { + match self { + // A cuff and a raised hand with its thumb up. + Stamp::ThumbsUp => &[ + MarkPart { d: "M10.5 19 H15 V29 H10.5 Z", filled: true }, + MarkPart { + d: "M16.5 19 L20.6 11.4 C21.6 9.8 24.6 10.6 24.2 13 L23.4 17 H28.2 C29.8 17 30.9 18.5 30.4 20 L28.4 27.3 C28 28.4 27.1 29 25.9 29 H16.5 Z", + filled: true, + }, + ], + // Two round lobes meeting in a point. + Stamp::Heart => &[MarkPart { + d: "M20 29.5 C12.5 24.5 9.5 20.6 10.4 16.4 C11.3 12.2 16.8 11.2 20 15.4 C23.2 11.2 28.7 12.2 29.6 16.4 C30.5 20.6 27.5 24.5 20 29.5 Z", + filled: true, + }], + Stamp::PlusOne => &[MarkPart { d: "M10 20 H20 M15 15 V25", filled: false }, MarkPart { d: "M24.5 15 L27.5 12.5 V27.5", filled: false }], + Stamp::Star => &[MarkPart { + d: "M20 10.5 L22.8 16.7 L29.5 17.4 L24.5 21.9 L25.9 28.6 L20 25.2 L14.1 28.6 L15.5 21.9 L10.5 17.4 L17.2 16.7 Z", + filled: true, + }], + Stamp::Question => &[ + MarkPart { d: "M15.6 16.2 C15.6 10.6 24.4 10.6 24.4 16 C24.4 19.6 20 19.9 20 23.6", filled: false }, + MarkPart { d: "M18.2 28 A1.8 1.8 0 1 0 21.8 28 A1.8 1.8 0 1 0 18.2 28 Z", filled: true }, + ], + Stamp::Check => &[MarkPart { d: "M12.5 20.5 L18 26 L28 14.5", filled: false }], + } + } +} + +/// How a connector runs between its ends. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Routing { + #[default] + Straight, + /// Right angles only: out of each end along its side's normal, turning once or twice. + Elbow, +} + +impl Routing { + pub fn id(self) -> &'static str { + match self { + Routing::Straight => "straight", + Routing::Elbow => "elbow", + } + } + pub fn from_id(s: &str) -> Option { + [Routing::Straight, Routing::Elbow].into_iter().find(|r| r.id().eq_ignore_ascii_case(s)) + } +} + +/// The side of an object a connector end is attached to (at its middle). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Side { + /// The side facing the other end. + #[default] + Auto, + Top, + Right, + Bottom, + Left, +} + +impl Side { + pub fn from_id(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "auto" => Side::Auto, + "top" => Side::Top, + "right" => Side::Right, + "bottom" => Side::Bottom, + "left" => Side::Left, + _ => return None, + }) + } + + /// The side's middle on `b`. + pub fn point(self, b: Rect) -> Point { + let c = b.center(); + match self { + Side::Top => Point::new(c.x, b.y0), + Side::Right => Point::new(b.x1, c.y), + Side::Bottom => Point::new(c.x, b.y1), + Side::Left => Point::new(b.x0, c.y), + Side::Auto => c, + } + } + + pub fn horizontal(self) -> bool { + matches!(self, Side::Left | Side::Right) + } +} + +/// One end of a connector: attached to a layer, or a free point on the page. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum End { + Node { + node: NodeId, + #[serde(default)] + side: Side, + }, + Point { + x: f64, + y: f64, + }, +} + +impl End { + pub fn node(&self) -> Option { + match self { + End::Node { node, .. } => Some(*node), + End::Point { .. } => None, + } + } +} + +/// What a connector joins and how it runs (its arrowheads are its stroke's caps). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Connector { + pub from: End, + pub to: End, + #[serde(default)] + pub routing: Routing, +} diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 2a7b42a6..d0ed4f2b 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -255,6 +255,18 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo if deep { return leaf; } + let target = level_target(doc, leaf, context); + // A board object is picked whole (a sticky, not its text), unless a layer inside it is selected. + let mut chain = doc.ancestors(target); + chain.reverse(); + chain.push(target); + chain + .into_iter() + .find(|c| doc.node(*c).is_some_and(Node::is_board_atom) && !context.iter().any(|s| s != c && doc.is_ancestor(*c, *s))) + .unwrap_or(target) +} + +fn level_target(doc: &Document, leaf: NodeId, context: &[NodeId]) -> NodeId { // Chain from the page's child down to the leaf. let mut chain: Vec = doc.ancestors(leaf); chain.reverse(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d131b2f0..3097181c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ //! live in higher crates. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod board; pub mod colors; pub mod comments; pub mod geometry; @@ -23,6 +24,7 @@ pub mod vector; pub mod vector_faces; pub mod vector_regions; +pub use board::*; pub use colors::*; pub use comments::*; pub use figcraft_color as color; @@ -157,6 +159,9 @@ pub struct ImageData { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { pub name: String, + /// A design file or a board (files from before boards are design files). + #[serde(default, skip_serializing_if = "FileKind::is_design")] + pub kind: FileKind, /// Page ids in order. pub pages: Vec, nodes: imbl::OrdMap>, @@ -221,6 +226,7 @@ impl Document { pub fn without_pages(name: impl Into) -> Self { Self { name: name.into(), + kind: FileKind::Design, pages: Vec::new(), nodes: imbl::OrdMap::new(), dirty: Dirty::default(), @@ -835,6 +841,10 @@ impl Document { { *component = *m; } + // A connector (or label) copied with what it joins joins the copies. + if let Some(b) = c.board.as_mut() { + b.remap(map); + } // A pattern tiling a layer of the copy tiles the copy's layer. for p in c.fills.iter_mut().chain(c.strokes.iter_mut()) { if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 00f7d559..649d5e56 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -518,6 +518,9 @@ pub struct Node { /// Dev resources: links for developers (docs, code, tickets…), in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dev_resources: Vec, + /// On a board: what this object is (a sticky note, a connector, a stamp…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub board: Option, } pub(crate) fn yes() -> bool { @@ -628,6 +631,7 @@ impl Node { annotations: Vec::new(), code_links: Vec::new(), dev_resources: Vec::new(), + board: None, } } @@ -653,6 +657,11 @@ impl Node { matches!(self.kind, NodeKind::Page { .. }) } + /// A board object selected and moved as a whole (a sticky, a shape with text, a stamp…). + pub fn is_board_atom(&self) -> bool { + self.board.as_ref().is_some_and(crate::BoardItem::is_atom) + } + /// A section whose contents are hidden: only its name and outline show; its children are /// skipped when rendering and hit testing. pub fn hides_contents(&self) -> bool { diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs new file mode 100644 index 00000000..317b0889 --- /dev/null +++ b/crates/engine/src/board.rs @@ -0,0 +1,1307 @@ +//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and +//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and +//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, +//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and +//! live-session edits from others all end in the same geometry. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_color::{BlendMode, Color, Paint}; +use figcraft_doc::{ + AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, + HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, + TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, +}; +use figcraft_geom::{Affine, Point, Rect}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; +use crate::tools::{Gesture, PointerEvent, ToolId}; +use crate::{EngineError, Result, Session, bad, spec, textedit}; + +/// Why a board command refuses on a design file. +pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; +/// Why prototyping and Dev Mode refuse on a board. +pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; + +/// A sticky note's side (it grows taller for long notes). +pub const STICKY: f64 = 200.0; +const STICKY_PAD: f64 = 16.0; +/// The room between a sticky and the next one made beside it. +const STICKY_GAP: f64 = 24.0; +/// A new shape's size. +const SHAPE: f64 = 160.0; +/// Text on board objects, and the author line under a sticky's note. +const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); +const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); +/// Connectors' default colour and weight. +const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); +const LINE_WEIGHT: f64 = 2.0; +/// Marker and highlighter strokes: (weight, opacity). +const MARKER: (f64, f32) = (4.0, 1.0); +const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); +/// How far the eraser reaches around the pointer (screen pixels). +const ERASER_PX: f64 = 8.0; +/// The text sizes the contextual bar offers. +pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; + +/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, +/// how new connectors run and the marker's and highlighter's colours. +#[derive(Clone, Debug)] +pub struct BoardTool { + pub sticky: Color, + pub shape: BoardShape, + pub stamp: Stamp, + pub routing: Routing, + pub marker: Color, + pub highlighter: Color, +} + +impl Default for BoardTool { + fn default() -> Self { + let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); + Self { + sticky: first, + shape: BoardShape::Rectangle, + stamp: Stamp::ThumbsUp, + routing: Routing::Elbow, + marker: INK, + highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), + } + } +} + +pub fn specs() -> Vec { + vec![ + spec!( + "board.sticky", + "Sticky note", + "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", + true, + sticky_cmd + ), + spec!( + "board.nextSticky", + "Next sticky note", + "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", + true, + next_sticky + ), + spec!( + "board.shape", + "Shape with text", + "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", + true, + shape_cmd + ), + spec!( + "board.connector", + "Connector", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + true, + connector_cmd + ), + spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), + spec!( + "board.stamp", + "Stamp", + "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", + true, + stamp_cmd + ), + spec!( + "board.ink", + "Marker stroke", + "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", + true, + ink_cmd + ), + spec!( + "board.setColor", + "Board color", + "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", + true, + set_color + ), + spec!( + "board.setStyle", + "Board style", + "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow, startCap?, endCap? (connectors)}", + true, + set_style + ), + spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { + if let Some(c) = color_param("tool.sticky", p)? { + s.tool.board.sticky = c; + } + board_tool(s, ToolId::Sticky) + }), + spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { + if let Some(v) = string(p, "shape") { + s.tool.board.shape = shape_of("tool.shape", &v)?; + } + board_tool(s, ToolId::BoardShape) + }), + spec!("tool.connector", "Connector", "{style?: straight|elbow} (drag from one object to another)", false, |s, p| { + if let Some(v) = string(p, "style") { + s.tool.board.routing = routing_of("tool.connector", &v)?; + } + board_tool(s, ToolId::Connector) + }), + spec!("tool.marker", "Marker", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.marker", p)? { + s.tool.board.marker = c; + } + board_tool(s, ToolId::Marker) + }), + spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.highlighter", p)? { + s.tool.board.highlighter = c; + } + board_tool(s, ToolId::Highlighter) + }), + spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), + spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { + if let Some(v) = string(p, "stamp") { + s.tool.board.stamp = stamp_of("tool.stamp", &v)?; + } + board_tool(s, ToolId::Stamp) + }), + ] +} + +pub fn is_board(doc: &Document) -> bool { + doc.kind == FileKind::Board +} + +/// Refuse `cmd` unless the active file is a board. +pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { + if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } +} + +/// Prototyping and Dev Mode: not on boards. +fn design_only(cmd: &str) -> bool { + cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") +} + +/// Refuse `cmd` on a board when it is prototyping or Dev Mode. +pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { + if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } +} + +fn board_tool(s: &mut Session, t: ToolId) -> Result { + require(s, &format!("tool.{}", t.id()))?; + crate::cmd::view::tool(s, t) +} + +fn shape_of(cmd: &str, v: &str) -> Result { + BoardShape::from_id(v) + .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) +} + +fn stamp_of(cmd: &str, v: &str) -> Result { + Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) +} + +fn routing_of(cmd: &str, v: &str) -> Result { + Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight or elbow, not `{v}`"))) +} + +fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), + } +} + +/// `color`: a board colour's name, or any colour. +fn color_param(cmd: &str, p: &Value) -> Result> { + match get(p, "color") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), + Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), + } +} + +/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). +fn line_shade(c: Color) -> Color { + let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); + if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } +} + +/// Your name on what you make: the name from Preferences, else the one a live session gave you +/// (none for the default name outside a session). +fn author(s: &Session) -> String { + let name = crate::live::display_name(&s.prefs); + if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } +} + +/// The parent a command places into, and that parent's page-to-local transform. +fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { + let page = s.page()?; + let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); + let doc = s.doc()?; + let n = doc.get(parent)?; + let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); + if !takes { + return Err(bad(cmd, "`parent` is the page, a section or a frame")); + } + Ok(parent) +} + +/// A new layer at the top of `parent`, at `at` in it, made by `f`. +fn add( + doc: &mut Document, + parent: NodeId, + kind: NodeKind, + name: &str, + at: (f64, f64), + size: (f64, f64), + f: impl FnOnce(&mut Node), +) -> Result { + let id = doc.alloc_id(); + let mut n = Node::new(id, kind, name, size.0, size.1); + n.transform = Affine::translate(at); + f(&mut n); + doc.insert(n, parent, None)?; + Ok(id) +} + +/// A soft shadow under stickies and stamps. +fn soft_shadow() -> Effect { + Effect { + kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), + visible: true, + } +} + +/// A text layer filling its frame's width (the frame's auto layout places it). +fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { + let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); + let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; + let mut td = TextData { + characters: crate::cmd::props::normalize_line_endings(text), + auto_resize: TextAutoResize::Height, + align_h: align, + ..TextData::default() + }; + td.style.font_size = size; + let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { + n.fills = vec![Paint::solid(color)]; + n.child_layout.sizing_h = Sizing::Fill; + })?; + if let Some(n) = doc.node(id) + && let Some((w, h)) = figcraft_text::auto_size(n) + && let Some(n) = doc.node_mut(id) + { + (n.width, n.height) = (w, h); + } + Ok(id) +} + +/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so +/// every copy of the file picks the same size whatever fonts it has). +pub fn sticky_font_size(text: &str) -> f64 { + let weight = text.chars().count() + 24 * text.matches('\n').count(); + match weight { + 0..=24 => 24.0, + 25..=64 => 20.0, + 65..=140 => 16.0, + 141..=280 => 13.0, + _ => 11.0, + } +} + +/// A sticky note's layers: the frame, the note and (when known) its author. +fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { + let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { + n.fills = vec![Paint::solid(color)]; + n.corners.radii = [2.0; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + gap: 8.0, + padding: [STICKY_PAD; 4], + main_align: MainAlign::SpaceBetween, + ..AutoLayout::default() + }); + n.child_layout.sizing_v = Sizing::Hug; + n.child_layout.min_h = Some(STICKY); + n.board = Some(BoardItem::Sticky { author: author.to_string() }); + })?; + let inner = STICKY - 2.0 * STICKY_PAD; + let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; + if !author.is_empty() { + let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; + if let Some(n) = doc.node_mut(a) { + n.name = "Author".into(); + } + } + Ok((id, note)) +} + +fn sticky_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.sticky"; + require(s, cmd)?; + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let author = match get(p, "author") { + Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), + Some(Value::Bool(false)) => String::new(), + _ => author(s), + }; + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": note})) +} + +/// The first text layer inside board object `id` (its note or label). +pub fn text_of(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + n.board.as_ref().filter(|b| b.has_text())?; + n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) +} + +/// The board object whose text `text` is, if any. +pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { + let p = doc.node(text)?.parent?; + (text_of(doc, p) == Some(text)).then_some(p) +} + +/// Start typing in board object `id`'s text (all of it selected). False when it has none. +pub fn edit_text(s: &mut Session, id: NodeId) -> Result { + let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; + textedit::begin(s, t, None, false)?; + textedit::key(s, "SelectAll", false, false, false)?; + Ok(true) +} + +fn next_sticky(s: &mut Session, p: &Value) -> Result { + let cmd = "board.nextSticky"; + require(s, cmd)?; + let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); + let doc = s.doc()?; + let from = id(p, "id") + .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) + .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; + let n = doc.get(from)?; + if !matches!(n.board, Some(BoardItem::Sticky { .. })) { + return Err(bad(cmd, "that isn't a sticky")); + } + let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); + let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; + let at = n.transform.translation(); + let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { + "right" => (n.width + STICKY_GAP, 0.0), + "left" => (-(STICKY + STICKY_GAP), 0.0), + "below" => (0.0, n.height + STICKY_GAP), + "above" => (0.0, -(STICKY + STICKY_GAP)), + other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), + }; + let author = author(s); + if editing.is_some() { + textedit::end(s)?; + } + let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; + s.set_selection(vec![id]); + if boolean(p, "edit") == Some(true) { + // Typing goes on in the new sticky once this step is recorded. + s.request_ui("board.editText", json!({"id": id})); + } + Ok(json!({"id": id, "text": note})) +} + +/// A shape with text's layers: the frame, its outline and its text. +fn make_shape( + doc: &mut Document, + parent: NodeId, + at: (f64, f64), + size: (f64, f64), + shape: BoardShape, + text: &str, + color: Color, +) -> Result<(NodeId, NodeId)> { + let (w, h) = size; + let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { + n.fills.clear(); + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + padding: shape_padding(shape, w, h), + main_align: MainAlign::Center, + cross_align: CrossAlign::Center, + ..AutoLayout::default() + }); + n.board = Some(BoardItem::Shape { shape }); + })?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + n.stroke.weight = 1.5; + n.stroke.align = StrokeAlign::Inside; + n.child_layout.absolute = true; + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; + })?; + let [_, r, _, l] = shape_padding(shape, w, h); + let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; + Ok((id, label)) +} + +fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { + let [t, r, b, l] = shape.text_insets(); + [t * h, r * w, b * h, l * w] +} + +fn shape_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.shape"; + require(s, cmd)?; + let shape = match string(p, "shape") { + Some(v) => shape_of(cmd, &v)?, + None => s.tool.board.shape, + }; + let size = |k| { + num(p, k) + .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) + }; + let (w, h) = (size("width")?, size("height")?); + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": label})) +} + +fn stamp_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.stamp"; + require(s, cmd)?; + let stamp = match string(p, "stamp") { + Some(v) => stamp_of(cmd, &v)?, + None => s.tool.board.stamp, + }; + let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let author = author(s); + let page = s.page()?; + let half = STAMP_SIZE / 2.0; + let doc = s.doc_mut()?; + let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { + n.fills = vec![Paint::solid(stamp.color())]; + n.corners.radii = [half; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.board = Some(BoardItem::Stamp { stamp, author }); + })?; + for part in stamp.mark() { + let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { + if part.filled { + n.fills = vec![Paint::solid(Color::WHITE)]; + } else { + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::WHITE)]; + n.stroke.weight = 3.0; + (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); + n.stroke.join = StrokeJoin::Round; + n.stroke.align = StrokeAlign::Center; + } + })?; + } + s.set_selection(vec![id]); + Ok(json!({"id": id})) +} + +fn ink_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.ink"; + require(s, cmd)?; + let high = boolean(p, "highlighter").unwrap_or(false); + let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); + let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; + if get(p, "path").is_none() && get(p, "points").is_none() { + return Err(bad(cmd, "`path` or `points` is required")); + } + let page = s.page()?; + let select = boolean(p, "select").unwrap_or(true); + let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { + "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", + }}); + for k in ["path", "points"] { + if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { + o.insert(k.into(), v.clone()); + } + } + let made = crate::cmd::create::create(s, &q)?; + let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; + let n = s.doc_mut()?.get_mut(id)?; + n.opacity = opacity; + if high { + n.blend = BlendMode::Multiply; + } + n.board = Some(BoardItem::Ink); + Ok(json!({"id": id})) +} + +/// One connector end from the params: a layer on this page, or a point. +fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { + let side = match string(p, side_key) { + Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, + None => Side::Auto, + }; + match get(p, key) { + Some(Value::Object(o)) if o.contains_key("x") => { + let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); + match (x, y) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), + _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), + } + } + Some(v) => { + let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; + let doc = s.doc()?; + let n = doc.get(node)?; + if n.is_page() || doc.page_of(node) != Some(s.page()?) { + return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); + } + if matches!(n.board, Some(BoardItem::Connector(_))) { + return Err(bad(cmd, format!("`{key}` can't be another connector"))); + } + Ok(End::Node { node, side }) + } + None => Err(bad(cmd, format!("`{key}` is required"))), + } +} + +fn connector_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.connector"; + require(s, cmd)?; + let from = end_param(cmd, s, p, "from", "fromSide")?; + let to = end_param(cmd, s, p, "to", "toSide")?; + if from.node().is_some() && from.node() == to.node() { + return Err(bad(cmd, "connect two different layers")); + } + let routing = match string(p, "style") { + Some(v) => routing_of(cmd, &v)?, + None => s.tool.board.routing, + }; + let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); + let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); + let c = Connector { from, to, routing }; + let page = s.page()?; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { + n.fills.clear(); + n.strokes = vec![Paint::solid(LINE)]; + n.stroke.weight = LINE_WEIGHT; + n.stroke.join = StrokeJoin::Round; + n.stroke.cap_start = start; + n.stroke.cap_end = end; + n.board = Some(BoardItem::Connector(c)); + })?; + place_route(doc, id, &pts); + let mut out = json!({"id": id}); + if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { + out["label"] = json!(set_label(doc, id, &text)?); + } + s.set_selection(vec![id]); + Ok(out) +} + +/// The label of connector `id`, if it has one. +pub fn label_of(doc: &Document, id: NodeId) -> Option { + let parent = doc.node(id)?.parent?; + doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) +} + +/// Give connector `id` the label `text` (empty removes it); the label's id. +fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { + let existing = label_of(doc, id); + if text.trim().is_empty() { + if let Some(l) = existing { + doc.remove(l)?; + } + return Ok(None); + } + if let Some(l) = existing { + if let Some(t) = text_of(doc, l) + && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) + { + let len = td.char_count(); + td.replace_range(0, len, text); + } + return Ok(Some(l)); + } + let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; + let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [4.0; 4]; + n.clip = false; + n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + n.board = Some(BoardItem::Label { connector: id }); + })?; + let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; + if let Some(n) = doc.node_mut(t) + && let NodeKind::Text(td) = &mut n.kind + { + // The label hugs its text: one line per paragraph. + td.auto_resize = TextAutoResize::WidthAndHeight; + n.child_layout.sizing_h = Sizing::Hug; + } + Ok(Some(l)) +} + +fn label_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.label"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let doc = s.doc()?; + let id = match doc.node(id).and_then(|n| n.board.clone()) { + Some(BoardItem::Connector(_)) => id, + Some(BoardItem::Label { connector }) => connector, + _ => return Err(bad(cmd, "that isn't a connector")), + }; + let text = req_string(cmd, p, "text")?; + let label = set_label(s.doc_mut()?, id, &text)?; + Ok(json!({"id": id, "label": label})) +} + +/// The board objects among `ids`, each with its item (a label counts as its connector's text). +fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { + ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() +} + +fn set_color(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setColor"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + match item { + BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], + BoardItem::Shape { .. } => { + let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; + let n = doc.get_mut(outline)?; + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + } + BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], + BoardItem::Label { .. } | BoardItem::Stamp { .. } => continue, + } + changed.push(id); + } + if changed.is_empty() { + return Err(bad(cmd, "select stickies, shapes, connectors or ink")); + } + Ok(json!({"ids": changed})) +} + +/// What `board.setStyle` changes (each `None` leaves it). +struct Restyle { + shape: Option, + routing: Option, + size: Option, + start: Option, + end: Option, +} + +impl Restyle { + fn of(cmd: &str, p: &Value) -> Result { + let size = num(p, "fontSize") + .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) + .transpose()?; + Ok(Restyle { + shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, + routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, + size, + start: cap_of(cmd, p, "startCap")?, + end: cap_of(cmd, p, "endCap")?, + }) + } + + /// Restyle board object `id` (`item`); whether anything about it changed. + fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { + let changed = match item { + BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { + Some(new) => { + reshape(doc, id, new)?; + true + } + None => false, + }, + BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, + _ => false, + }; + // Text sizes: shapes' and labels' text (stickies size theirs to fit). + let sized = match (self.size, &item) { + (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), + _ => false, + }; + Ok(changed || sized) + } + + fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { + let n = doc.get_mut(id)?; + if let Some(r) = self.routing { + c.routing = r; + n.board = Some(BoardItem::Connector(c)); + } + if let Some(cap) = self.start { + n.stroke.cap_start = cap; + } + if let Some(cap) = self.end { + n.stroke.cap_end = cap; + } + Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) + } +} + +/// Shape with text `id` takes outline `new` (its text room too). +fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { + let n = doc.get_mut(id)?; + n.board = Some(BoardItem::Shape { shape: new }); + n.name = new.label().into(); + let (w, h) = (n.width, n.height); + if let Some(l) = n.layout.as_mut() { + l.padding = shape_padding(new, w, h); + } + if let Some(o) = doc.get(id)?.children.first().copied() + && let Some(on) = doc.node_mut(o) + { + on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; + (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); + } + Ok(()) +} + +/// Board object `id`'s text at `size`; whether it has text. +fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { + let Some(t) = text_of(doc, id) else { return false }; + if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { + set_font_size(td, size); + return true; + } + false +} + +fn set_style(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setStyle"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let restyle = Restyle::of(cmd, p)?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + if restyle.apply(doc, id, item)? { + changed.push(id); + } + } + if changed.is_empty() { + return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); + } + Ok(json!({"ids": changed})) +} + +fn set_font_size(td: &mut TextData, size: f64) { + td.style.font_size = size; + for r in &mut td.runs { + r.style.font_size = size; + } +} + +/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole +/// subtrees pair up when they have the same shape (an instance made of a main component may not; +/// then only the roots do). +pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { + let mut map = BTreeMap::new(); + for (s, c) in sources.iter().zip(copies) { + let (a, b) = (doc.subtree(*s), doc.subtree(*c)); + if a.len() == b.len() { + map.extend(a.into_iter().zip(b)); + } else { + map.insert(*s, *c); + } + } + map +} + +/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the +/// originals in `doc` (`map`: original → copy). +pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { + map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() +} + +/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the +/// originals had them), copied connectors join the copies of what they joined and labels follow +/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a +/// connector's end there stays where it is drawn, as a point, and a label without its connector +/// goes. +pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { + for (copy, item) in originals { + let mut item = item.clone(); + let copied = |n: NodeId| map.contains_key(&n); + match &mut item { + BoardItem::Connector(c) if foreign => { + let ends = ends_now(doc, *copy); + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && !copied(node) + { + *end = End::Point { x: p.x, y: p.y }; + } + } + } + BoardItem::Label { connector } if foreign && !copied(*connector) => { + let _ = doc.remove(*copy); + continue; + } + BoardItem::Connector(_) | BoardItem::Label { .. } => {} + _ => continue, + } + item.remap(map); + if let Some(n) = doc.node_mut(*copy) { + n.board = Some(item); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Derived data (the normalize pass). + +/// Before text is measured: a sticky's note takes the size its length calls for, and a resized +/// shape keeps its text inside its outline. +pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) { + return; + } + for &id in touched { + let Some(n) = doc.node(id) else { continue }; + if let Some(owner) = + owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) + && let Some(t) = text_of(doc, owner) + && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) + { + let size = sticky_font_size(&td.characters); + let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); + if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { + set_font_size(td, size); + } + continue; + } + if let Some(BoardItem::Shape { shape }) = n.board + && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) + { + let pad = shape_padding(shape, n.width, n.height); + if n.layout.as_ref().is_some_and(|l| l.padding != pad) + && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) + { + l.padding = pad; + } + } + } +} + +/// After layout: connectors run between their ends again (an end whose layer is gone stays where +/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only +/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a +/// touched (or gone) end, connector or layer around them. +pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) || touched.is_empty() { + return; + } + let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); + let (mut connectors, mut labels) = (Vec::new(), Vec::new()); + for n in doc.nodes() { + match &n.board { + Some(BoardItem::Connector(c)) => { + let ends = [c.from.node(), c.to.node()].into_iter().flatten(); + if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { + connectors.push((n.id, *c)); + } + } + Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), + _ => {} + } + } + let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); + for (id, mut c) in connectors { + let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); + if changed && let Some(n) = doc.node_mut(id) { + n.board = Some(BoardItem::Connector(c)); + } + if let Some(pts) = route(doc, &c) { + place_route(doc, id, &pts); + } + } + for (label, connector) in labels { + if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { + place_label(doc, label, connector); + } + } +} + +/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are +/// drawn, as points. Whether any did. +fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { + let (page, ends) = (doc.page_of(id), ends_now(doc, id)); + let mut detached = false; + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && (!doc.contains(node) || doc.page_of(node) != page) + { + *end = End::Point { x: p.x, y: p.y }; + detached = true; + } + } + detached +} + +/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it +/// joins): its free ends move with it. Whether they did. +fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; + if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { + return false; + } + let Some(expected) = route(doc, c).and_then(|pts| figcraft_geom::points_bounds(pts.iter().copied())).map(|b| b.origin()) else { return false }; + let d = (doc.world_transform(id) * Point::ZERO) - expected; + if d.hypot() <= 1e-9 { + return false; + } + let mut moved = false; + for end in [&mut c.from, &mut c.to] { + if let End::Point { x, y } = end { + (*x, *y) = (*x + d.x, *y + d.y); + moved = true; + } + } + moved +} + +/// Label `label` at the middle of connector `connector`; gone with it when it is gone. +fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { + let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { + let _ = doc.remove(label); + return; + }; + let Some(n) = doc.node(label) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * mid; + let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); + if n.transform != t + && let Some(n) = doc.node_mut(label) + { + n.transform = t; + } +} + +/// Connector `id`'s first and last points on the page as it is drawn now. +fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) +} + +/// The middle of connector `id`'s route by length, on the page. +fn route_mid(doc: &Document, id: NodeId) -> Option { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + let pts: Vec = network.vertices.iter().map(|v| w * v.point()).collect(); + let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); + let mut left = total / 2.0; + for s in pts.windows(2) { + let (a, b) = (s[0], s[1]); + let l = (b - a).hypot(); + if l >= left && l > 0.0 { + return Some(a.lerp(b, left / l)); + } + left -= l; + } + pts.first().copied() +} + +/// Write the route `pts` (page points) into connector `id`: its box, place and path. Untouched +/// when it already runs there, so a connector that didn't move isn't an edit. +fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) { + let Some(n) = doc.node(id) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let inv = pw.inverse(); + let local: Vec = pts.iter().map(|p| inv * *p).collect(); + let Some(b) = figcraft_geom::points_bounds(local.iter().copied()) else { return }; + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { + return; + } + let rel: Vec = local.iter().map(|p| Point::new(p.x - b.x0, p.y - b.y0)).collect(); + let network = VectorNetwork::from_points(&rel, false); + let t = Affine::translate((b.x0, b.y0)); + let same = + n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); + if same { + return; + } + if let Some(n) = doc.node_mut(id) { + n.kind = NodeKind::Vector { network }; + n.transform = t; + (n.width, n.height) = (b.width(), b.height()); + } +} + +/// The page points connector `c` runs through: from the middle of the side of each attached layer +/// facing the other end (or the side it names), straight or in right angles. +pub fn route(doc: &Document, c: &Connector) -> Option> { + let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); + let centre = |e: &End, b: Option| match e { + End::Point { x, y } => Some(Point::new(*x, *y)), + End::Node { .. } => b.map(|b| b.center()), + }; + let (fb, tb) = (bounds(&c.from), bounds(&c.to)); + let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); + let (p0, h0) = attach(&c.from, fb, tc)?; + let (p1, h1) = attach(&c.to, tb, fc)?; + let mut pts = match c.routing { + Routing::Straight => vec![p0, p1], + Routing::Elbow => elbow(p0, h0, p1, h1), + }; + pts.dedup(); + // A turn that doesn't turn (the ends in line) is no point of the route. + let in_line = |i: usize, b: &Point| match (i.checked_sub(1).and_then(|j| pts.get(j)), pts.get(i + 1)) { + (Some(a), Some(c)) => (a.x == b.x && b.x == c.x) || (a.y == b.y && b.y == c.y), + _ => false, + }; + let pts: Vec = pts.iter().enumerate().filter(|(i, b)| !in_line(*i, b)).map(|(_, b)| *b).collect(); + pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) +} + +/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and +/// whether that side faces sideways (`None`: a free point). +fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { + match e { + End::Point { x, y } => Some((Point::new(*x, *y), None)), + End::Node { side, .. } => { + let b = b?; + let side = match side { + Side::Auto => { + let d = other - b.center(); + // Compare against the box's proportions: a wide box takes the sides more. + if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { + if d.x >= 0.0 { Side::Right } else { Side::Left } + } else if d.y >= 0.0 { + Side::Bottom + } else { + Side::Top + } + } + s => *s, + }; + Some((side.point(b), Some(side.horizontal()))) + } + } +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals +/// (`h`: sideways; a free end follows the other). +fn elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { + let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); + let h1 = h1.unwrap_or(h0); + match (h0, h1) { + (true, true) => { + let mx = (p0.x + p1.x) / 2.0; + vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] + } + (false, false) => { + let my = (p0.y + p1.y) / 2.0; + vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] + } + (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], + (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], + } +} + +// --------------------------------------------------------------------------------------------- +// The board tools. + +impl Session { + /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, + /// or start erasing. + pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { + // A board tool kept while a design file came to the front: back to Move for this press. + if !is_board(self.doc()?) { + self.tool.tool = ToolId::Move; + return self.pointer(ev); + } + // A sticky still being typed in ends first, so the next object is an undo step of its own. + if self.ds()?.text_edit.is_some() { + crate::textedit::end(self)?; + } + match self.tool.tool { + ToolId::Sticky => { + let half = STICKY / 2.0; + let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + if let Some(id) = r.get("id").and_then(id_of) { + edit_text(self, id)?; + } + Ok(()) + } + ToolId::BoardShape => { + let half = SHAPE / 2.0; + self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + Ok(()) + } + ToolId::Stamp => { + // Stamps go on one after another: the tool stays. + self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; + Ok(()) + } + ToolId::Marker | ToolId::Highlighter => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Connector => { + let from = self.end_at(ev); + self.tool.gesture = Gesture::Connect { from, start: ev.p() }; + Ok(()) + } + ToolId::Eraser => { + self.begin_interaction("Erase")?; + self.tool.gesture = Gesture::Erase { gone: Vec::new() }; + self.erase_at(ev) + } + _ => Ok(()), + } + } + + /// After placing an object: back to the Move tool unless tools stay on. + fn board_placed(&mut self) { + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + + /// What a connector end at the pointer attaches to: the object there, or the point. + fn end_at(&self, ev: &PointerEvent) -> End { + let hit = (|| { + let doc = self.doc().ok()?; + let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; + let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); + let n = doc.node(t)?; + (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) + })(); + match hit { + Some(node) => End::Node { node, side: Side::Auto }, + None => End::Point { x: ev.x, y: ev.y }, + } + } + + pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { + let a = match from { + End::Point { x, y } => Point::new(x, y), + End::Node { .. } => start, + }; + self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); + self.tool.overlay.drop_target = from.node(); + self.tool.gesture = Gesture::Connect { from, start }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + self.tool.overlay.drop_target = None; + // A click, not a drag: nothing to join. + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let to = self.end_at(ev); + if to.node().is_some() && to.node() == from.node() { + return Ok(()); + } + let json_end = |e: End| match e { + End::Node { node, .. } => json!(node), + End::Point { x, y } => json!({"x": x, "y": y}), + }; + let style = self.tool.board.routing.id(); + self.execute("board.connector", &json!({"from": json_end(from), "to": json_end(to), "style": style}))?; + self.board_placed(); + Ok(()) + } + + /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the + /// gesture ends, which is one undo step). + pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { + let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; + let tol = ERASER_PX / ev.zoom.max(1e-6); + self.tool.overlay.brush = Some([ev.x, ev.y, tol]); + let (doc, page) = (self.doc()?.clone(), self.page()?); + if let Some(hit) = + doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) + { + gone.push(hit); + let ids = gone.clone(); + self.preview(|d, sel| { + for i in &ids { + let _ = d.remove(*i); + } + sel.retain(|s| !ids.contains(s)); + Ok(()) + })?; + } + self.tool.gesture = Gesture::Erase { gone }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } + + pub(crate) fn erase_up(&mut self) -> Result<()> { + self.tool.overlay.brush = None; + self.commit_interaction() + } + + /// A finished marker or highlighter stroke (page points). + pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { + let high = self.tool.tool == ToolId::Highlighter; + self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; + // Strokes come one after another: the selection stays as it was. + Ok(()) + } +} + +impl Session { + /// A double-click with the Move tool on a board object: type in its text (a connector gets a + /// label to type in). True when it was one. + pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { + let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| owner_of_text(doc, te.id) == Some(whole)); + let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + match item { + BoardItem::Connector(_) => { + let label = match label_of(doc, whole) { + Some(l) => l, + None => { + let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; + r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? + } + }; + edit_text(self, label) + } + b if b.has_text() => edit_text(self, whole), + _ => Ok(false), + } + } +} diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs new file mode 100644 index 00000000..5b8cbbf9 --- /dev/null +++ b/crates/engine/src/board_tests.rs @@ -0,0 +1,363 @@ +//! Boards: the file kind, sticky notes, shapes with text, connectors that follow what they join +//! (locally, through undo and in live sessions), stamps, ink and the eraser, and refusals. + +use figcraft_doc::{ + BOARD_PAGE, BoardItem, BoardShape, Connector, End, FileKind, Node, NodeId, NodeKind, Routing, Side, Stamp, StrokeCap, board_color, +}; +use figcraft_geom::Point; +use serde_json::{Value, json}; + +use crate::board::{route, sticky_font_size}; +use crate::collab_live_tests::Room; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use crate::{EngineError, Session}; + +pub(crate) fn board() -> Session { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({"name": "Ideas"})).unwrap(); + s +} + +pub(crate) fn id(v: &Value, key: &str) -> NodeId { + v[key].as_u64().map(NodeId).unwrap_or_else(|| panic!("no {key} in {v}")) +} + +pub(crate) fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().node(id).unwrap().clone() +} + +pub(crate) fn connector(s: &Session, id: NodeId) -> Connector { + match node(s, id).board { + Some(BoardItem::Connector(c)) => c, + other => panic!("not a connector: {other:?}"), + } +} + +/// Connector `id`'s points on the page. +pub(crate) fn drawn(s: &Session, id: NodeId) -> Vec { + let doc = s.doc().unwrap(); + let NodeKind::Vector { network } = &doc.node(id).unwrap().kind else { panic!("not a vector") }; + let w = doc.world_transform(id); + network.vertices.iter().map(|v| w * v.point()).collect() +} + +pub(crate) fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-6 +} + +#[test] +fn a_board_is_a_kind_of_file_that_saves_and_reopens() { + let s = board(); + let doc = s.doc().unwrap(); + assert_eq!(doc.kind, FileKind::Board); + assert!(matches!(doc.node(doc.pages[0]).unwrap().kind, NodeKind::Page { background, .. } if background == BOARD_PAGE)); + let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap(); + assert_eq!(back.kind, FileKind::Board); + // Files from before boards (no kind) are design files, and design files don't write one. + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + let json = figcraft_format::to_json(d.doc().unwrap()).unwrap(); + assert!(json.get("kind").is_none()); + assert_eq!(figcraft_format::from_json(&json.to_string()).unwrap().0.kind, FileKind::Design); + assert_eq!(crate::inspect::document(&s, 0, false)["kind"], "board", "inspect shows the kind"); +} + +#[test] +fn board_commands_refuse_on_design_files_and_prototyping_refuses_on_boards() { + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0})), ("tool.sticky", json!({})), ("tool.set", json!({"tool": "marker"}))] { + assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd} on a design file"); + } + let mut s = board(); + for cmd in ["view.devMode", "view.prototype", "proto.present", "proto.flow.add", "dev.focus"] { + let e = s.execute(cmd, &json!({})).unwrap_err(); + assert!(e.to_string().contains("boards have no prototypes"), "{cmd}: {e}"); + } +} + +#[test] +fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() { + let mut s = board(); + s.prefs.user_name = "Ada".into(); + let r = s.execute("board.sticky", &json!({"x": 10, "y": 20, "text": "Hi", "color": "blue"})).unwrap(); + let (st, note) = (id(&r, "id"), id(&r, "text")); + let n = node(&s, st); + assert!(n.is_board_atom() && matches!(&n.board, Some(BoardItem::Sticky { author }) if author == "Ada")); + assert_eq!((n.width, n.height), (200.0, 200.0)); + assert_eq!(n.fills[0].solid_color(), board_color("blue")); + assert!(n.layout.is_some() && !n.effects.is_empty()); + assert_eq!(n.children.len(), 2, "the note and its author"); + assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0); + // A click on the note picks the sticky whole. + let doc = s.doc().unwrap(); + assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st); + // A long note takes a smaller size; a very long one grows the sticky past its square. + let long = "word ".repeat(120); + s.execute("node.set", &json!({"id": note, "props": {"characters": long}})).unwrap(); + assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, sticky_font_size(&long)); + assert!(sticky_font_size(&long) < 16.0); + assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0); + // Undo puts the short note and its size back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0); +} + +#[test] +fn next_sticky_goes_beside_in_the_same_colour_and_emptied_notes_stay() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink"})).unwrap(), "id"); + let r = s.execute("board.nextSticky", &json!({})).unwrap(); + let b = id(&r, "id"); + let (na, nb) = (node(&s, a), node(&s, b)); + assert_eq!(nb.transform.translation().x, na.width + 24.0); + assert_eq!(nb.fills, na.fills); + assert_eq!(s.selection(), vec![b]); + // Typing in it and emptying it again keeps its note; ending selects the sticky. + let note = id(&r, "text"); + crate::board::edit_text(&mut s, b).unwrap(); + crate::textedit::insert(&mut s, "x").unwrap(); + crate::textedit::key(&mut s, "Backspace", false, false, false).unwrap(); + crate::textedit::end(&mut s).unwrap(); + assert!(s.doc().unwrap().contains(note)); + assert_eq!(s.selection(), vec![b]); +} + +#[test] +fn shapes_carry_text_and_change_outline() { + let mut s = board(); + let r = s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "text": "Decide", "color": "green"})).unwrap(); + let sh = id(&r, "id"); + let n = node(&s, sh); + assert_eq!(n.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })); + assert_eq!(n.children.len(), 2); + assert!(matches!(node(&s, n.children[0]).kind, NodeKind::Vector { .. })); + s.execute("board.setStyle", &json!({"ids": [sh], "shape": "speechBubble", "fontSize": 24})).unwrap(); + assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::SpeechBubble })); + assert_eq!(node(&s, id(&r, "text")).kind.text().unwrap().style.font_size, 24.0); + s.execute("board.setColor", &json!({"ids": [sh], "color": "#FF0000"})).unwrap(); + assert_eq!(node(&s, n.children[0]).fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into())); + assert!(s.execute("board.shape", &json!({"shape": "blob", "x": 0, "y": 0})).is_err()); +} + +#[test] +fn connectors_follow_what_they_join_and_undo_with_it() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id"); + let pts = drawn(&s, c); + assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}"); + assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow); + // Its label sits at the middle. + let label = crate::board::label_of(s.doc().unwrap(), c).unwrap(); + let lb = s.doc().unwrap().world_bounds(label).unwrap(); + assert!(near(lb.center(), Point::new(300.0, 100.0)), "{lb:?}"); + // Move one end: the connector re-routes (elbow now, out of the facing sides). + s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap(); + s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap(); + let pts = drawn(&s, c); + assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}"); + assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}"); + // Undo takes the move back and the route with it. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + // A deleted end stays where it was, as a point. + s.execute("edit.delete", &json!({"ids": [b]})).unwrap(); + assert!(matches!(connector(&s, c).to, End::Point { .. })); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + // A connector moved on its own takes its free end along; the attached one stays. + s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap(); + assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c)); + assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0))); + // Deleting the connector takes its label. + s.execute("edit.delete", &json!({"ids": [c]})).unwrap(); + assert!(!s.doc().unwrap().contains(label)); + assert!(s.execute("board.connector", &json!({"from": a, "to": a})).is_err()); +} + +#[test] +fn routes_are_the_same_for_the_same_document() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow }; + let doc = s.doc().unwrap(); + let r = route(doc, &c).unwrap(); + assert_eq!(r, route(&doc.clone(), &c).unwrap()); + // Down out of the bottom, across halfway, down into the point. + assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]); +} + +#[test] +fn stamps_and_ink_and_the_eraser() { + let mut s = board(); + s.prefs.user_name = "Grace".into(); + let st = id(&s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "heart"})).unwrap(), "id"); + let n = node(&s, st); + assert_eq!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Heart, author: "Grace".into() })); + assert_eq!(s.doc().unwrap().world_bounds(st).unwrap().center(), Point::new(100.0, 100.0)); + assert!(!n.children.is_empty()); + // Marker and highlighter strokes through the tools; the eraser takes what it touches. + let draw = |s: &mut Session, tool: &str, y: f64| { + s.execute("tool.set", &json!({"tool": tool})).unwrap(); + for (k, x) in [(PointerKind::Down, 0.0), (PointerKind::Move, 50.0), (PointerKind::Move, 100.0), (PointerKind::Up, 150.0)] { + s.pointer(&PointerEvent::at(k, x, y, 1.0)).unwrap(); + } + }; + draw(&mut s, "marker", 300.0); + draw(&mut s, "highlighter", 400.0); + let inks: Vec = s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).cloned().collect(); + assert_eq!(inks.len(), 2); + assert!(inks.iter().any(|n| n.opacity < 1.0 && n.stroke.weight > 10.0), "a highlighter stroke"); + let undo = s.undo_depth().0; + s.execute("tool.eraser", &json!({})).unwrap(); + for (k, x) in [(PointerKind::Down, 20.0), (PointerKind::Move, 60.0), (PointerKind::Up, 80.0)] { + s.pointer(&PointerEvent::at(k, x, 300.0, 1.0)).unwrap(); + } + assert_eq!(s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).count(), 1, "the marker stroke is gone"); + assert_eq!(s.undo_depth().0, undo + 1, "one step"); + assert_eq!(s.tool.tool, ToolId::Eraser); +} + +#[test] +fn the_sticky_and_connector_tools() { + let mut s = board(); + s.execute("tool.sticky", &json!({"color": "orange"})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap(); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the new sticky"); + let a = crate::board::owner_of_text(s.doc().unwrap(), te.id).unwrap(); + assert_eq!(node(&s, a).fills[0].solid_color(), board_color("orange")); + crate::textedit::insert(&mut s, "First").unwrap(); + crate::textedit::end(&mut s).unwrap(); + let b = id(&s.execute("board.sticky", &json!({"x": 500, "y": 0})).unwrap(), "id"); + s.execute("tool.connector", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 300.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 100.0, 1.0)).unwrap(); + let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap(); + let k = connector(&s, c); + assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b))); +} + +#[test] +fn boards_sync_in_live_sessions() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap(), "id"); + let mut room = Room::new(s.doc().unwrap().clone(), 2, 7); + assert_eq!(room.s(1).doc().unwrap().kind, FileKind::Board, "joiners get a board"); + let b = id(&room.s(1).execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap(), "id"); + room.settle(); + let c = id(&room.s(0).execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id"); + room.s(1).execute("board.stamp", &json!({"x": 50, "y": 50, "stamp": "star"})).unwrap(); + room.settle(); + room.check(); + // Person 1 moves the sticky person 0 connected: both see the connector follow it. + room.s(1).execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 250})).unwrap(); + room.s(0).execute("board.setColor", &json!({"ids": [a], "color": "violet"})).unwrap(); + room.settle(); + room.check(); + let doc = room.server.document(); + let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") }; + let end = doc.world_transform(c) * network.vertices.last().unwrap().point(); + assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side"); + assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet")); +} + +#[test] +fn board_commands_survive_junk() { + let junk = [ + json!({}), + json!(null), + json!([1]), + json!({"x": "NaN", "y": 1e308, "text": 5, "color": [1], "author": {"x": 1}, "parent": "p"}), + json!({"x": -1e308, "y": 0, "color": "neon", "shape": 7, "width": -5, "height": 1e308, "stamp": "skull"}), + json!({"from": {"x": "a"}, "to": 99_999, "fromSide": "inside", "style": 3, "startCap": "spike", "endCap": 7, "label": [1]}), + json!({"from": {"x": 1e308, "y": -1e308}, "to": {"x": 0, "y": 0}, "style": "elbow", "label": "\u{0}"}), + json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}), + json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}), + json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}), + ]; + let ids: Vec<&str> = crate::cmd::all_commands() + .iter() + .map(|c| c.id) + .filter(|i| { + i.starts_with("board.") + || ["tool.sticky", "tool.shape", "tool.connector", "tool.marker", "tool.highlighter", "tool.eraser", "tool.stamp", "file.newBoard"] + .contains(i) + }) + .collect(); + assert!(ids.len() >= 17, "{ids:?}"); + for cmd in ids { + for j in &junk { + let mut s = board(); + let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = s.execute("board.shape", &json!({"x": 300, "y": 0})).unwrap()["id"].clone(); + let _ = s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } + // Every other command on a board with objects on it. + for spec in crate::cmd::all_commands() { + if spec.id.starts_with("file.") && spec.id != "file.newBoard" || spec.id == "text.rescanFonts" || spec.id == "image.place" { + continue; + } + for j in &junk[..3] { + let mut s = board(); + let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = s.execute("board.stamp", &json!({"x": 300, "y": 0})).unwrap()["id"].clone(); + let _ = s.execute("board.connector", &json!({"from": a, "to": b})); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} + +#[test] +fn a_press_on_a_stickys_background_picks_and_drags_it() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Hi"})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id"); + s.execute("select.none", &json!({})).unwrap(); + // Below the note, on the sticky's own fill (a top-level frame's background is the canvas). + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 150.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 250.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 450.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap(); + assert_eq!(s.selection(), vec![a]); + assert_eq!(node(&s, a).transform.translation().y, 300.0); + assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c)); +} + +#[test] +fn a_copy_of_joined_objects_joins_the_copies() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})).unwrap(), "id"); + let label = crate::board::label_of(s.doc().unwrap(), c).unwrap(); + s.execute("edit.duplicate", &json!({"ids": [a, b, c, label]})).unwrap(); + let copies = s.selection(); + assert_eq!(copies.len(), 4, "{copies:?}"); + let k = copies.iter().find_map(|i| match node(&s, *i).board { + Some(BoardItem::Connector(k)) => Some(k), + _ => None, + }); + let k = k.unwrap(); + assert!(copies.contains(&k.from.node().unwrap()) && copies.contains(&k.to.node().unwrap()), "{k:?} {copies:?}"); + // Copy and paste too. + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = s.selection(); + let k = pasted.iter().find_map(|i| match node(&s, *i).board { + Some(BoardItem::Connector(k)) => Some(k), + _ => None, + }); + let k = k.unwrap(); + assert!(pasted.contains(&k.from.node().unwrap()) && pasted.contains(&k.to.node().unwrap()), "{k:?} {pasted:?}"); +} diff --git a/crates/engine/src/board_tools_tests.rs b/crates/engine/src/board_tools_tests.rs new file mode 100644 index 00000000..4109adda --- /dev/null +++ b/crates/engine/src/board_tools_tests.rs @@ -0,0 +1,277 @@ +//! Boards, continued: copies and pastes (within a file and from another), routes with named sides +//! and free points, connectors inside moving sections, styles and labels with their refusals, and +//! the shape, stamp, highlighter and connector tools with double-clicks. + +use figcraft_doc::{BoardItem, BoardShape, End, NodeId, Side, Stamp, StrokeCap, board_color}; +use figcraft_geom::Point; +use serde_json::json; + +use crate::Session; +use crate::board::route; +use crate::board_tests::{board, connector, drawn, id, near, node}; +use crate::tools::{PointerEvent, PointerKind, ToolId}; + +/// The connector among `ids`, if any. +fn connector_in(s: &Session, ids: &[NodeId]) -> Option { + let doc = s.doc().ok()?; + ids.iter().find_map(|i| match &doc.node(*i)?.board { + Some(BoardItem::Connector(k)) => Some(*k), + _ => None, + }) +} + +#[test] +fn pasting_into_another_board_detaches_what_was_left_behind() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let r = s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "x"})).unwrap(); + let (c, label) = (id(&r, "id"), id(&r, "label")); + let first = s.active; + // A second board whose own ids are the same small numbers as the first's. + let second = s.execute("file.newBoard", &json!({})).unwrap()["index"].clone(); + for x in [0, 300, 600, 900] { + s.execute("board.sticky", &json!({"x": x, "y": 500})).unwrap(); + } + let others: Vec = s.doc().unwrap().nodes().map(|n| n.id).collect(); + s.execute("doc.switch", &json!({"index": first})).unwrap(); + s.execute("select.set", &json!({"ids": [a, c, label]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("doc.switch", &json!({"index": second})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let doc = s.doc().unwrap(); + let pasted = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_))) && !others.contains(&n.id)).unwrap().clone(); + let k = connector(&s, pasted.id); + let from = k.from.node().unwrap(); + assert!(!others.contains(&from) && matches!(doc.node(from).unwrap().board, Some(BoardItem::Sticky { .. })), "joins the pasted sticky"); + let to = drawn(&s, pasted.id).last().copied().unwrap(); + assert!(matches!(k.to, End::Point { x, y } if near(Point::new(x, y), to)), "the end left behind stays where it is drawn: {k:?}"); + let pasted_label = crate::board::label_of(doc, pasted.id).expect("the label came with its connector"); + assert!(!others.contains(&pasted_label)); + // A label copied without its connector doesn't come. + s.execute("doc.switch", &json!({"index": first})).unwrap(); + s.execute("select.set", &json!({"ids": [label]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("doc.switch", &json!({"index": second})).unwrap(); + let labels = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Label { .. }))).count(); + let before = labels(&s); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(labels(&s), before); + // Within its own file, a connector pasted alone stays joined to the same layers. + s.execute("doc.switch", &json!({"index": first})).unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let k = connector_in(&s, &s.selection()).unwrap(); + assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b))); +} + +#[test] +fn duplicating_and_alt_dragging_keep_connections_inside_copied_sections() { + let mut s = board(); + let sec = id(&s.execute("node.create", &json!({"type": "section", "x": -50, "y": -50, "width": 400, "height": 400})).unwrap(), "id"); + let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id"); + s.execute("edit.duplicate", &json!({"ids": [sec, c]})).unwrap(); + let copies = s.selection(); + let from = connector_in(&s, &copies).unwrap().from.node().unwrap(); + let doc = s.doc().unwrap(); + assert!(from != a && copies.iter().any(|r| doc.is_ancestor(*r, from)), "the copy joins the sticky inside the copied section"); + // Alt-drag copies the same way. + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let press = |k, x| PointerEvent { alt: true, ..PointerEvent::at(k, x, 100.0, 1.0) }; + for (k, x) in [(PointerKind::Down, 700.0), (PointerKind::Move, 760.0), (PointerKind::Move, 800.0), (PointerKind::Up, 800.0)] { + s.pointer(&press(k, x)).unwrap(); + } + let copies = s.selection(); + assert_eq!(copies.len(), 2); + let k = connector_in(&s, &copies).unwrap(); + assert!(copies.contains(&k.to.node().unwrap()), "{k:?}"); +} + +#[test] +fn routes_take_named_sides_and_free_points() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id"); + let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id"); + let doc = s.doc().unwrap(); + let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow }).unwrap(); + let side = |n, side| End::Node { node: n, side }; + let p = |x, y| End::Point { x, y }; + let pt = Point::new; + // Side to side: out, across halfway, in. + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]); + // A side and a top or bottom: one turn. + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]); + assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]); + // Two free points: the longer way first; in a line, no turns. + assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]); + assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]); + // An automatic side faces the other end. + let straight = route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight }); + assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]); + assert_eq!(Side::from_id("TOP"), Some(Side::Top)); + assert_eq!(Side::from_id("inside"), None); + // The same place twice is refused, as are a side that doesn't exist and a point without numbers. + assert!(s.execute("board.connector", &json!({"from": {"x": 5, "y": 5}, "to": {"x": 5, "y": 5}})).is_err()); + assert!(s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "inside"})).is_err()); + assert!(s.execute("board.connector", &json!({"from": a, "to": {"x": "a"}})).is_err()); + assert!(s.execute("board.connector", &json!({"from": a})).is_err()); +} + +#[test] +fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits() { + let mut s = board(); + let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id"); + let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id"); + let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id"); + assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0))); + // Only the section is edited; the sticky inside it moves with it, and so does the connector. + s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap(); + assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c)); + // An edit that touches neither end leaves the connector as it was. + let before = s.doc().unwrap().node_arc(c).cloned().unwrap(); + s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, s.doc().unwrap().node_arc(c).unwrap())); +} + +#[test] +fn styles_labels_and_their_refusals() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "author": false})).unwrap(), "id"); + assert_eq!(node(&s, a).children.len(), 1, "no author line"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0, "author": " Lin "})).unwrap(), "id"); + assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { ref author }) if author == "Lin")); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "startCap": "circleFilled", "endCap": "none"})).unwrap(), "id"); + assert_eq!((node(&s, c).stroke.cap_start, node(&s, c).stroke.cap_end), (StrokeCap::CircleFilled, StrokeCap::None)); + // Labels: set, change, size, remove; named by the label itself too. + let l = id(&s.execute("board.label", &json!({"id": c, "text": "first"})).unwrap(), "label"); + assert_eq!(id(&s.execute("board.label", &json!({"id": l, "text": "second"})).unwrap(), "label"), l, "the same label, new text"); + let text = crate::board::text_of(s.doc().unwrap(), l).unwrap(); + assert_eq!(node(&s, text).kind.text().unwrap().characters, "second"); + s.execute("board.setStyle", &json!({"ids": [l], "fontSize": 36})).unwrap(); + assert_eq!(node(&s, text).kind.text().unwrap().style.font_size, 36.0); + assert!(s.execute("board.label", &json!({"id": c, "text": ""})).unwrap()["label"].is_null()); + assert!(!s.doc().unwrap().contains(l)); + // What doesn't take a style or colour says so. + assert!(s.execute("board.setStyle", &json!({"ids": [a], "fontSize": 30})).is_err(), "stickies size their own text"); + assert!(s.execute("board.setStyle", &json!({"ids": [c], "fontSize": 0})).is_err()); + let st = id(&s.execute("board.stamp", &json!({"x": 0, "y": 0, "stamp": "check"})).unwrap(), "id"); + assert!(s.execute("board.setColor", &json!({"ids": [st], "color": "blue"})).is_err()); + assert!(s.execute("board.setColor", &json!({"ids": [a]})).is_err()); + assert!(s.execute("board.label", &json!({"id": a, "text": "x"})).is_err()); + // A palette colour on a line draws in its deeper shade; a hex colour as given. + s.execute("board.setColor", &json!({"ids": [c], "color": "blue"})).unwrap(); + assert!(node(&s, c).strokes[0].solid_color().is_some() && node(&s, c).strokes[0].solid_color() != board_color("blue")); + s.execute("board.setColor", &json!({"ids": [c], "color": "#123456"})).unwrap(); + assert_eq!(node(&s, c).strokes[0].solid_color().map(|c| c.to_hex()), Some("123456".into())); + // Next stickies on each side, and refusals. + for (side, dx, dy) in [("below", 0.0, 224.0), ("left", -224.0, 0.0), ("above", 0.0, -224.0)] { + let n = id(&s.execute("board.nextSticky", &json!({"id": a, "side": side})).unwrap(), "id"); + let t = node(&s, n).transform.translation(); + assert_eq!((t.x, t.y), (dx, dy), "{side}"); + } + assert!(s.execute("board.nextSticky", &json!({"id": a, "side": "diagonal"})).is_err()); + assert!(s.execute("board.nextSticky", &json!({"id": c})).is_err()); + assert!(s.execute("board.ink", &json!({})).is_err()); + assert!(s.execute("board.sticky", &json!({"x": 0, "y": 0, "parent": c})).is_err(), "a connector holds no layers"); +} + +#[test] +fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() { + let mut s = board(); + let click = |s: &mut Session, x, y| { + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); + }; + s.execute("tool.shape", &json!({"shape": "roundedRectangle"})).unwrap(); + click(&mut s, 100.0, 100.0); + let sh = s.selection()[0]; + assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::RoundedRectangle })); + assert_eq!(s.tool.tool, ToolId::Move, "back to Move after placing"); + s.execute("tool.stamp", &json!({"stamp": "question"})).unwrap(); + click(&mut s, 400.0, 100.0); + click(&mut s, 450.0, 100.0); + let stamps = s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Question, .. }))).count(); + assert_eq!(stamps, 2); + assert_eq!(s.tool.tool, ToolId::Stamp, "stamps go on one after another"); + assert!(s.execute("tool.stamp", &json!({"stamp": "skull"})).is_err()); + s.execute("tool.highlighter", &json!({"color": "pink"})).unwrap(); + assert_eq!(Some(s.tool.board.highlighter), board_color("pink")); + s.execute("tool.marker", &json!({"color": "#336699"})).unwrap(); + assert_eq!(s.tool.board.marker.to_hex(), "336699"); + // A connector drag that is only a click, or from an object to itself, makes nothing. + s.execute("tool.connector", &json!({"style": "straight"})).unwrap(); + click(&mut s, 100.0, 100.0); + s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap(); + assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging"); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap(); + assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_))))); + // From the shape out to empty canvas: a connector to a free point. + s.pointer(&PointerEvent::at(PointerKind::Down, 80.0, 140.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 80.0, 600.0, 1.0)).unwrap(); + let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap(); + assert!(matches!(connector(&s, c).to, End::Point { .. })); + // Double-clicks: on the shape its text, on the connector a new label, both typed into. + s.execute("tool.move", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 140.0, 1.0)).unwrap(); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the shape"); + assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(sh)); + crate::textedit::end(&mut s).unwrap(); + let line = drawn(&s, c); + let m = line[0].midpoint(line[1]); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, m.x, m.y, 1.0)).unwrap(); + let label = crate::board::label_of(s.doc().unwrap(), c).expect("a label was added"); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the label"); + assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(label)); +} + +#[test] +fn an_ink_stroke_ends_where_the_button_comes_up() { + let mut s = board(); + s.execute("tool.marker", &json!({})).unwrap(); + // Down and up with no move between: the stroke still runs from the press to the release. + s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 150.0, 0.0, 1.0)).unwrap(); + let doc = s.doc().unwrap(); + let ink = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Ink))).map(|n| n.id).expect("a stroke"); + let b = doc.world_bounds(ink).unwrap(); + assert!(b.x0.abs() < 6.0 && (b.x1 - 150.0).abs() < 6.0, "from 0 to 150 (stroke width aside): {b:?}"); +} + +#[test] +fn a_second_sticky_ends_the_first_ones_typing_and_undoes_alone() { + let mut s = board(); + s.execute("tool.sticky", &json!({})).unwrap(); + s.prefs.keep_tool = true; + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap(); + let text = s.ds().unwrap().text_edit.as_ref().expect("typing in the new sticky").id; + crate::textedit::insert(&mut s, "first note").unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 100.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 500.0, 100.0, 1.0)).unwrap(); + let stickies = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).count(); + let typed = |s: &Session| s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters.clone(); + assert_eq!(stickies(&s), 2); + crate::textedit::end(&mut s).unwrap(); + s.undo().unwrap(); + assert_eq!(stickies(&s), 1, "the second sticky is its own undo step"); + assert_eq!(typed(&s), "first note", "the first sticky keeps what was typed in it"); +} + +#[test] +fn a_board_tool_left_on_presses_as_move_in_a_design_file() { + let mut s = board(); + s.execute("tool.sticky", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id"); + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.tool.tool, ToolId::Move); + assert_eq!(s.selection(), vec![r], "the press selected as Move does"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 4b6cd3fc..5180b340 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -21,8 +21,9 @@ pub const SEP: &str = "-"; pub static MENU: &[MenuItem] = &[ // File m!("File", "New design file", "file.new"), - // FigCraft's own: starter templates (not counted by `parity`, like the other items of its own - // listed in `OWN`). + m!("File", "New board", "file.newBoard"), + // FigCraft's own: boards and starter templates (not counted by `parity`, like the other items + // of its own listed in `OWN`). m!("File", "New from template…", "file.newFromTemplate"), m!("File", "New window", "window.new"), m!("File", "Open…", "file.open"), @@ -289,7 +290,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; +const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3a9f6d67..33c611a9 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -111,7 +111,8 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> return None; } let n = doc.node(id)?; - if !n.visible || n.locked { + // Board objects (stickies, shapes with text) never take layers in. + if !n.visible || n.locked || n.is_board_atom() { return None; } let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..2225133b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -494,6 +494,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { } out.push(nid); } + // Board connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &out); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); s.set_selection(out.clone()); s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() }); Ok(json!({"ids": out})) @@ -675,6 +678,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); all.extend(map); } + // Board connectors pasted with what they join join the pasted copies. + let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = + clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); + crate::board::rejoin(doc, &originals, &all, !copy_local); if let Some(src) = foreign { crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; } @@ -691,7 +698,10 @@ fn paste(s: &mut Session, p: &Value) -> Result { let target = id(p, "parent").or_else(|| { sel.first() .copied() - .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + .filter(|i| { + sel.len() == 1 + && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) + }) .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) }); let parent = target.unwrap_or(page); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 60784750..d8ad924d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -11,22 +11,34 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{DocState, EngineError, Result, Session, bad, spec}; +/// A new file of `kind` in a tab of its own. +fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result { + let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into()); + let mut doc = Document::new(name); + doc.kind = kind; + // A property of the page from here on: a later change of theme leaves it alone. Boards are + // light whatever the theme. + let background = if kind == FileKind::Board { BOARD_PAGE } else { s.new_page_background() }; + for page in doc.pages.clone() { + if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { + *b = background; + } + } + s.docs.push(DocState::new(doc, None)); + s.active = s.docs.len() - 1; + Ok(json!({"index": s.active, "kind": kind.id()})) +} + pub fn specs() -> Vec { vec![ - spec!("file.new", "New design file", "{name?}", false, |s, p| { - let name = string(p, "name").unwrap_or_else(|| "Untitled".into()); - let mut doc = Document::new(name); - // A property of the page from here on: a later change of theme leaves it alone. - let background = s.new_page_background(); - for page in doc.pages.clone() { - if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { - *b = background; - } - } - s.docs.push(DocState::new(doc, None)); - s.active = s.docs.len() - 1; - Ok(json!({"index": s.active})) - }), + spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)), + spec!( + "file.newBoard", + "New board", + "{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page", + false, + |s, p| new_file(s, p, FileKind::Board) + ), spec!( "file.open", "Open", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f011238d..60d08963 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -109,6 +109,7 @@ fn registry() -> &'static Registry { list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); list.extend(crate::templates::specs()); + list.extend(crate::board::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0592046e..0905284c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -200,7 +200,7 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re Ok(json!({"on": *slot})) } -fn tool(s: &mut Session, t: ToolId) -> Result { +pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result { // A file you can't edit now (view only, or on its way to a server): moving around only. if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) { return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into())); @@ -465,11 +465,14 @@ pub fn specs() -> Vec { spec!( "tool.set", "Choose tool", - "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}", + "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}", false, |s, p| { let t = req_string("tool.set", p, "tool")?; let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?; + if t.on_boards() { + crate::board::require(s, &format!("tool.{}", t.id()))?; + } tool(s, t) } ), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index a6973f01..d30f5f51 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -135,6 +135,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !n.exports.is_empty() { o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); } + if let Some(b) = &n.board { + o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); + } if !n.annotations.is_empty() { o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); } @@ -312,6 +315,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); json!({ "name": doc.name, + "kind": doc.kind.id(), "path": ds.path, "dirty": ds.is_dirty(), "currentPage": ds.page, diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index c5e3c334..1ffad5e2 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -193,6 +193,15 @@ pub static KEYS: &[Binding] = &[ k(Tools, "tool.text", &["T"]), k(Tools, "tool.comment", &["C"]), k(Tools, "tool.shapeBuilder", &["Shift+M"]), + // Board tools (on boards only), on keys nothing else uses. + k(Tools, "tool.sticky", &["Q"]), + k(Tools, "tool.shape", &["B"]), + k(Tools, "tool.connector", &["X"]), + k(Tools, "tool.marker", &["M"]), + k(Tools, "tool.highlighter", &["U"]), + k(Tools, "tool.eraser", &["E"]), + k(Tools, "tool.stamp", &["W"]), + k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(), // View // The documentation gives both keys for the pixel grid in different places. k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0e5b34bb..c9298022 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; pub mod background; +pub mod board; pub mod booleans; pub mod branches; pub mod catalog; @@ -936,6 +937,7 @@ impl Session { /// as it was. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + board::refuse_on_board(self, id)?; // Revert replaces the document's uid: refuse before ending previews or syncing branches. if id == "file.revert" && self.live().doc_uid.is_some() { return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into())); @@ -1394,7 +1396,9 @@ pub fn normalize(doc: &mut Document, before: &Document) { figcraft_layout::coherent_sizing(doc, id); } touched.extend(doc.take_dirty()); - // 1. Text auto-size. + // 1. Text auto-size (a sticky's note first takes the size its length calls for). + board::settle_text(doc, before, &touched); + touched.extend(doc.take_dirty()); for id in live(doc, &touched) { let Some(n) = doc.node(id) else { continue }; if let Some((w, h)) = figcraft_text::auto_size(n) @@ -1475,6 +1479,9 @@ pub fn normalize(doc: &mut Document, before: &Document) { } } touched.extend(doc.take_dirty()); + // 4b. Board connectors follow the layers they join; their labels sit at their middles. + board::settle_routes(doc, before, &touched); + touched.extend(doc.take_dirty()); // 5. Ready-for-dev layers whose contents changed show "Changed". mark_dev_changes(doc, before, &touched); doc.take_dirty(); @@ -1607,6 +1614,10 @@ mod batch_step_ref_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod board_tests; +#[cfg(test)] +mod board_tools_tests; +#[cfg(test)] mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index cf7d5d47..c0d7e65e 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -120,6 +120,15 @@ static ROWS: &[Row] = &[ pin(Tools, "tool.slice", &["S"], &[]), pin(Tools, "file.placeImage", &["Shift+Cmd+K"], &[]), pin(Tools, "tool.shapeBuilder", &["Shift+M"], &[]), + // Board tools (boards only), on keys no design-file shortcut uses. + pin(Tools, "tool.sticky", &["Q"], &[]), + pin(Tools, "tool.shape", &["B"], &[]), + pin(Tools, "tool.connector", &["X"], &[]), + pin(Tools, "tool.marker", &["M"], &[]), + pin(Tools, "tool.highlighter", &["U"], &[]), + pin(Tools, "tool.eraser", &["E"], &[]), + pin(Tools, "tool.stamp", &["W"], &[]), + pin(Tools, "board.nextSticky", &["Shift+Cmd+Enter"], &[]), // Vector edit mode doc(Selection, "Edit the selected shape's points", &["Enter"], &[], Bound("select.children"), "Enter on a shape edits it; on a frame or group it selects the children"), doc(Shape, "Vector edit: move", &["V"], &[], VectorTool(VTool::Move), ""), @@ -398,7 +407,7 @@ fn counts_match_the_docs() { let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count(); let pinned = ROWS.len() - documented.len(); // docs/ui-parity.md quotes these. - assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 57)); } #[test] diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 902ba149..85ea535b 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -32,7 +32,9 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - pub fn end(s: &mut Session) -> Result<()> { let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) }; s.ds_mut()?.text_edit = None; - let empty = text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false); + // A board object's text (a sticky's note) stays when emptied; the object is selected again. + let owner = s.doc().ok().and_then(|d| crate::board::owner_of_text(d, te.id)); + let empty = owner.is_none() && text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false); if empty { let doc = s.doc_mut()?; let _ = doc.remove(te.id); @@ -50,6 +52,9 @@ pub fn end(s: &mut Session) -> Result<()> { } s.commit_interaction()?; let ds = s.ds_mut()?; + if let Some(o) = owner { + ds.selection = vec![o]; + } ds.revision += 1; Ok(()) } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dc28d73e..4b7b2abf 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -39,10 +39,18 @@ pub enum ToolId { Comment, Eyedropper, ShapeBuilder, + // Board tools. + Sticky, + BoardShape, + Connector, + Marker, + Highlighter, + Eraser, + Stamp, } impl ToolId { - pub const ALL: [ToolId; 18] = [ + pub const ALL: [ToolId; 25] = [ ToolId::Move, ToolId::Scale, ToolId::Hand, @@ -61,6 +69,13 @@ impl ToolId { ToolId::Comment, ToolId::Eyedropper, ToolId::ShapeBuilder, + ToolId::Sticky, + ToolId::BoardShape, + ToolId::Connector, + ToolId::Marker, + ToolId::Highlighter, + ToolId::Eraser, + ToolId::Stamp, ]; pub fn id(self) -> &'static str { match self { @@ -82,6 +97,13 @@ impl ToolId { ToolId::Comment => "comment", ToolId::Eyedropper => "eyedropper", ToolId::ShapeBuilder => "shapeBuilder", + ToolId::Sticky => "sticky", + ToolId::BoardShape => "shape", + ToolId::Connector => "connector", + ToolId::Marker => "marker", + ToolId::Highlighter => "highlighter", + ToolId::Eraser => "eraser", + ToolId::Stamp => "stamp", } } pub fn from_id(s: &str) -> Option { @@ -107,8 +129,23 @@ impl ToolId { ToolId::Comment => "Comment", ToolId::Eyedropper => "Pick color", ToolId::ShapeBuilder => "Shape builder", + ToolId::Sticky => "Sticky note", + ToolId::BoardShape => "Shapes", + ToolId::Connector => "Connector", + ToolId::Marker => "Marker", + ToolId::Highlighter => "Highlighter", + ToolId::Eraser => "Eraser", + ToolId::Stamp => "Stamp", } } + + /// The tools of a board's toolbar (a design file's toolbar has the others). + pub fn on_boards(self) -> bool { + matches!( + self, + ToolId::Sticky | ToolId::BoardShape | ToolId::Connector | ToolId::Marker | ToolId::Highlighter | ToolId::Eraser | ToolId::Stamp + ) + } /// The tool's key (from [`crate::keymap`]; empty when it has none). pub fn shortcut(self) -> &'static str { let id = self.id(); @@ -404,6 +441,15 @@ pub(crate) enum Gesture { }, /// Dragging the rotation origin handle (see [`crate::rotation_origin`]). Origin, + /// The connector tool: dragging from `from` (pressed at `start`). + Connect { + from: figcraft_doc::End, + start: Point, + }, + /// The eraser: the strokes it took so far. + Erase { + gone: Vec, + }, /// Shape builder: regions collected so far (handled in `shapebuilder`). ShapeBuild { picked: Vec, @@ -464,6 +510,8 @@ pub struct ToolState { pub labels: Vec<(NodeId, [Point; 4])>, /// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke). pub pencil_brush: Option, + /// The board tools' colours and choices. + pub board: crate::board::BoardTool, } /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most @@ -1005,6 +1053,7 @@ impl Session { Ok(()) } ToolId::Eyedropper => self.eyedrop(ev), + t if t.on_boards() => self.board_down(ev), t if t.draws_shape() => self.draw_down(ev), _ => Ok(()), }, @@ -1203,12 +1252,13 @@ impl Session { } // A frame's name label picks the frame itself. The background of a top-level frame with // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is - // selected; an empty frame (or a widget) is picked and dragged. + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. let board_background = |leaf: NodeId| { !sel.contains(&leaf) - && doc - .node(leaf) - .is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n)) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) }; let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) @@ -1328,6 +1378,9 @@ impl Session { let idx = doc.index_in_parent(*i).map(|x| x + 1); copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); } + // Connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &copies); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); moving = copies; *sel = moving.clone(); } @@ -1492,6 +1545,15 @@ impl Session { Ok(()) } Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), + Gesture::Connect { from, start } => { + let (from, start) = (*from, *start); + self.connect_drag(ev, from, start); + return Ok(()); + } + Gesture::Erase { gone } => { + self.tool.gesture = Gesture::Erase { gone: gone.clone() }; + return self.erase_at(ev); + } Gesture::PenDrag { anchor } => { // A click's wobble pulls out no handle. let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; @@ -1868,10 +1930,17 @@ impl Session { self.tool.tool = t; } } - Gesture::Pencil { points, pressure } => { + Gesture::Pencil { mut points, mut pressure } => { self.tool.overlay.pen.clear(); + // The stroke ends where the button came up, even without a move to get there. + if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + points.push(ev.p()); + pressure.push(ev.pressure); + } self.pencil_finish(&points, &pressure)?; } + Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, + Gesture::Erase { .. } => self.erase_up()?, Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} Gesture::Vector(_) => self.commit_interaction()?, @@ -1922,6 +1991,9 @@ impl Session { self.cancel_interaction()?; } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(&doc, leaf, &sel)? { + return Ok(()); + } let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. if edits @@ -2268,6 +2340,9 @@ impl Session { } let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); let d = fitted.to_svg(); + if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { + return self.ink_finish(&d); + } let page = self.page()?; let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); let pw = self.doc()?.world_transform(parent); diff --git a/crates/format/src/thumbnail.rs b/crates/format/src/thumbnail.rs index a5e7e2b3..76ebcf4e 100644 --- a/crates/format/src/thumbnail.rs +++ b/crates/format/src/thumbnail.rs @@ -109,6 +109,8 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option { pub struct Preview { pub page: Option<[f32; 4]>, pub picture: Option, + /// A design file or a board (`.fig` files and images: design). + pub kind: figcraft_doc::FileKind, } /// The [`Preview`] of the file at `path`, its picture at most `fit` pixels on its longer side. @@ -119,7 +121,7 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview { let Ok(file) = std::fs::File::open(path) else { return Preview::default() }; archive_preview(std::io::BufReader::new(file), fit) } - FileKind::Image => Preview { page: None, picture: file_thumbnail(path, fit) }, + FileKind::Image => Preview { picture: file_thumbnail(path, fit), ..Preview::default() }, FileKind::Svg | FileKind::Other => Preview::default(), } } @@ -129,31 +131,33 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview { pub fn archive_preview(archive: R, fit: u32) -> Preview { let Ok(mut z) = zip::ZipArchive::new(archive) else { return Preview::default() }; let picture = crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok().and_then(|png| decode_thumbnail(&png, fit)); - let page = if z.file_names().any(|n| n == "document.json") { - crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().and_then(|json| figcraft_page(&json)) + let (page, kind) = if z.file_names().any(|n| n == "document.json") { + crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().map(|json| figcraft_page(&json)).unwrap_or_default() } else { - crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json)) + (crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json)), figcraft_doc::FileKind::Design) }; - Preview { page, picture } + Preview { page, picture, kind } } -/// The first page's background in a `.figcraft` `document.json`. -fn figcraft_page(json: &[u8]) -> Option<[f32; 4]> { +/// The first page's background in a `.figcraft` `document.json`, and the file's kind. +fn figcraft_page(json: &[u8]) -> (Option<[f32; 4]>, figcraft_doc::FileKind) { #[derive(serde::Deserialize)] struct Head { #[serde(default)] pages: Vec, #[serde(default)] nodes: std::collections::BTreeMap, + #[serde(default)] + kind: figcraft_doc::FileKind, } #[derive(serde::Deserialize)] struct Node { #[serde(default)] background: Option, } - let head: Head = serde_json::from_slice(json).ok()?; - let first = head.pages.first()?; - rgba(head.nodes.get(&first.to_string())?.background.as_ref()?) + let Ok(head) = serde_json::from_slice::(json) else { return (None, figcraft_doc::FileKind::Design) }; + let page = head.pages.first().and_then(|first| head.nodes.get(&first.to_string())).and_then(|n| n.background.as_ref()).and_then(rgba); + (page, head.kind) } /// The canvas colour a `.fig` file's `meta.json` gives (`client_meta.background_color`). diff --git a/crates/format/src/thumbnail_tests.rs b/crates/format/src/thumbnail_tests.rs index 864c0f24..eeaeaba5 100644 --- a/crates/format/src/thumbnail_tests.rs +++ b/crates/format/src/thumbnail_tests.rs @@ -148,7 +148,12 @@ fn previews_carry_the_first_pages_colour() { assert!(p.picture.is_some()); // A file with nothing on it: its colour, no picture. let bytes = crate::save_bytes(&doc_on([0.9, 0.9, 0.9]), None).unwrap(); - assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None }); + assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None, kind: Default::default() }); + // A board says so (the home screen marks it). + let mut board = doc_on([0.9, 0.9, 0.9]); + board.kind = figcraft_doc::FileKind::Board; + let bytes = crate::save_bytes(&board, None).unwrap(); + assert_eq!(archive_preview(Cursor::new(&bytes), 640).kind, figcraft_doc::FileKind::Board); // Damaged or odd metadata: no colour, never a panic. for meta in [&b"{"[..], b"{\"client_meta\":{\"background_color\":{\"r\":\"x\",\"g\":0,\"b\":0}}}", b"[]", b"{\"client_meta\":7}"] { assert_eq!(archive_preview(Cursor::new(zip(&[("meta.json", meta)])), 640).page, None, "{}", String::from_utf8_lossy(meta)); diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index adedc983..3e2ce652 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -126,6 +126,7 @@ status = "draft — needs native review" "Archived" = "আর্কাইভ করা হয়েছে" "Arrange" = "সাজান" "Arrow" = "তীর" +"Arrow at the end" = "শেষে তীর" "As typed" = "যেমনটি টাইপ করা হয়েছে" "Asking the server…" = "সার্ভারকে জিজ্ঞাসা করা হচ্ছে..." "Assets" = "অ্যাসেট" @@ -199,12 +200,15 @@ status = "draft — needs native review" "C or ⇧C" = "C বা ⇧C" "CMYK colour" = "CMYK রঙ" "Can view" = "দেখতে পারেন" +"Check" = "টিক" "Checking the fonts installed on this computer…" = "এই কম্পিউটারে ইনস্টল করা ফন্টগুলো চেক করা হচ্ছে..." "Checking the server…" = "সার্ভার চেক করা হচ্ছে..." +"Chevron" = "শেভরন" "Clear from this list" = "এই তালিকা থেকে মুছে ফেলুন" "Code or invite link" = "কোড বা আমন্ত্রণ লিংক" "Color profile: {name}" = "কালার প্রোফাইল: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "রঙ ও টেক্সট স্টাইল, লাইট এবং ডার্ক ভেরিয়েবল, বোতাম, ইনপুট এবং কার্ড" +"Connector" = "সংযোগকারী" "Convert to frame" = "ফ্রেম রূপান্তর করুন" "Convert to section" = "সেকশনে রূপান্তর করুন" "CSS custom properties…" = "CSS কাস্টম প্রপার্টি..." @@ -378,6 +382,7 @@ status = "draft — needs native review" "Description" = "বিবরণ" "Description (optional)" = "বিবরণ (ঐচ্ছিক)" "Design" = "ডিজাইন" +"Design panel" = "ডিজাইন প্যানেল" "Design system starter" = "ডিজাইন সিস্টেম স্টার্টার" "Desktop" = "ডেস্কটপ" "Destination" = "গন্তব্য" @@ -440,6 +445,7 @@ status = "draft — needs native review" "Effect settings" = "ইফেক্ট সেটিংস" "Effect styles" = "ইফেক্ট স্টাইল" "Effects" = "ইফেক্টসমূহ" +"Elbow" = "কোণযুক্ত" "Ellipse" = "উপবৃত্ত" "Ellipses" = "উপবৃত্তসমূহ" "Else" = "অন্যথায় (Else)" @@ -576,6 +582,7 @@ status = "draft — needs native review" "Hanging list" = "হ্যাংগিং তালিকা" "Hanging punctuation" = "হ্যাংগিং বিরামচিহ্ন" "Hard light" = "হার্ড লাইট" +"Heart" = "হৃদয়" "Height" = "উচ্চতা" "Help" = "সহায়তা" "Help and resources" = "সহায়তা ও রিসোর্স" @@ -588,6 +595,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "এই টিপসগুলো লুকান (সহায়তা মেনুর 'শুরু করা' অপশনটি এগুলো আবার দেখাবে)" "Hide when publishing" = "প্রকাশ করার সময় লুকান" "Highlight layers on hover" = "হোভারে লেয়ার হাইলাইট করুন" +"Highlighter" = "হাইলাইটার" "Highlights" = "হাইলাইটস" "Hold Alt" = "Alt চেপে ধরে রাখুন" "Hold Alt+Z" = "Alt+Z চেপে ধরে রাখুন" @@ -754,6 +762,7 @@ status = "draft — needs native review" "Mark as resolved" = "সমাধানকৃত হিসেবে চিহ্নিত করুন" "Mark as unread" = "অপঠিত হিসেবে চিহ্নিত করুন" "Mark as unresolved" = "অমীমাংসিত হিসেবে চিহ্নিত করুন" +"Marker" = "মার্কার" "Mask" = "মাস্ক" "Mass · Stiffness · Damping" = "ভর · দৃঢ়তা · ড্যাম্পিং" "Match (empty: the whole name)" = "মেলবন্ধন (খালি: সম্পূর্ণ নাম)" @@ -813,6 +822,7 @@ status = "draft — needs native review" "Named instance" = "নামকৃত ইনস্ট্যান্স" "Navigate to" = "এতে যান" "Nested instance" = "নেস্টেড ইনস্ট্যান্স" +"New board" = "নতুন বোর্ড" "New design file" = "নতুন ডিজাইন ফাইল" "Newest first" = "সবচেয়ে নতুন প্রথমে" "Next or previous control in the focused region" = "ফোকাসড অঞ্চলের পরবর্তী বা পূর্ববর্তী কন্ট্রোল" @@ -953,6 +963,7 @@ status = "draft — needs native review" "Page" = "পেজ" "Pages" = "পেজসমূহ" "Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে" +"Pink" = "গোলাপি" "Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে" "Pages: {pages}" = "পেজসমূহ: {pages}" "Paint bucket" = "পেইন্ট বালতি" @@ -997,6 +1008,7 @@ status = "draft — needs native review" "Plugins & widgets" = "প্লাগইন ও উইজেট" "Plus darker" = "প্লাস অন্ধকার" "Plus lighter" = "প্লাস হালকা" +"Plus one" = "প্লাস ওয়ান" "Point" = "বিন্দু" "Points" = "বিন্দুসমূহ" "Polygon" = "বহুভুজ" @@ -1045,6 +1057,7 @@ status = "draft — needs native review" "Push" = "পুশ" "Push changes to main component" = "প্রধান কম্পোনেন্টে পরিবর্তন পুশ করুন" "Put text on path" = "পাথে টেক্সট রাখুন" +"Question" = "প্রশ্ন" "R" = "R" "Radial" = "রেডিয়াল" "Radius" = "ব্যাসার্ধ" @@ -1145,6 +1158,7 @@ status = "draft — needs native review" "Rotation jitter" = "ঘূর্ণন জিটার" "Round" = "গোল" "Rounded" = "গোল করা" +"Rounded rectangle" = "গোলাকার আয়তক্ষেত্র" "Row" = "সারি" "Row gap" = "সারির গ্যাপ" "Row sizes" = "সারির আকার" @@ -1220,6 +1234,7 @@ status = "draft — needs native review" "Shape" = "শেপ" "Shape builder" = "শেপ বিল্ডার" "Shape tools" = "শেপ টুলস" +"Shapes" = "আকৃতি" "Share" = "শেয়ার" "Share this file" = "এই ফাইলটি শেয়ার করুন" "Share with a code" = "কোড দিয়ে শেয়ার করুন" @@ -1286,10 +1301,13 @@ status = "draft — needs native review" "Space+drag" = "Space+ড্র্যাগ" "Spacing" = "ব্যবধান" "Spam, stolen work, abuse…" = "স্প্যাম, চুরি করা কাজ, অপব্যবহার..." +"Speech bubble" = "কথার বুদবুদ" "Splay" = "সপ্লে" "Spotlight me" = "আমাকে স্পটলাইট করুন" "Spread" = "স্প্রেড" "Square" = "বর্গক্ষেত্র" +"Stamp" = "স্ট্যাম্প" +"Stamps" = "স্ট্যাম্পগুলি" "Standard" = "স্ট্যান্ডার্ড" "Standard ligatures" = "স্ট্যান্ডার্ড লিগেচার" "Star" = "তারকা" @@ -1305,12 +1323,16 @@ status = "draft — needs native review" "Starting to share…" = "শেয়ার করা শুরু হচ্ছে..." "Status" = "স্ট্যাটাস" "Sticky" = "স্টিকি" +"Sticky note" = "স্টিকি নোট" +"Sticky note colors" = "স্টিকি নোটের রং" +"Sticky notes, shapes and connectors" = "স্টিকি নোট, আকৃতি ও সংযোগকারী" "Stop" = "থামুন" "Stop following {name}" = "{name}-কে অনুসরণ করা বন্ধ করুন" "Stop session" = "সেশন বন্ধ করুন" "Stop sharing" = "শেয়ার করা বন্ধ করুন" "Stop spotlighting" = "স্পটলাইটিং বন্ধ করুন" "Stop {n} {hex}" = "স্টপ {n} {hex}" +"Straight" = "সোজা" "Stretch" = "টেনে বড় করা (Stretch)" "Strikethrough" = "স্ট্রাইকথ্রু" "String" = "স্ট্রিং" @@ -1355,6 +1377,7 @@ status = "draft — needs native review" "Text" = "টেক্সট" "Text align" = "টেক্সট সারিবদ্ধকরণ" "Text content" = "টেক্সট কন্টেন্ট" +"Text size" = "লেখার আকার" "Text style" = "টেক্সট স্টাইল" "Text styles" = "টেক্সট স্টাইলসমূহ" "Texts" = "টেক্সটসমূহ" @@ -1389,6 +1412,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "এই সার্ভারটি সবার জন্য উন্মুক্ত: কোনো কী প্রয়োজন নেই।" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "এই সার্ভারের কী প্রয়োজন: ঠিকানায় ?key=… যোগ করুন (`figcraft-server key` এটি সার্ভারের মেশিনে প্রিন্ট করে)।" "This server takes files only with its key: see Advanced." = "এই সার্ভার শুধুমাত্র এর কী সহ ফাইল নেয়: উন্নত (Advanced) দেখুন।" +"Thumbs up" = "বুড়ো আঙুল উঁচু" "Tidy up" = "পরিপাটি করুন" "Tile" = "টাইল" "Tile type" = "টাইল টাইপ" @@ -1405,6 +1429,7 @@ status = "draft — needs native review" "Top left radius" = "ওপরের বাম ব্যাসার্ধ" "Top right radius" = "ওপরের ডান ব্যাসার্ধ" "Transform" = "ট্রান্সফর্ম" +"Triangle" = "ত্রিভুজ" "Triangle arrow" = "ত্রিভুজ তীর" "Trigger" = "ট্রিগার" "True" = "সত্য (True)" @@ -1739,3 +1764,23 @@ status = "draft — needs native review" "⌘ click" = "⌘ ক্লিক" "⌘ scroll" = "⌘ স্ক্রল" "⌥ drag" = "⌥ ড্র্যাগ" + + +[commands] +"board.connector" = "সংযোগকারী" +"board.ink" = "মার্কারের টান" +"board.label" = "সংযোগকারীর লেবেল" +"board.nextSticky" = "পরের স্টিকি নোট" +"board.setColor" = "বোর্ডের রং" +"board.setStyle" = "বোর্ডের শৈলী" +"board.shape" = "লেখাসহ আকৃতি" +"board.stamp" = "স্ট্যাম্প" +"board.sticky" = "স্টিকি নোট" +"file.newBoard" = "নতুন বোর্ড" +"tool.connector" = "সংযোগকারী" +"tool.eraser" = "ইরেজার" +"tool.highlighter" = "হাইলাইটার" +"tool.marker" = "মার্কার" +"tool.shape" = "আকৃতি" +"tool.stamp" = "স্ট্যাম্প" +"tool.sticky" = "স্টিকি নোট" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index eea0e8dc..6544b7b8 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -120,6 +120,7 @@ status = "draft — needs native review" "Archived" = "Archivováno" "Arrange" = "Uspořádat" "Arrow" = "Šipka" +"Arrow at the end" = "Šipka na konci" "As typed" = "Podle zadání" "Asking the server…" = "Dotazování serveru…" "Assets" = "Zdroje" @@ -193,7 +194,9 @@ status = "draft — needs native review" "C or ⇧C" = "C nebo ⇧C" "CMYK colour" = "Barva CMYK" "Can view" = "Může prohlížet" +"Check" = "Fajfka" "Checking the server…" = "Kontrola serveru…" +"Chevron" = "Lomená šipka" "Clear from this list" = "Vymazat z tohoto seznamu" "Code or invite link" = "Kód nebo odkaz s pozvánkou" "Color profile: {name}" = "Barevný profil: {name}" @@ -282,6 +285,7 @@ status = "draft — needs native review" "Connected" = "Připojeno" "Connected to code: {from}" = "Propojeno s kódem: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Spojení ztraceno, opětovné připojování (pokus {attempt})…" +"Connector" = "Spojnice" "Constrain proportions" = "Zachovat poměr stran" "Constraints" = "Omezení" "Content" = "Obsah" @@ -368,6 +372,7 @@ status = "draft — needs native review" "Description" = "Popis" "Description (optional)" = "Popis (nepovinný)" "Design" = "Návrh" +"Design panel" = "Panel návrhu" "Design system starter" = "Základ design systému" "Desktop" = "Počítač" "Destination" = "Cíl" @@ -427,6 +432,7 @@ status = "draft — needs native review" "Effect settings" = "Nastavení efektu" "Effect styles" = "Styly efektů" "Effects" = "Efekty" +"Elbow" = "Lomená" "Ellipse" = "Elipsa" "Ellipses" = "Elipsy" "Else" = "Jinak" @@ -562,6 +568,7 @@ status = "draft — needs native review" "Hanging list" = "Předsazený seznam" "Hanging punctuation" = "Předsazená interpunkce" "Hard light" = "Tvrdé světlo" +"Heart" = "Srdce" "Height" = "Výška" "Help" = "Nápověda" "Help and resources" = "Nápověda a zdroje" @@ -574,6 +581,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Skrýt tyto tipy (znovu je zobrazíte volbou Začínáme v nabídce Nápověda)" "Hide when publishing" = "Skrýt při publikování" "Highlight layers on hover" = "Zvýraznit vrstvy pod ukazatelem" +"Highlighter" = "Zvýrazňovač" "Highlights" = "Světla" "Hold Alt" = "Podržte Alt" "Hold Alt+Z" = "Podržte Alt+Z" @@ -739,6 +747,7 @@ status = "draft — needs native review" "Mark as resolved" = "Označit jako vyřešené" "Mark as unread" = "Označit jako nepřečtené" "Mark as unresolved" = "Označit jako nevyřešené" +"Marker" = "Fix" "Mask" = "Maska" "Mass · Stiffness · Damping" = "Hmotnost · Tuhost · Tlumení" "Match (empty: the whole name)" = "Hledaný text (prázdné: celý název)" @@ -797,6 +806,7 @@ status = "draft — needs native review" "Named instance" = "Pojmenovaná instance" "Navigate to" = "Přejít na" "Nested instance" = "Vnořená instance" +"New board" = "Nová tabule" "New design file" = "Nový soubor návrhu" "Newest first" = "Od nejnovějších" "Next frame" = "Další rámec" @@ -933,6 +943,7 @@ status = "draft — needs native review" "Page deleted" = "Stránka smazána" "Pages" = "Stránky" "Pages added to the document" = "Stránky přidány do dokumentu" +"Pink" = "Růžová" "Placed on the current page" = "Umístěno na aktuální stránku" "Pages: {pages}" = "Stránky: {pages}" "Paint bucket" = "Plechovka barvy" @@ -975,6 +986,7 @@ status = "draft — needs native review" "Plugins" = "Pluginy" "Plus darker" = "Sčítání se ztmavením" "Plus lighter" = "Sčítání se zesvětlením" +"Plus one" = "Plus jedna" "Point" = "Bod" "Points" = "Body" "Polygon" = "Mnohoúhelník" @@ -1023,6 +1035,7 @@ status = "draft — needs native review" "Push" = "Vytlačení" "Push changes to main component" = "Přenést změny do hlavní komponenty" "Put text on path" = "Umístit text na cestu" +"Question" = "Otázka" "R" = "R" "Radial" = "Kruhový" "Radius" = "Poloměr" @@ -1122,6 +1135,7 @@ status = "draft — needs native review" "Rotation jitter" = "Náhodná odchylka natočení" "Round" = "Kulatý" "Rounded" = "Zaoblený" +"Rounded rectangle" = "Zaoblený obdélník" "Row" = "Řádek" "Row gap" = "Mezera mezi řádky" "Row sizes" = "Velikosti řádků" @@ -1194,6 +1208,7 @@ status = "draft — needs native review" "Shape" = "Tvar" "Shape builder" = "Tvorba tvarů" "Shape tools" = "Nástroje tvarů" +"Shapes" = "Tvary" "Share" = "Sdílet" "Share this file" = "Sdílet tento soubor" "Share with a code" = "Sdílet pomocí kódu" @@ -1260,10 +1275,13 @@ status = "draft — needs native review" "Space+drag" = "Mezerník+tažení" "Spacing" = "Rozestupy" "Spam, stolen work, abuse…" = "Spam, odcizená práce, zneužití…" +"Speech bubble" = "Bublina" "Splay" = "Rozevření" "Spotlight me" = "Sdílet můj pohled" "Spread" = "Rozšíření" "Square" = "Čtverec" +"Stamp" = "Razítko" +"Stamps" = "Razítka" "Standard" = "Standardní" "Standard ligatures" = "Standardní ligatury" "Star" = "Hvězda" @@ -1279,12 +1297,16 @@ status = "draft — needs native review" "Starting to share…" = "Zahajování sdílení…" "Status" = "Stav" "Sticky" = "Přichycené" +"Sticky note" = "Lepicí poznámka" +"Sticky note colors" = "Barvy lepicích poznámek" +"Sticky notes, shapes and connectors" = "Lepicí poznámky, tvary a spojnice" "Stop" = "Zastavit" "Stop following {name}" = "Přestat sledovat: {name}" "Stop session" = "Ukončit relaci" "Stop sharing" = "Zastavit sdílení" "Stop spotlighting" = "Ukončit sdílení pohledu" "Stop {n} {hex}" = "Zarážka {n} {hex}" +"Straight" = "Rovná" "Stretch" = "Roztáhnout" "Strikethrough" = "Přeškrtnutí" "String" = "Textový řetězec" @@ -1329,6 +1351,7 @@ status = "draft — needs native review" "Text" = "Text" "Text align" = "Zarovnání textu" "Text content" = "Textový obsah" +"Text size" = "Velikost textu" "Text style" = "Textový styl" "Text styles" = "Textové styly" "Texts" = "Texty" @@ -1359,6 +1382,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Tento server je veřejný: klíč není potřeba." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tento server vyžaduje klíč: přidejte do adresy ?key=… (příkaz `figcraft-server key` ho vypíše na počítači serveru)." "This server takes files only with its key: see Advanced." = "Tento server přijímá soubory pouze s klíčem: viz Pokročilé." +"Thumbs up" = "Palec nahoru" "Tidy up" = "Uspořádat" "Tile" = "Dlaždice" "Tile type" = "Typ dlaždic" @@ -1375,6 +1399,7 @@ status = "draft — needs native review" "Top left radius" = "Poloměr levého horního rohu" "Top right radius" = "Poloměr pravého horního rohu" "Transform" = "Transformace" +"Triangle" = "Trojúhelník" "Triangle arrow" = "Trojúhelníková šipka" "Trigger" = "Spouštěč" "True" = "Pravda" @@ -1729,6 +1754,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Zaokrouhlit na pixely" "arrange.spacing" = "Rozestupy" "arrange.tidy" = "Uspořádat" +"board.connector" = "Spojnice" +"board.ink" = "Tah fixem" +"board.label" = "Popisek spojnice" +"board.nextSticky" = "Další lepicí poznámka" +"board.setColor" = "Barva na tabuli" +"board.setStyle" = "Styl na tabuli" +"board.shape" = "Tvar s textem" +"board.stamp" = "Razítko" +"board.sticky" = "Lepicí poznámka" "bool.exclude" = "Vyloučit výběr" "bool.flatten" = "Sloučit booleovskou skupinu do vektoru" "bool.intersect" = "Průnik výběru" @@ -1854,6 +1888,7 @@ status = "draft — needs native review" "file.import" = "Importovat" "file.moveTab" = "Přesunout kartu" "file.new" = "Nový soubor návrhu" +"file.newBoard" = "Nová tabule" "file.newFromTemplate" = "Nový ze šablony" "file.open" = "Otevřít" "file.openRecent" = "Otevřít nedávné" @@ -2120,10 +2155,14 @@ status = "draft — needs native review" "text.weightUp" = "Zvětšit tloušťku písma" "tool.arrow" = "Šipka" "tool.comment" = "Komentář" +"tool.connector" = "Spojnice" "tool.ellipse" = "Elipsa" +"tool.eraser" = "Guma" "tool.frame" = "Rámec" "tool.hand" = "Ruka" +"tool.highlighter" = "Zvýrazňovač" "tool.line" = "Čára" +"tool.marker" = "Fix" "tool.move" = "Přesunout" "tool.pen" = "Pero" "tool.pencil" = "Tužka" @@ -2135,9 +2174,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Kotva měřítka" "tool.section" = "Sekce" "tool.set" = "Vybrat nástroj" +"tool.shape" = "Tvary" "tool.shapeBuilder" = "Tvorba tvarů" "tool.slice" = "Výřez" +"tool.stamp" = "Razítko" "tool.star" = "Hvězda" +"tool.sticky" = "Lepicí poznámka" "tool.text" = "Text" "transform.move" = "Přesunout" "transform.moveTo" = "Přesunout na" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index d429e9a8..b8cf80ef 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -118,6 +118,7 @@ status = "draft — needs native review" "Archived" = "Archiviert" "Arrange" = "Anordnen" "Arrow" = "Pfeil" +"Arrow at the end" = "Pfeil am Ende" "As typed" = "Wie eingegeben" "Asking the server…" = "Server wird gefragt…" "Assets" = "Assets" @@ -189,7 +190,9 @@ status = "draft — needs native review" "C or ⇧C" = "C oder ⇧C" "CMYK colour" = "CMYK-Farbe" "Can view" = "Kann ansehen" +"Check" = "Häkchen" "Checking the server…" = "Server wird geprüft…" +"Chevron" = "Chevron" "Clear from this list" = "Aus dieser Liste entfernen" "Code or invite link" = "Code oder Einladungslink" "Color profile: {name}" = "Farbprofil: {name}" @@ -278,6 +281,7 @@ status = "draft — needs native review" "Connected" = "Verbunden" "Connected to code: {from}" = "Mit Code verbunden: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Verbindung verloren, neuer Versuch ({attempt})…" +"Connector" = "Verbinder" "Constrain proportions" = "Proportionen beibehalten" "Constraints" = "Constraints" "Content" = "Inhalt" @@ -362,6 +366,7 @@ status = "draft — needs native review" "Description" = "Beschreibung" "Description (optional)" = "Beschreibung (optional)" "Design" = "Design" +"Design panel" = "Design-Bereich" "Desktop" = "Desktop" "Destination" = "Ziel" "Detach alias" = "Alias lösen" @@ -422,6 +427,7 @@ status = "draft — needs native review" "Effect settings" = "Effekteinstellungen" "Effect styles" = "Effektstile" "Effects" = "Effekte" +"Elbow" = "Gewinkelt" "Ellipse" = "Ellipse" "Ellipses" = "Ellipsen" "Else" = "Sonst" @@ -552,6 +558,7 @@ status = "draft — needs native review" "Hanging list" = "Hängende Liste" "Hanging punctuation" = "Hängende Interpunktion" "Hard light" = "Hartes Licht" +"Heart" = "Herz" "Height" = "Höhe" "Help" = "Hilfe" "Help and resources" = "Hilfe und Ressourcen" @@ -562,6 +569,7 @@ status = "draft — needs native review" "Hide from publishing" = "Nicht veröffentlichen" "Hide section contents" = "Abschnittsinhalt ausblenden" "Highlight layers on hover" = "Ebenen beim Überfahren hervorheben" +"Highlighter" = "Textmarker" "Highlights" = "Lichter" "Hold Alt" = "Alt gedrückt halten" "Hold Alt+Z" = "Alt+Z gedrückt halten" @@ -717,6 +725,7 @@ status = "draft — needs native review" "Mark as resolved" = "Als erledigt markieren" "Mark as unread" = "Als ungelesen markieren" "Mark as unresolved" = "Als nicht erledigt markieren" +"Marker" = "Marker" "Mask" = "Maske" "Mass · Stiffness · Damping" = "Masse · Steifigkeit · Dämpfung" "Match (empty: the whole name)" = "Übereinstimmung (leer: der ganze Name)" @@ -773,6 +782,7 @@ status = "draft — needs native review" "Named instance" = "Benannte Instanz" "Navigate to" = "Navigieren zu" "Nested instance" = "Verschachtelte Instanz" +"New board" = "Neues Whiteboard" "New design file" = "Neue Designdatei" "Next frame" = "Nächster Rahmen" "Next or previous control in the focused region" = "Nächstes oder vorheriges Steuerelement im fokussierten Bereich" @@ -905,6 +915,7 @@ status = "draft — needs native review" "Page deleted" = "Seite gelöscht" "Pages" = "Seiten" "Pages added to the document" = "Seiten zum Dokument hinzugefügt" +"Pink" = "Rosa" "Placed on the current page" = "Auf der aktuellen Seite platziert" "Pages: {pages}" = "Seiten: {pages}" "Paint bucket" = "Farbeimer" @@ -946,6 +957,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins laufen in der Sandbox von FigCraft, ohne Dateien, Netzwerk oder Uhr. Installieren Sie nur Plugins, denen Sie vertrauen." "Plus darker" = "Plus dunkler" "Plus lighter" = "Plus heller" +"Plus one" = "Plus eins" "Point" = "Punkt" "Points" = "Punkte" "Polygon" = "Polygon" @@ -992,6 +1004,7 @@ status = "draft — needs native review" "Push" = "Schieben" "Push changes to main component" = "Änderungen an Hauptkomponente übertragen" "Put text on path" = "Text auf Pfad setzen" +"Question" = "Frage" "R" = "R" "Radial" = "Radial" "Radius" = "Radius" @@ -1092,6 +1105,7 @@ status = "draft — needs native review" "Rotation jitter" = "Drehungsschwankung" "Round" = "Rund" "Rounded" = "Abgerundet" +"Rounded rectangle" = "Abgerundetes Rechteck" "Row" = "Zeile" "Row gap" = "Zeilenabstand" "Row sizes" = "Zeilengrößen" @@ -1161,6 +1175,7 @@ status = "draft — needs native review" "Shape" = "Form" "Shape builder" = "Formerstellung" "Shape tools" = "Formwerkzeuge" +"Shapes" = "Formen" "Share" = "Teilen" "Share this file" = "Diese Datei teilen" "Share: live session, export" = "Teilen: Live-Sitzung, Export" @@ -1218,10 +1233,13 @@ status = "draft — needs native review" "Space+drag" = "Leertaste+Ziehen" "Spacing" = "Abstand" "Spam, stolen work, abuse…" = "Spam, gestohlene Arbeit, Missbrauch…" +"Speech bubble" = "Sprechblase" "Splay" = "Spreizung" "Spotlight me" = "Spotlight auf mich" "Spread" = "Ausbreitung" "Square" = "Quadrat" +"Stamp" = "Stempel" +"Stamps" = "Stempel" "Standard" = "Standard" "Standard ligatures" = "Standardligaturen" "Star" = "Stern" @@ -1236,12 +1254,16 @@ status = "draft — needs native review" "Starting to share…" = "Teilen wird gestartet…" "Status" = "Status" "Sticky" = "Fixiert" +"Sticky note" = "Haftnotiz" +"Sticky note colors" = "Haftnotizfarben" +"Sticky notes, shapes and connectors" = "Haftnotizen, Formen und Verbinder" "Stop" = "Beenden" "Stop following {name}" = "{name} nicht mehr folgen" "Stop session" = "Sitzung beenden" "Stop sharing" = "Teilen beenden" "Stop spotlighting" = "Spotlight beenden" "Stop {n} {hex}" = "Farbunterbrechung {n} {hex}" +"Straight" = "Gerade" "Stretch" = "Strecken" "Strikethrough" = "Durchgestrichen" "String" = "Zeichenfolge" @@ -1285,6 +1307,7 @@ status = "draft — needs native review" "Text" = "Text" "Text align" = "Textausrichtung" "Text content" = "Textinhalt" +"Text size" = "Textgröße" "Text style" = "Textstil" "Text styles" = "Textstile" "Texts" = "Texte" @@ -1318,6 +1341,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Dieser Server ist öffentlich zugänglich: kein Schlüssel nötig." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Dieser Server braucht seinen Schlüssel: Fügen Sie der Adresse ?key=… hinzu (`figcraft-server key` gibt ihn auf dem Rechner des Servers aus)." "This server takes files only with its key: see Advanced." = "Dieser Server nimmt Dateien nur mit seinem Schlüssel an: siehe Erweitert." +"Thumbs up" = "Daumen hoch" "This version can't be read." = "Diese Version kann nicht gelesen werden." "This version doesn't have it: all of it is new." = "Diese Version enthält es nicht: alles daran ist neu." "Tidy up" = "Aufräumen" @@ -1336,6 +1360,7 @@ status = "draft — needs native review" "Top left radius" = "Radius oben links" "Top right radius" = "Radius oben rechts" "Transform" = "Transformieren" +"Triangle" = "Dreieck" "Triangle arrow" = "Dreieckspfeil" "Trigger" = "Auslöser" "True" = "Wahr" @@ -1685,6 +1710,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Auf Pixel runden" "arrange.spacing" = "Abstand" "arrange.tidy" = "Aufräumen" +"board.connector" = "Verbinder" +"board.ink" = "Markerstrich" +"board.label" = "Verbinderbeschriftung" +"board.nextSticky" = "Nächste Haftnotiz" +"board.setColor" = "Whiteboard-Farbe" +"board.setStyle" = "Whiteboard-Stil" +"board.shape" = "Form mit Text" +"board.stamp" = "Stempel" +"board.sticky" = "Haftnotiz" "bool.exclude" = "Auswahl ausschließen" "bool.flatten" = "Boolesche Gruppe zusammenfügen" "bool.intersect" = "Schnittmenge der Auswahl" @@ -1809,6 +1843,7 @@ status = "draft — needs native review" "file.import" = "Importieren" "file.moveTab" = "Tab verschieben" "file.new" = "Neue Designdatei" +"file.newBoard" = "Neues Whiteboard" "file.open" = "Öffnen" "file.openRecent" = "Zuletzt verwendete öffnen" "file.placeImage" = "Bild platzieren" @@ -2072,10 +2107,14 @@ status = "draft — needs native review" "text.weightUp" = "Schriftstärke erhöhen" "tool.arrow" = "Pfeil" "tool.comment" = "Kommentar" +"tool.connector" = "Verbinder" "tool.ellipse" = "Ellipse" +"tool.eraser" = "Radiergummi" "tool.frame" = "Rahmen" "tool.hand" = "Hand" +"tool.highlighter" = "Textmarker" "tool.line" = "Linie" +"tool.marker" = "Marker" "tool.move" = "Verschieben" "tool.pen" = "Zeichenstift" "tool.pencil" = "Bleistift" @@ -2087,9 +2126,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Skalierungsanker" "tool.section" = "Abschnitt" "tool.set" = "Werkzeug wählen" +"tool.shape" = "Formen" "tool.shapeBuilder" = "Formerstellung" "tool.slice" = "Slice" +"tool.stamp" = "Stempel" "tool.star" = "Stern" +"tool.sticky" = "Haftnotiz" "tool.text" = "Text" "transform.move" = "Verschieben" "transform.moveTo" = "Verschieben nach" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 162d7b3e..2f302eff 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -125,6 +125,7 @@ name = "English" "Archived" = "Archived" "Arrange" = "Arrange" "Arrow" = "Arrow" +"Arrow at the end" = "Arrow at the end" "As typed" = "As typed" "Asking the server…" = "Asking the server…" "Assets" = "Assets" @@ -199,12 +200,15 @@ name = "English" "C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Can view" = "Can view" +"Check" = "Check" "Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" +"Chevron" = "Chevron" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" +"Connector" = "Connector" "Convert to frame" = "Convert to frame" "Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" @@ -380,6 +384,7 @@ name = "English" "Description" = "Description" "Description (optional)" = "Description (optional)" "Design" = "Design" +"Design panel" = "Design panel" "Design system starter" = "Design system starter" "Desktop" = "Desktop" "Destination" = "Destination" @@ -443,6 +448,7 @@ name = "English" "Effect settings" = "Effect settings" "Effect styles" = "Effect styles" "Effects" = "Effects" +"Elbow" = "Elbow" "Ellipse" = "Ellipse" "Ellipses" = "Ellipses" "Else" = "Else" @@ -579,6 +585,7 @@ name = "English" "Hanging list" = "Hanging list" "Hanging punctuation" = "Hanging punctuation" "Hard light" = "Hard light" +"Heart" = "Heart" "Height" = "Height" "Help" = "Help" "Help and resources" = "Help and resources" @@ -591,6 +598,7 @@ name = "English" "Hide these tips (Getting started in the Help menu shows them again)" = "Hide these tips (Getting started in the Help menu shows them again)" "Hide when publishing" = "Hide when publishing" "Highlight layers on hover" = "Highlight layers on hover" +"Highlighter" = "Highlighter" "Highlights" = "Highlights" "Hold Alt" = "Hold Alt" "Hold Alt+Z" = "Hold Alt+Z" @@ -760,6 +768,7 @@ name = "English" "Mark as resolved" = "Mark as resolved" "Mark as unread" = "Mark as unread" "Mark as unresolved" = "Mark as unresolved" +"Marker" = "Marker" "Mask" = "Mask" "Mass · Stiffness · Damping" = "Mass · Stiffness · Damping" "Match (empty: the whole name)" = "Match (empty: the whole name)" @@ -819,6 +828,7 @@ name = "English" "Named instance" = "Named instance" "Navigate to" = "Navigate to" "Nested instance" = "Nested instance" +"New board" = "New board" "New design file" = "New design file" "Newest first" = "Newest first" "Next or previous control in the focused region" = "Next or previous control in the focused region" @@ -961,6 +971,7 @@ name = "English" "Page deleted" = "Page deleted" "Pages" = "Pages" "Pages added to the document" = "Pages added to the document" +"Pink" = "Pink" "Placed on the current page" = "Placed on the current page" "Pages: {pages}" = "Pages: {pages}" "Paint bucket" = "Paint bucket" @@ -1005,6 +1016,7 @@ name = "English" "Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" +"Plus one" = "Plus one" "Point" = "Point" "Points" = "Points" "Polygon" = "Polygon" @@ -1053,6 +1065,7 @@ name = "English" "Push" = "Push" "Push changes to main component" = "Push changes to main component" "Put text on path" = "Put text on path" +"Question" = "Question" "R" = "R" "Radial" = "Radial" "Radius" = "Radius" @@ -1157,6 +1170,7 @@ name = "English" "Rotation jitter" = "Rotation jitter" "Round" = "Round" "Rounded" = "Rounded" +"Rounded rectangle" = "Rounded rectangle" "Row" = "Row" "Row gap" = "Row gap" "Row sizes" = "Row sizes" @@ -1233,6 +1247,7 @@ name = "English" "Shape" = "Shape" "Shape builder" = "Shape builder" "Shape tools" = "Shape tools" +"Shapes" = "Shapes" "Share" = "Share" "Share this file" = "Share this file" "Share with a code" = "Share with a code" @@ -1299,10 +1314,13 @@ name = "English" "Space+drag" = "Space+drag" "Spacing" = "Spacing" "Spam, stolen work, abuse…" = "Spam, stolen work, abuse…" +"Speech bubble" = "Speech bubble" "Splay" = "Splay" "Spotlight me" = "Spotlight me" "Spread" = "Spread" "Square" = "Square" +"Stamp" = "Stamp" +"Stamps" = "Stamps" "Standard" = "Standard" "Standard ligatures" = "Standard ligatures" "Star" = "Star" @@ -1319,12 +1337,16 @@ name = "English" "Starting to share…" = "Starting to share…" "Status" = "Status" "Sticky" = "Sticky" +"Sticky note" = "Sticky note" +"Sticky note colors" = "Sticky note colors" +"Sticky notes, shapes and connectors" = "Sticky notes, shapes and connectors" "Stop" = "Stop" "Stop following {name}" = "Stop following {name}" "Stop session" = "Stop session" "Stop sharing" = "Stop sharing" "Stop spotlighting" = "Stop spotlighting" "Stop {n} {hex}" = "Stop {n} {hex}" +"Straight" = "Straight" "Stretch" = "Stretch" "Strikethrough" = "Strikethrough" "String" = "String" @@ -1369,6 +1391,7 @@ name = "English" "Text" = "Text" "Text align" = "Text align" "Text content" = "Text content" +"Text size" = "Text size" "Text style" = "Text style" "Text styles" = "Text styles" "Texts" = "Texts" @@ -1405,6 +1428,7 @@ name = "English" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." +"Thumbs up" = "Thumbs up" "This version can't be read." = "This version can't be read." "This version doesn't have it: all of it is new." = "This version doesn't have it: all of it is new." "Tidy up" = "Tidy up" @@ -1424,6 +1448,7 @@ name = "English" "Top padding" = "Top padding" "Top right radius" = "Top right radius" "Transform" = "Transform" +"Triangle" = "Triangle" "Triangle arrow" = "Triangle arrow" "Trigger" = "Trigger" "True" = "True" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 96666c42..3f4263da 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,11 @@ name = "Español" status = "draft — needs native review" [ui] +"Arrow at the end" = "Flecha al final" +"Check" = "Visto" +"Chevron" = "Cheurón" "Compatibility notes" = "Notas de compatibilidad" +"Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" "% of all commits" = "% del total de commits" @@ -381,6 +385,7 @@ status = "draft — needs native review" "Description" = "Descripción" "Description (optional)" = "Descripción (opcional)" "Design" = "Diseño" +"Design panel" = "Panel de diseño" "Design system starter" = "Base de sistema de diseño" "Desktop" = "Escritorio" "Destination" = "Destino" @@ -444,6 +449,7 @@ status = "draft — needs native review" "Effect settings" = "Ajustes del efecto" "Effect styles" = "Estilos de efecto" "Effects" = "Efectos" +"Elbow" = "En ángulo" "Ellipse" = "Elipse" "Ellipses" = "Elipses" "Else" = "Si no" @@ -580,6 +586,7 @@ status = "draft — needs native review" "Hanging list" = "Lista colgante" "Hanging punctuation" = "Puntuación colgante" "Hard light" = "Luz fuerte" +"Heart" = "Corazón" "Height" = "Alto" "Help" = "Ayuda" "Help and resources" = "Ayuda y recursos" @@ -592,6 +599,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estos consejos (Primeros pasos en el menú Ayuda los vuelve a mostrar)" "Hide when publishing" = "Ocultar al publicar" "Highlight layers on hover" = "Resaltar capas al pasar el cursor" +"Highlighter" = "Resaltador" "Highlights" = "Luces" "Hold Alt" = "Mantén Alt" "Hold Alt+Z" = "Mantén Alt+Z" @@ -761,6 +769,7 @@ status = "draft — needs native review" "Mark as resolved" = "Marcar como resuelto" "Mark as unread" = "Marcar como no leído" "Mark as unresolved" = "Marcar como no resuelto" +"Marker" = "Rotulador" "Mask" = "Máscara" "Mass · Stiffness · Damping" = "Masa · Rigidez · Amortiguación" "Match (empty: the whole name)" = "Coincidencia (vacío: el nombre entero)" @@ -820,6 +829,7 @@ status = "draft — needs native review" "Named instance" = "Instancia con nombre" "Navigate to" = "Navegar a" "Nested instance" = "Instancia anidada" +"New board" = "Nueva pizarra" "New design file" = "Nuevo archivo de diseño" "Newest first" = "Más recientes primero" "Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada" @@ -962,6 +972,7 @@ status = "draft — needs native review" "Page deleted" = "Página eliminada" "Pages" = "Páginas" "Pages added to the document" = "Páginas añadidas al documento" +"Pink" = "Rosa" "Placed on the current page" = "Colocado en la página actual" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Bote de pintura" @@ -1006,6 +1017,7 @@ status = "draft — needs native review" "Plugins & widgets" = "Plugins y widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" +"Plus one" = "Más uno" "Point" = "Punto" "Points" = "Puntos" "Polygon" = "Polígono" @@ -1054,6 +1066,7 @@ status = "draft — needs native review" "Push" = "Empujar" "Push changes to main component" = "Enviar cambios al componente principal" "Put text on path" = "Poner texto en trazado" +"Question" = "Pregunta" "R" = "R" "Radial" = "Radial" "Radius" = "Radio" @@ -1158,6 +1171,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variación de rotación" "Round" = "Redondo" "Rounded" = "Redondeado" +"Rounded rectangle" = "Rectángulo redondeado" "Row" = "Fila" "Row gap" = "Espacio entre filas" "Row sizes" = "Tamaños de fila" @@ -1234,6 +1248,7 @@ status = "draft — needs native review" "Shape" = "Forma" "Shape builder" = "Generador de formas" "Shape tools" = "Herramientas de forma" +"Shapes" = "Formas" "Share" = "Compartir" "Share this file" = "Compartir este archivo" "Share with a code" = "Comparte con un código" @@ -1300,10 +1315,13 @@ status = "draft — needs native review" "Space+drag" = "Espacio+arrastrar" "Spacing" = "Espaciado" "Spam, stolen work, abuse…" = "Spam, obra robada, abuso…" +"Speech bubble" = "Bocadillo" "Splay" = "Apertura" "Spotlight me" = "Ponerme en el foco" "Spread" = "Extensión" "Square" = "Cuadrado" +"Stamp" = "Sello" +"Stamps" = "Sellos" "Standard" = "Estándar" "Standard ligatures" = "Ligaduras estándar" "Star" = "Estrella" @@ -1320,12 +1338,16 @@ status = "draft — needs native review" "Starting to share…" = "Empezando a compartir…" "Status" = "Estado" "Sticky" = "Adherido" +"Sticky note" = "Nota adhesiva" +"Sticky note colors" = "Colores de las notas adhesivas" +"Sticky notes, shapes and connectors" = "Notas adhesivas, formas y conectores" "Stop" = "Parar" "Stop following {name}" = "Dejar de seguir a {name}" "Stop session" = "Detener sesión" "Stop sharing" = "Dejar de compartir" "Stop spotlighting" = "Salir del foco" "Stop {n} {hex}" = "Parada {n} {hex}" +"Straight" = "Recto" "Stretch" = "Estirar" "Strikethrough" = "Tachado" "String" = "Cadena" @@ -1370,6 +1392,7 @@ status = "draft — needs native review" "Text" = "Texto" "Text align" = "Alineación del texto" "Text content" = "Contenido de texto" +"Text size" = "Tamaño del texto" "Text style" = "Estilo de texto" "Text styles" = "Estilos de texto" "Texts" = "Textos" @@ -1406,6 +1429,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)." "This server takes files only with its key: see Advanced." = "Este servidor solo acepta archivos con su clave: mira Avanzado." +"Thumbs up" = "Pulgar arriba" "This version can't be read." = "No se puede leer esta versión." "This version doesn't have it: all of it is new." = "Esta versión no lo tiene: todo es nuevo." "Tidy up" = "Ordenar" @@ -1425,6 +1449,7 @@ status = "draft — needs native review" "Top padding" = "Margen interno superior" "Top right radius" = "Radio superior derecho" "Transform" = "Transformar" +"Triangle" = "Triángulo" "Triangle arrow" = "Flecha triangular" "Trigger" = "Desencadenante" "True" = "Verdadero" @@ -1792,6 +1817,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Redondear a píxeles" "arrange.spacing" = "Espaciado" "arrange.tidy" = "Ordenar" +"board.connector" = "Conector" +"board.ink" = "Trazo de rotulador" +"board.label" = "Etiqueta del conector" +"board.nextSticky" = "Siguiente nota adhesiva" +"board.setColor" = "Color en la pizarra" +"board.setStyle" = "Estilo en la pizarra" +"board.shape" = "Forma con texto" +"board.stamp" = "Sello" +"board.sticky" = "Nota adhesiva" "bool.exclude" = "Excluir selección" "bool.flatten" = "Acoplar booleana" "bool.intersect" = "Intersecar selección" @@ -1918,6 +1952,7 @@ status = "draft — needs native review" "file.import" = "Importar" "file.moveTab" = "Mover pestaña" "file.new" = "Nuevo archivo de diseño" +"file.newBoard" = "Nueva pizarra" "file.newFromTemplate" = "Nuevo desde plantilla" "file.open" = "Abrir" "file.openRecent" = "Abrir reciente" @@ -2192,10 +2227,14 @@ status = "draft — needs native review" "text.weightUp" = "Aumentar peso de fuente" "tool.arrow" = "Flecha" "tool.comment" = "Comentario" +"tool.connector" = "Conector" "tool.ellipse" = "Elipse" +"tool.eraser" = "Borrador" "tool.frame" = "Marco" "tool.hand" = "Mano" +"tool.highlighter" = "Resaltador" "tool.line" = "Línea" +"tool.marker" = "Rotulador" "tool.move" = "Mover" "tool.pen" = "Pluma" "tool.pencil" = "Lápiz" @@ -2207,9 +2246,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Ancla de escala" "tool.section" = "Sección" "tool.set" = "Elegir herramienta" +"tool.shape" = "Formas" "tool.shapeBuilder" = "Generador de formas" "tool.slice" = "Sector" +"tool.stamp" = "Sello" "tool.star" = "Estrella" +"tool.sticky" = "Nota adhesiva" "tool.text" = "Texto" "transform.move" = "Mover" "transform.moveTo" = "Mover a" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 205a9d29..8b1b8743 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -127,6 +127,7 @@ status = "draft — needs native review" "Archived" = "Arkistoitu" "Arrange" = "Järjestä" "Arrow" = "Nuoli" +"Arrow at the end" = "Nuoli loppuun" "As typed" = "Kirjoitetussa muodossa" "Asking the server…" = "Kysytään palvelimelta…" "Assets" = "Resurssit" @@ -221,8 +222,10 @@ status = "draft — needs native review" "Changes on main" = "Päähaaran muutokset" "Changes on the branch" = "Haaran muutokset" "Changes unknown" = "Muutokset tuntemattomia" +"Check" = "Valintamerkki" "Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…" "Checking the server…" = "Tarkistetaan palvelinta…" +"Chevron" = "Kulmanuoli" "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI tai Compose yhdistääksesi tämän komponentin sen koodiin." "Choose another profile to change it." = "Vaihda valitsemalla toinen profiili." "Choose collection" = "Valitse kokoelma" @@ -290,6 +293,7 @@ status = "draft — needs native review" "Connected" = "Yhdistetty" "Connected to code: {from}" = "Yhdistetty koodiin: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Yhteys katkesi, yhdistetään uudelleen (yritys {attempt})…" +"Connector" = "Yhdistin" "Constrain proportions" = "Säilytä mittasuhteet" "Constraints" = "Rajoitteet" "Content" = "Sisältö" @@ -380,6 +384,7 @@ status = "draft — needs native review" "Description" = "Kuvaus" "Description (optional)" = "Kuvaus (valinnainen)" "Design" = "Suunnittelu" +"Design panel" = "Suunnittelupaneeli" "Design system starter" = "Muotoilujärjestelmän aloituspohja" "Desktop" = "Työpöytä" "Destination" = "Kohde" @@ -442,6 +447,7 @@ status = "draft — needs native review" "Effect settings" = "Tehosteen asetukset" "Effect styles" = "Tehostetyylit" "Effects" = "Tehosteet" +"Elbow" = "Kulmikas" "Ellipse" = "Ellipsi" "Ellipses" = "Ellipsit" "Else" = "Muuten" @@ -578,6 +584,7 @@ status = "draft — needs native review" "Hanging list" = "Riippuva luettelo" "Hanging punctuation" = "Välimerkkien ulonnus" "Hard light" = "Kova valo" +"Heart" = "Sydän" "Height" = "Korkeus" "Help" = "Ohje" "Help and resources" = "Ohje ja resurssit" @@ -590,6 +597,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Kätke nämä vinkit (Ohje-valikon Aloittaminen näyttää ne uudelleen)" "Hide when publishing" = "Kätke julkaistaessa" "Highlight layers on hover" = "Korosta tasot osoitettaessa" +"Highlighter" = "Korostuskynä" "Highlights" = "Huippuvalot" "Hold Alt" = "Pidä Alt painettuna" "Hold Alt+Z" = "Pidä Alt+Z painettuna" @@ -756,6 +764,7 @@ status = "draft — needs native review" "Mark as resolved" = "Merkitse ratkaistuksi" "Mark as unread" = "Merkitse lukemattomaksi" "Mark as unresolved" = "Merkitse ratkaisemattomaksi" +"Marker" = "Tussi" "Mask" = "Maski" "Mass · Stiffness · Damping" = "Massa · jäykkyys · vaimennus" "Match (empty: the whole name)" = "Etsittävä (tyhjä: koko nimi)" @@ -815,6 +824,7 @@ status = "draft — needs native review" "Named instance" = "Nimetty ilmentymä" "Navigate to" = "Siirry kohteeseen" "Nested instance" = "Sisäkkäinen ilmentymä" +"New board" = "Uusi taulu" "New design file" = "Uusi suunnittelutiedosto" "Newest first" = "Uusimmat ensin" "Next frame" = "Seuraava kehys" @@ -987,6 +997,7 @@ status = "draft — needs native review" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Valitse Kehys-työkalu ja vedä piirtoalueella tai valitse laitteen koko" "Pin a property" = "Kiinnitä ominaisuus" "Pinch" = "Nipistys" +"Pink" = "Vaaleanpunainen" "Pixel grid" = "Pikseliruudukko" "Pixel preview 1x" = "Pikseliesikatselu 1x" "Pixel preview 2x" = "Pikseliesikatselu 2x" @@ -1001,6 +1012,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Liitännäiset toimivat FigCraftin eristetyssä ympäristössä ilman tiedostoja, verkkoa tai kelloa. Asenna vain luottamiasi liitännäisiä." "Plus darker" = "Tummempi summa" "Plus lighter" = "Vaaleampi summa" +"Plus one" = "Plus yksi" "Point" = "Piste" "Points" = "Pisteet" "Polygon" = "Monikulmio" @@ -1048,6 +1060,7 @@ status = "draft — needs native review" "Push" = "Työntö" "Push changes to main component" = "Siirrä muutokset pääkomponenttiin" "Put text on path" = "Sijoita teksti polulle" +"Question" = "Kysymys" "R" = "R" "Radial" = "Säteittäinen" "Radius" = "Säde" @@ -1148,6 +1161,7 @@ status = "draft — needs native review" "Rotation jitter" = "Kierron vaihtelu" "Round" = "Pyöreä" "Rounded" = "Pyöristetty" +"Rounded rectangle" = "Pyöristetty suorakulmio" "Row" = "Rivi" "Row gap" = "Rivien väli" "Row sizes" = "Rivien koot" @@ -1223,6 +1237,7 @@ status = "draft — needs native review" "Shape" = "Muoto" "Shape builder" = "Muodonrakennin" "Shape tools" = "Muototyökalut" +"Shapes" = "Muodot" "Share" = "Jaa" "Share this file" = "Jaa tämä tiedosto" "Share with a code" = "Jaa koodilla" @@ -1289,10 +1304,13 @@ status = "draft — needs native review" "Space+drag" = "Space+drag" "Spacing" = "Välistys" "Spam, stolen work, abuse…" = "Roskaposti, varastettu työ, väärinkäyttö…" +"Speech bubble" = "Puhekupla" "Splay" = "Harotus" "Spotlight me" = "Nosta minut esiin" "Spread" = "Leviäminen" "Square" = "Neliö" +"Stamp" = "Leima" +"Stamps" = "Leimat" "Standard" = "Vakio" "Standard ligatures" = "Tavalliset ligatuurit" "Star" = "Tähti" @@ -1309,12 +1327,16 @@ status = "draft — needs native review" "Starting to share…" = "Aloitetaan jakamista…" "Status" = "Tila" "Sticky" = "Kiinnittyvä" +"Sticky note" = "Muistilappu" +"Sticky note colors" = "Muistilappujen värit" +"Sticky notes, shapes and connectors" = "Muistilaput, muodot ja yhdistimet" "Stop" = "Pysäytä" "Stop following {name}" = "Lopeta käyttäjän {name} seuraaminen" "Stop session" = "Lopeta istunto" "Stop sharing" = "Lopeta jakaminen" "Stop spotlighting" = "Lopeta esiin nostaminen" "Stop {n} {hex}" = "Piste {n} {hex}" +"Straight" = "Suora" "Stretch" = "Venytys" "Strikethrough" = "Yliviivaus" "String" = "Merkkijono" @@ -1359,6 +1381,7 @@ status = "draft — needs native review" "Text" = "Teksti" "Text align" = "Tekstin tasaus" "Text content" = "Tekstisisältö" +"Text size" = "Tekstin koko" "Text style" = "Tekstityyli" "Text styles" = "Tekstityylit" "Texts" = "Tekstit" @@ -1393,6 +1416,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Tämä palvelin on julkinen: avainta ei tarvita." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tämä palvelin tarvitsee avaimen. Lisää osoitteeseen ?key=… (`figcraft-server key` tulostaa avaimen palvelimen koneella)." "This server takes files only with its key: see Advanced." = "Tämä palvelin ottaa tiedostoja vastaan vain avaimellaan. Katso Lisäasetukset." +"Thumbs up" = "Peukku ylös" "Tidy up" = "Järjestä tasavälein" "Tile" = "Laatoitus" "Tile type" = "Laatan tyyppi" @@ -1409,6 +1433,7 @@ status = "draft — needs native review" "Top left radius" = "Vasemman yläkulman säde" "Top right radius" = "Oikean yläkulman säde" "Transform" = "Muunna" +"Triangle" = "Kolmio" "Triangle arrow" = "Kolmionuoli" "Trigger" = "Laukaisin" "True" = "Tosi" @@ -1771,6 +1796,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Pyöristä pikseleihin" "arrange.spacing" = "Välistys" "arrange.tidy" = "Järjestä tasavälein" +"board.connector" = "Yhdistin" +"board.ink" = "Tussiviiva" +"board.label" = "Yhdistimen nimiö" +"board.nextSticky" = "Seuraava muistilappu" +"board.setColor" = "Taulun väri" +"board.setStyle" = "Taulun tyyli" +"board.shape" = "Muoto ja teksti" +"board.stamp" = "Leima" +"board.sticky" = "Muistilappu" "bool.exclude" = "Sulje valinnan päällekkäinen alue pois" "bool.flatten" = "Yhdistä Boolen muoto vektoriksi" "bool.intersect" = "Leikkaa valinnan yhteinen alue" @@ -1897,6 +1931,7 @@ status = "draft — needs native review" "file.import" = "Tuo" "file.moveTab" = "Siirrä välilehti" "file.new" = "Uusi suunnittelutiedosto" +"file.newBoard" = "Uusi taulu" "file.newFromTemplate" = "Uusi mallista" "file.open" = "Avaa" "file.openRecent" = "Avaa viimeksi avattu" @@ -2171,10 +2206,14 @@ status = "draft — needs native review" "text.weightUp" = "Lisää fontin paksuutta" "tool.arrow" = "Nuoli" "tool.comment" = "Kommentti" +"tool.connector" = "Yhdistin" "tool.ellipse" = "Ellipsi" +"tool.eraser" = "Pyyhekumi" "tool.frame" = "Kehys" "tool.hand" = "Käsi" +"tool.highlighter" = "Korostuskynä" "tool.line" = "Viiva" +"tool.marker" = "Tussi" "tool.move" = "Siirrä" "tool.pen" = "Kynä" "tool.pencil" = "Lyijykynä" @@ -2186,9 +2225,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Skaalauksen ankkuri" "tool.section" = "Osio" "tool.set" = "Valitse työkalu" +"tool.shape" = "Muodot" "tool.shapeBuilder" = "Muodonrakennin" "tool.slice" = "Viipale" +"tool.stamp" = "Leima" "tool.star" = "Tähti" +"tool.sticky" = "Muistilappu" "tool.text" = "Teksti" "transform.move" = "Siirrä" "transform.moveTo" = "Siirrä kohteeseen" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 0eb2a5f1..5616dca1 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -125,6 +125,7 @@ status = "draft — needs native review" "Archived" = "Archivée" "Arrange" = "Disposer" "Arrow" = "Flèche" +"Arrow at the end" = "Flèche à la fin" "As typed" = "Tel que saisi" "Asking the server…" = "Interrogation du serveur…" "Assets" = "Ressources" @@ -198,7 +199,9 @@ status = "draft — needs native review" "C or ⇧C" = "C ou ⇧C" "CMYK colour" = "Couleur CMJN" "Can view" = "Peut consulter" +"Check" = "Coche" "Checking the server…" = "Vérification auprès du serveur…" +"Chevron" = "Chevron" "Clear from this list" = "Effacer de cette liste" "Code or invite link" = "Code ou lien d’invitation" "Color profile: {name}" = "Profil colorimétrique : {name}" @@ -288,6 +291,7 @@ status = "draft — needs native review" "Connected" = "Connecté" "Connected to code: {from}" = "Relié au code : {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Connexion perdue, reconnexion (tentative {attempt})…" +"Connector" = "Connecteur" "Constrain proportions" = "Conserver les proportions" "Constraints" = "Contraintes" "Content" = "Contenu" @@ -378,6 +382,7 @@ status = "draft — needs native review" "Description" = "Description" "Description (optional)" = "Description (facultative)" "Design" = "Design" +"Design panel" = "Panneau Design" "Design system starter" = "Base de système de design" "Desktop" = "Ordinateur" "Destination" = "Destination" @@ -441,6 +446,7 @@ status = "draft — needs native review" "Effect settings" = "Réglages de l’effet" "Effect styles" = "Styles d’effet" "Effects" = "Effets" +"Elbow" = "Coudé" "Ellipse" = "Ellipse" "Ellipses" = "Ellipses" "Else" = "Sinon" @@ -577,6 +583,7 @@ status = "draft — needs native review" "Hanging list" = "Liste en retrait" "Hanging punctuation" = "Ponctuation en débord" "Hard light" = "Lumière dure" +"Heart" = "Cœur" "Height" = "Hauteur" "Help" = "Aide" "Help and resources" = "Aide et ressources" @@ -589,6 +596,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)" "Hide when publishing" = "Masquer à la publication" "Highlight layers on hover" = "Mettre en évidence les calques au survol" +"Highlighter" = "Surligneur" "Highlights" = "Hautes lumières" "Hold Alt" = "Maintenir Alt" "Hold Alt+Z" = "Maintenir Alt+Z" @@ -756,6 +764,7 @@ status = "draft — needs native review" "Mark as resolved" = "Marquer comme résolu" "Mark as unread" = "Marquer comme non lu" "Mark as unresolved" = "Marquer comme non résolu" +"Marker" = "Marqueur" "Mask" = "Masque" "Mass · Stiffness · Damping" = "Masse · Rigidité · Amortissement" "Match (empty: the whole name)" = "Correspondance (vide : le nom entier)" @@ -814,6 +823,7 @@ status = "draft — needs native review" "Named instance" = "Instance nommée" "Navigate to" = "Naviguer vers" "Nested instance" = "Instance imbriquée" +"New board" = "Nouveau tableau blanc" "New design file" = "Nouveau fichier de design" "Newest first" = "Plus récent d’abord" "Next frame" = "Cadre suivant" @@ -956,6 +966,7 @@ status = "draft — needs native review" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" +"Pink" = "Rose" "Placed on the current page" = "Placé sur la page actuelle" "Pages: {pages}" = "Pages : {pages}" "Paint bucket" = "Pot de peinture" @@ -1000,6 +1011,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Les plugins s’exécutent dans le bac à sable de FigCraft, sans fichiers, réseau ni horloge. N’installez que des plugins de confiance." "Plus darker" = "Plus foncé" "Plus lighter" = "Plus clair" +"Plus one" = "Plus un" "Point" = "Point" "Points" = "Points" "Polygon" = "Polygone" @@ -1047,6 +1059,7 @@ status = "draft — needs native review" "Push" = "Pousser" "Push changes to main component" = "Transmettre les modifications au composant principal" "Put text on path" = "Placer le texte sur un tracé" +"Question" = "Question" "R" = "R" "Radial" = "Radial" "Radius" = "Rayon" @@ -1149,6 +1162,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variation de rotation" "Round" = "Arrondi" "Rounded" = "Arrondi" +"Rounded rectangle" = "Rectangle arrondi" "Row" = "Ligne" "Row gap" = "Espacement des lignes" "Row sizes" = "Tailles des lignes" @@ -1222,6 +1236,7 @@ status = "draft — needs native review" "Shape" = "Forme" "Shape builder" = "Générateur de formes" "Shape tools" = "Outils de forme" +"Shapes" = "Formes" "Share" = "Partager" "Share this file" = "Partager ce fichier" "Share with a code" = "Partager avec un code" @@ -1288,10 +1303,13 @@ status = "draft — needs native review" "Space+drag" = "Espace+glisser" "Spacing" = "Espacement" "Spam, stolen work, abuse…" = "Spam, travail volé, abus…" +"Speech bubble" = "Bulle" "Splay" = "Évasement" "Spotlight me" = "Me mettre sous les projecteurs" "Spread" = "Étendue" "Square" = "Carré" +"Stamp" = "Tampon" +"Stamps" = "Tampons" "Standard" = "Standard" "Standard ligatures" = "Ligatures standard" "Star" = "Étoile" @@ -1308,12 +1326,16 @@ status = "draft — needs native review" "Starting to share…" = "Démarrage du partage…" "Status" = "Statut" "Sticky" = "Collant" +"Sticky note" = "Note autocollante" +"Sticky note colors" = "Couleurs des notes autocollantes" +"Sticky notes, shapes and connectors" = "Notes autocollantes, formes et connecteurs" "Stop" = "Arrêter" "Stop following {name}" = "Ne plus suivre {name}" "Stop session" = "Arrêter la session" "Stop sharing" = "Arrêter le partage" "Stop spotlighting" = "Quitter les projecteurs" "Stop {n} {hex}" = "Arrêt {n} {hex}" +"Straight" = "Droit" "Stretch" = "Étirer" "Strikethrough" = "Barré" "String" = "Chaîne" @@ -1358,6 +1380,7 @@ status = "draft — needs native review" "Text" = "Texte" "Text align" = "Alignement du texte" "Text content" = "Contenu du texte" +"Text size" = "Taille du texte" "Text style" = "Style de texte" "Text styles" = "Styles de texte" "Texts" = "Textes" @@ -1392,6 +1415,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)." "This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé." +"Thumbs up" = "Pouce levé" "This version can't be read." = "Cette version est illisible." "This version doesn't have it: all of it is new." = "Cette version ne le contient pas : tout est nouveau." "Tidy up" = "Ranger" @@ -1410,6 +1434,7 @@ status = "draft — needs native review" "Top left radius" = "Rayon supérieur gauche" "Top right radius" = "Rayon supérieur droit" "Transform" = "Transformer" +"Triangle" = "Triangle" "Triangle arrow" = "Flèche triangulaire" "Trigger" = "Déclencheur" "True" = "Vrai" @@ -1772,6 +1797,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arrondir aux pixels" "arrange.spacing" = "Espacement" "arrange.tidy" = "Ranger" +"board.connector" = "Connecteur" +"board.ink" = "Trait de marqueur" +"board.label" = "Libellé du connecteur" +"board.nextSticky" = "Note autocollante suivante" +"board.setColor" = "Couleur du tableau" +"board.setStyle" = "Style du tableau" +"board.shape" = "Forme avec texte" +"board.stamp" = "Tampon" +"board.sticky" = "Note autocollante" "bool.exclude" = "Exclure la sélection" "bool.flatten" = "Aplatir le booléen" "bool.intersect" = "Intersection de la sélection" @@ -1898,6 +1932,7 @@ status = "draft — needs native review" "file.import" = "Importer" "file.moveTab" = "Déplacer l’onglet" "file.new" = "Nouveau fichier de design" +"file.newBoard" = "Nouveau tableau blanc" "file.newFromTemplate" = "Nouveau à partir d’un modèle" "file.open" = "Ouvrir" "file.openRecent" = "Ouvrir un fichier récent" @@ -2169,10 +2204,14 @@ status = "draft — needs native review" "text.weightUp" = "Augmenter la graisse" "tool.arrow" = "Flèche" "tool.comment" = "Commentaire" +"tool.connector" = "Connecteur" "tool.ellipse" = "Ellipse" +"tool.eraser" = "Gomme" "tool.frame" = "Cadre" "tool.hand" = "Main" +"tool.highlighter" = "Surligneur" "tool.line" = "Ligne" +"tool.marker" = "Marqueur" "tool.move" = "Déplacer" "tool.pen" = "Plume" "tool.pencil" = "Crayon" @@ -2184,9 +2223,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Ancre de mise à l’échelle" "tool.section" = "Section" "tool.set" = "Choisir un outil" +"tool.shape" = "Formes" "tool.shapeBuilder" = "Générateur de formes" "tool.slice" = "Tranche" +"tool.stamp" = "Tampon" "tool.star" = "Étoile" +"tool.sticky" = "Note autocollante" "tool.text" = "Texte" "transform.move" = "Déplacer" "transform.moveTo" = "Déplacer vers" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index f21e1ca3..bf5fe82e 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -124,6 +124,7 @@ status = "draft — needs native review" "Archived" = "Diarsipkan" "Arrange" = "Susun" "Arrow" = "Panah" +"Arrow at the end" = "Panah di ujung" "As typed" = "Sesuai ketikan" "Asking the server…" = "Menghubungi server…" "Assets" = "Aset" @@ -197,7 +198,9 @@ status = "draft — needs native review" "C or ⇧C" = "C atau ⇧C" "CMYK colour" = "Warna CMYK" "Can view" = "Dapat melihat" +"Check" = "Centang" "Checking the server…" = "Memeriksa server…" +"Chevron" = "Chevron" "Clear from this list" = "Hapus dari daftar ini" "Code or invite link" = "Kode atau link undangan" "Color profile: {name}" = "Profil warna: {name}" @@ -286,6 +289,7 @@ status = "draft — needs native review" "Connected" = "Terhubung" "Connected to code: {from}" = "Terhubung ke kode: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Koneksi terputus, menghubungkan ulang (percobaan {attempt})…" +"Connector" = "Konektor" "Constrain proportions" = "Kunci proporsi" "Constraints" = "Batasan" "Content" = "Konten" @@ -372,6 +376,7 @@ status = "draft — needs native review" "Description" = "Deskripsi" "Description (optional)" = "Deskripsi (opsional)" "Design" = "Desain" +"Design panel" = "Panel desain" "Design system starter" = "Dasar sistem desain" "Desktop" = "Desktop" "Destination" = "Tujuan" @@ -431,6 +436,7 @@ status = "draft — needs native review" "Effect settings" = "Pengaturan efek" "Effect styles" = "Gaya efek" "Effects" = "Efek" +"Elbow" = "Siku" "Ellipse" = "Elips" "Ellipses" = "Elips" "Else" = "Jika tidak" @@ -566,6 +572,7 @@ status = "draft — needs native review" "Hanging list" = "Daftar menggantung" "Hanging punctuation" = "Tanda baca menggantung" "Hard light" = "Hard light" +"Heart" = "Hati" "Height" = "Tinggi" "Help" = "Bantuan" "Help and resources" = "Bantuan dan sumber daya" @@ -578,6 +585,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Sembunyikan tips ini (tampilkan lagi lewat Memulai di menu Bantuan)" "Hide when publishing" = "Sembunyikan saat dipublikasikan" "Highlight layers on hover" = "Sorot layer saat hover" +"Highlighter" = "Stabilo" "Highlights" = "Sorotan" "Hold Alt" = "Tahan Alt" "Hold Alt+Z" = "Tahan Alt+Z" @@ -743,6 +751,7 @@ status = "draft — needs native review" "Mark as resolved" = "Tandai selesai" "Mark as unread" = "Tandai belum dibaca" "Mark as unresolved" = "Tandai belum selesai" +"Marker" = "Spidol" "Mask" = "Mask" "Mass · Stiffness · Damping" = "Massa · Kekakuan · Redaman" "Match (empty: the whole name)" = "Cocokkan (kosong: seluruh nama)" @@ -801,6 +810,7 @@ status = "draft — needs native review" "Named instance" = "Instance bernama" "Navigate to" = "Navigasi ke" "Nested instance" = "Instance bersarang" +"New board" = "Papan baru" "New design file" = "File desain baru" "Newest first" = "Terbaru dulu" "Next frame" = "Frame berikutnya" @@ -937,6 +947,7 @@ status = "draft — needs native review" "Page deleted" = "Halaman dihapus" "Pages" = "Halaman" "Pages added to the document" = "Halaman ditambahkan ke dokumen" +"Pink" = "Merah muda" "Placed on the current page" = "Ditempatkan di halaman saat ini" "Pages: {pages}" = "Halaman: {pages}" "Paint bucket" = "Ember cat" @@ -979,6 +990,7 @@ status = "draft — needs native review" "Plugins" = "Plugin" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" +"Plus one" = "Plus satu" "Point" = "Titik" "Points" = "Titik" "Polygon" = "Poligon" @@ -1027,6 +1039,7 @@ status = "draft — needs native review" "Push" = "Dorong" "Push changes to main component" = "Terapkan perubahan ke main component" "Put text on path" = "Tempatkan teks di path" +"Question" = "Pertanyaan" "R" = "R" "Radial" = "Radial" "Radius" = "Radius" @@ -1126,6 +1139,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variasi rotasi" "Round" = "Bulat" "Rounded" = "Membulat" +"Rounded rectangle" = "Persegi panjang membulat" "Row" = "Baris" "Row gap" = "Jarak baris" "Row sizes" = "Ukuran baris" @@ -1198,6 +1212,7 @@ status = "draft — needs native review" "Shape" = "Bentuk" "Shape builder" = "Shape builder" "Shape tools" = "Alat bentuk" +"Shapes" = "Bentuk" "Share" = "Bagikan" "Share this file" = "Bagikan file ini" "Share with a code" = "Bagikan dengan kode" @@ -1264,10 +1279,13 @@ status = "draft — needs native review" "Space+drag" = "Space+seret" "Spacing" = "Spasi" "Spam, stolen work, abuse…" = "Spam, karya curian, penyalahgunaan…" +"Speech bubble" = "Balon kata" "Splay" = "Bukaan" "Spotlight me" = "Sorot saya" "Spread" = "Sebaran" "Square" = "Persegi" +"Stamp" = "Stempel" +"Stamps" = "Stempel" "Standard" = "Standar" "Standard ligatures" = "Ligatur standar" "Star" = "Bintang" @@ -1283,12 +1301,16 @@ status = "draft — needs native review" "Starting to share…" = "Memulai berbagi…" "Status" = "Status" "Sticky" = "Menempel" +"Sticky note" = "Catatan tempel" +"Sticky note colors" = "Warna catatan tempel" +"Sticky notes, shapes and connectors" = "Catatan tempel, bentuk, dan konektor" "Stop" = "Hentikan" "Stop following {name}" = "Berhenti mengikuti {name}" "Stop session" = "Hentikan sesi" "Stop sharing" = "Berhenti berbagi" "Stop spotlighting" = "Hentikan sorotan" "Stop {n} {hex}" = "Titik {n} {hex}" +"Straight" = "Lurus" "Stretch" = "Regangkan" "Strikethrough" = "Coret" "String" = "String" @@ -1333,6 +1355,7 @@ status = "draft — needs native review" "Text" = "Teks" "Text align" = "Perataan teks" "Text content" = "Konten teks" +"Text size" = "Ukuran teks" "Text style" = "Gaya teks" "Text styles" = "Gaya teks" "Texts" = "Teks" @@ -1363,6 +1386,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Server ini terbuka untuk publik: tidak perlu kunci." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Server ini memerlukan kuncinya: tambahkan ?key=… ke alamat (`figcraft-server key` menampilkannya di mesin server)." "This server takes files only with its key: see Advanced." = "Server ini hanya menerima file dengan kuncinya: lihat Lanjutan." +"Thumbs up" = "Jempol" "Tidy up" = "Rapikan" "Tile" = "Ubin" "Tile type" = "Jenis ubin" @@ -1379,6 +1403,7 @@ status = "draft — needs native review" "Top left radius" = "Radius kiri atas" "Top right radius" = "Radius kanan atas" "Transform" = "Transformasi" +"Triangle" = "Segitiga" "Triangle arrow" = "Panah segitiga" "Trigger" = "Pemicu" "True" = "Benar" @@ -1733,6 +1758,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Bulatkan ke piksel" "arrange.spacing" = "Spasi" "arrange.tidy" = "Rapikan" +"board.connector" = "Konektor" +"board.ink" = "Goresan spidol" +"board.label" = "Label konektor" +"board.nextSticky" = "Catatan tempel berikutnya" +"board.setColor" = "Warna papan" +"board.setStyle" = "Gaya papan" +"board.shape" = "Bentuk dengan teks" +"board.stamp" = "Stempel" +"board.sticky" = "Catatan tempel" "bool.exclude" = "Kecualikan pilihan" "bool.flatten" = "Satukan boolean" "bool.intersect" = "Iriskan pilihan" @@ -1858,6 +1892,7 @@ status = "draft — needs native review" "file.import" = "Impor" "file.moveTab" = "Pindahkan tab" "file.new" = "File desain baru" +"file.newBoard" = "Papan baru" "file.newFromTemplate" = "Baru dari templat" "file.open" = "Buka" "file.openRecent" = "Buka terbaru" @@ -2124,10 +2159,14 @@ status = "draft — needs native review" "text.weightUp" = "Tambah ketebalan font" "tool.arrow" = "Panah" "tool.comment" = "Komentar" +"tool.connector" = "Konektor" "tool.ellipse" = "Elips" +"tool.eraser" = "Penghapus" "tool.frame" = "Frame" "tool.hand" = "Tangan" +"tool.highlighter" = "Stabilo" "tool.line" = "Garis" +"tool.marker" = "Spidol" "tool.move" = "Pindahkan" "tool.pen" = "Pena" "tool.pencil" = "Pensil" @@ -2139,9 +2178,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Titik acuan skala" "tool.section" = "Section" "tool.set" = "Pilih alat" +"tool.shape" = "Bentuk" "tool.shapeBuilder" = "Shape builder" "tool.slice" = "Slice" +"tool.stamp" = "Stempel" "tool.star" = "Bintang" +"tool.sticky" = "Catatan tempel" "tool.text" = "Teks" "transform.move" = "Pindahkan" "transform.moveTo" = "Pindahkan ke" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index e5ec0a0b..a6ffa01f 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -126,6 +126,7 @@ status = "draft — needs native review" "Archived" = "アーカイブ済み" "Arrange" = "配置" "Arrow" = "矢印" +"Arrow at the end" = "末端に矢印" "As typed" = "入力どおり" "Asking the server…" = "サーバーに問い合わせ中…" "Assets" = "アセット" @@ -199,7 +200,9 @@ status = "draft — needs native review" "C or ⇧C" = "C または ⇧C" "CMYK colour" = "CMYK カラー" "Can view" = "閲覧可" +"Check" = "チェック" "Checking the server…" = "サーバーを確認中…" +"Chevron" = "シェブロン" "Clear from this list" = "このリストから消去" "Code or invite link" = "コードまたは招待リンク" "Color profile: {name}" = "カラープロファイル:{name}" @@ -289,6 +292,7 @@ status = "draft — needs native review" "Connected" = "接続済み" "Connected to code: {from}" = "コードに接続済み:{from}" "Connection lost, reconnecting (attempt {attempt})…" = "接続が切断。再接続中({attempt} 回目)…" +"Connector" = "コネクタ" "Constrain proportions" = "縦横比を固定" "Constraints" = "制約" "Content" = "コンテンツ" @@ -376,6 +380,7 @@ status = "draft — needs native review" "Description" = "説明" "Description (optional)" = "説明(任意)" "Design" = "デザイン" +"Design panel" = "デザインパネル" "Design system starter" = "デザインシステムの雛形" "Desktop" = "デスクトップ" "Destination" = "遷移先" @@ -436,6 +441,7 @@ status = "draft — needs native review" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" +"Elbow" = "直角" "Ellipse" = "楕円" "Ellipses" = "楕円" "Else" = "それ以外" @@ -572,6 +578,7 @@ status = "draft — needs native review" "Hanging list" = "ぶら下げリスト" "Hanging punctuation" = "ぶら下げ句読点" "Hard light" = "ハードライト" +"Heart" = "ハート" "Height" = "高さ" "Help" = "ヘルプ" "Help and resources" = "ヘルプとリソース" @@ -584,6 +591,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "ヒントを非表示(ヘルプメニューの「使い始めるには」で再表示)" "Hide when publishing" = "公開時に非表示" "Highlight layers on hover" = "ホバー時にレイヤーをハイライト" +"Highlighter" = "蛍光ペン" "Highlights" = "ハイライト" "Hold Alt" = "Alt を押したまま" "Hold Alt+Z" = "Alt+Z を押したまま" @@ -750,6 +758,7 @@ status = "draft — needs native review" "Mark as resolved" = "解決済みにする" "Mark as unread" = "未読にする" "Mark as unresolved" = "未解決にする" +"Marker" = "マーカー" "Mask" = "マスク" "Mass · Stiffness · Damping" = "質量 · 剛性 · 減衰" "Match (empty: the whole name)" = "一致(空欄:名前全体)" @@ -808,6 +817,7 @@ status = "draft — needs native review" "Named instance" = "名前付きインスタンス" "Navigate to" = "移動先" "Nested instance" = "ネストされたインスタンス" +"New board" = "新規ボード" "New design file" = "新規デザインファイル" "Newest first" = "新しい順" "Next frame" = "次のフレーム" @@ -945,6 +955,7 @@ status = "draft — needs native review" "Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" +"Pink" = "ピンク" "Placed on the current page" = "現在のページに配置した" "Pages: {pages}" = "ページ:{pages}" "Paint bucket" = "塗りつぶし" @@ -988,6 +999,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "プラグインは FigCraft のサンドボックス内で、ファイル、ネットワーク、時計なしで実行される。信頼できるプラグインのみをインストールすること。" "Plus darker" = "プラス(暗)" "Plus lighter" = "プラス(明)" +"Plus one" = "プラスワン" "Point" = "ポイント" "Points" = "ポイント" "Polygon" = "多角形" @@ -1035,6 +1047,7 @@ status = "draft — needs native review" "Push" = "プッシュ" "Push changes to main component" = "変更をメインコンポーネントにプッシュ" "Put text on path" = "テキストをパスに沿わせる" +"Question" = "質問" "R" = "R" "Radial" = "放射状" "Radius" = "半径" @@ -1136,6 +1149,7 @@ status = "draft — needs native review" "Rotation jitter" = "回転のばらつき" "Round" = "丸型" "Rounded" = "丸型" +"Rounded rectangle" = "角丸長方形" "Row" = "行" "Row gap" = "行の間隔" "Row sizes" = "行のサイズ" @@ -1209,6 +1223,7 @@ status = "draft — needs native review" "Shape" = "シェイプ" "Shape builder" = "シェイプビルダー" "Shape tools" = "シェイプツール" +"Shapes" = "シェイプ" "Share" = "共有" "Share this file" = "このファイルを共有" "Share with a code" = "コードで共有" @@ -1275,10 +1290,13 @@ status = "draft — needs native review" "Space+drag" = "Space+ドラッグ" "Spacing" = "間隔" "Spam, stolen work, abuse…" = "スパム、盗用、迷惑行為…" +"Speech bubble" = "吹き出し" "Splay" = "広がり" "Spotlight me" = "自分をスポットライト" "Spread" = "スプレッド" "Square" = "正方形" +"Stamp" = "スタンプ" +"Stamps" = "スタンプ" "Standard" = "標準" "Standard ligatures" = "標準合字" "Star" = "星" @@ -1295,12 +1313,16 @@ status = "draft — needs native review" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" +"Sticky note" = "付箋" +"Sticky note colors" = "付箋の色" +"Sticky notes, shapes and connectors" = "付箋、シェイプ、コネクタ" "Stop" = "停止" "Stop following {name}" = "{name} のフォローを停止" "Stop session" = "セッションを停止" "Stop sharing" = "共有を停止" "Stop spotlighting" = "スポットライトを停止" "Stop {n} {hex}" = "分岐点 {n} {hex}" +"Straight" = "直線" "Stretch" = "伸縮" "Strikethrough" = "取り消し線" "String" = "文字列" @@ -1345,6 +1367,7 @@ status = "draft — needs native review" "Text" = "テキスト" "Text align" = "テキストの配置" "Text content" = "テキストの内容" +"Text size" = "テキストサイズ" "Text style" = "テキストスタイル" "Text styles" = "テキストスタイル" "Texts" = "テキスト" @@ -1375,6 +1398,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "このサーバーは一般公開されている:キーは不要。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "このサーバーにはキーが必要:アドレスに ?key=… を追加(サーバーのマシンで `figcraft-server key` を実行すると表示)。" "This server takes files only with its key: see Advanced." = "このサーバーはキーがないとファイルを受け付けない:「詳細設定」を参照。" +"Thumbs up" = "いいね" "This version can't be read." = "このバージョンを読み込めません。" "This version doesn't have it: all of it is new." = "このバージョンにはありません。すべて新規です。" "Tidy up" = "整列" @@ -1393,6 +1417,7 @@ status = "draft — needs native review" "Top left radius" = "左上の半径" "Top right radius" = "右上の半径" "Transform" = "変形" +"Triangle" = "三角形" "Triangle arrow" = "三角矢印" "Trigger" = "トリガー" "True" = "true" @@ -1752,6 +1777,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "ピクセルに丸める" "arrange.spacing" = "間隔" "arrange.tidy" = "整列" +"board.connector" = "コネクタ" +"board.ink" = "マーカーの線" +"board.label" = "コネクタのラベル" +"board.nextSticky" = "次の付箋" +"board.setColor" = "ボードの色" +"board.setStyle" = "ボードのスタイル" +"board.shape" = "テキスト付きシェイプ" +"board.stamp" = "スタンプ" +"board.sticky" = "付箋" "bool.exclude" = "選択範囲を除外" "bool.flatten" = "ブーリアンを平坦化" "bool.intersect" = "選択範囲を交差" @@ -1878,6 +1912,7 @@ status = "draft — needs native review" "file.import" = "インポート" "file.moveTab" = "タブを移動" "file.new" = "新規デザインファイル" +"file.newBoard" = "新規ボード" "file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" "file.openRecent" = "最近使ったファイルを開く" @@ -2147,10 +2182,14 @@ status = "draft — needs native review" "text.weightUp" = "フォントの太さを太く" "tool.arrow" = "矢印" "tool.comment" = "コメント" +"tool.connector" = "コネクタ" "tool.ellipse" = "楕円" +"tool.eraser" = "消しゴム" "tool.frame" = "フレーム" "tool.hand" = "手のひら" +"tool.highlighter" = "蛍光ペン" "tool.line" = "線" +"tool.marker" = "マーカー" "tool.move" = "移動" "tool.pen" = "ペン" "tool.pencil" = "鉛筆" @@ -2162,9 +2201,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "拡大縮小の基準点" "tool.section" = "セクション" "tool.set" = "ツールを選択" +"tool.shape" = "シェイプ" "tool.shapeBuilder" = "シェイプビルダー" "tool.slice" = "スライス" +"tool.stamp" = "スタンプ" "tool.star" = "星" +"tool.sticky" = "付箋" "tool.text" = "テキスト" "transform.move" = "移動" "transform.moveTo" = "指定位置へ移動" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 5b73b4fb..ffbc8444 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -128,6 +128,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Archived" = "დაარქივებულია" "Arrange" = "დალაგება" "Arrow" = "ისარი" +"Arrow at the end" = "ისარი ბოლოში" "As typed" = "როგორც აიკრიფა" "Asking the server…" = "სერვერთან მოთხოვნა…" "Assets" = "რესურსები" @@ -201,12 +202,15 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "C or ⇧C" = "C ან ⇧C" "CMYK colour" = "CMYK ფერი" "Can view" = "შეუძლია ნახვა" +"Check" = "ნიშნული" "Checking the fonts installed on this computer…" = "ამ კომპიუტერზე დაინსტალირებული შრიფტების შემოწმება…" "Checking the server…" = "სერვერის შემოწმება…" +"Chevron" = "შევრონი" "Clear from this list" = "ამ სიიდან ამოღება" "Code or invite link" = "კოდი ან მოსაწვევი ბმული" "Color profile: {name}" = "ფერის პროფილი: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები" +"Connector" = "შემაერთებელი" "Convert to frame" = "ჩარჩოდ გარდაქმნა" "Convert to section" = "სექციად გარდაქმნა" "CSS custom properties…" = "CSS-ის მორგებული თვისებები…" @@ -380,6 +384,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Description" = "აღწერა" "Description (optional)" = "აღწერა (არასავალდებულო)" "Design" = "დიზაინი" +"Design panel" = "დიზაინის პანელი" "Design system starter" = "დიზაინ-სისტემის საწყისი ნაკრები" "Desktop" = "დესკტოპი" "Destination" = "დანიშნულება" @@ -442,6 +447,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Effect settings" = "ეფექტის პარამეტრები" "Effect styles" = "ეფექტის სტილები" "Effects" = "ეფექტები" +"Elbow" = "კუთხოვანი" "Ellipse" = "ელიფსი" "Ellipses" = "ელიფსები" "Else" = "სხვა შემთხვევაში" @@ -578,6 +584,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Hanging list" = "გამოწეული სია" "Hanging punctuation" = "გამოწეული პუნქტუაცია" "Hard light" = "მკვეთრი სინათლე" +"Heart" = "გული" "Height" = "სიმაღლე" "Help" = "დახმარება" "Help and resources" = "დახმარება და რესურსები" @@ -590,6 +597,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Hide these tips (Getting started in the Help menu shows them again)" = "ამ რჩევების დამალვა („პირველი ნაბიჯები“ დახმარების მენიუში ისევ აჩვენებს მათ)" "Hide when publishing" = "გამოქვეყნებისას დამალვა" "Highlight layers on hover" = "შრეების გამოკვეთა მაჩვენებლის მიტანისას" +"Highlighter" = "ხაზგასმის მარკერი" "Highlights" = "ღია ტონები" "Hold Alt" = "Alt-ის დაჭერა" "Hold Alt+Z" = "Alt+Z-ის დაჭერა" @@ -756,6 +764,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Mark as resolved" = "გადაწყვეტილად მარკირება" "Mark as unread" = "წაუკითხავად მარკირება" "Mark as unresolved" = "გადაუწყვეტლად მარკირება" +"Marker" = "მარკერი" "Mask" = "ნიღაბი" "Mass · Stiffness · Damping" = "მასა · სიხისტე · მილევა" "Match (empty: the whole name)" = "დამთხვევა (ცარიელი: მთელი სახელი)" @@ -815,6 +824,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Named instance" = "დასახელებული ინსტანსი" "Navigate to" = "გადასვლა" "Nested instance" = "ჩადგმული ინსტანსი" +"New board" = "ახალი დაფა" "New design file" = "ახალი დიზაინის ფაილი" "Newest first" = "ჯერ უახლესი" "Next or previous control in the focused region" = "ფოკუსირებულ არეში შემდეგი ან წინა მართვის ელემენტი" @@ -955,6 +965,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Page" = "გვერდი" "Pages" = "გვერდები" "Pages added to the document" = "გვერდები დაემატა დოკუმენტს" +"Pink" = "ვარდისფერი" "Placed on the current page" = "განთავსდა მიმდინარე გვერდზე" "Pages: {pages}" = "გვერდი: {pages}" "Paint bucket" = "საღებავის ვედრო" @@ -999,6 +1010,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Plugins & widgets" = "პლაგინები და ვიჯეტები" "Plus darker" = "პლუს მუქი" "Plus lighter" = "პლუს ღია" +"Plus one" = "პლუს ერთი" "Point" = "წერტილი" "Points" = "წერტილები" "Polygon" = "მრავალკუთხედი" @@ -1047,6 +1059,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Push" = "ბიძგი" "Push changes to main component" = "ცვლილებების მთავარ კომპონენტში გადაგზავნა" "Put text on path" = "ტექსტის ტრაექტორიაზე განთავსება" +"Question" = "კითხვა" "R" = "R" "Radial" = "რადიალური" "Radius" = "რადიუსი" @@ -1147,6 +1160,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Rotation jitter" = "შემობრუნების ცვალებადობა" "Round" = "მომრგვალებული" "Rounded" = "მომრგვალებული" +"Rounded rectangle" = "მომრგვალებული მართკუთხედი" "Row" = "სტრიქონი" "Row gap" = "სტრიქონებს შორის შუალედი" "Row sizes" = "სტრიქონების ზომები" @@ -1222,6 +1236,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Shape" = "ფიგურა" "Shape builder" = "ფიგურების კონსტრუქტორი" "Shape tools" = "ფიგურის ხელსაწყოები" +"Shapes" = "ფიგურები" "Share" = "გაზიარება" "Share this file" = "ამ ფაილის გაზიარება" "Share with a code" = "კოდით გაზიარება" @@ -1288,10 +1303,13 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Space+drag" = "Space+გადათრევა" "Spacing" = "ინტერვალი" "Spam, stolen work, abuse…" = "სპამი, მოპარული ნამუშევარი, შეურაცხყოფა…" +"Speech bubble" = "სიტყვის ბუშტი" "Splay" = "გაშლილობა" "Spotlight me" = "ჩემი ხედის ჩვენება ყველასთვის" "Spread" = "გაფართოება" "Square" = "კვადრატი" +"Stamp" = "შტამპი" +"Stamps" = "შტამპები" "Standard" = "სტანდარტული" "Standard ligatures" = "სტანდარტული ლიგატურები" "Star" = "ვარსკვლავი" @@ -1307,12 +1325,16 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Starting to share…" = "გაზიარების დაწყება…" "Status" = "სტატუსი" "Sticky" = "მიმაგრებული" +"Sticky note" = "სტიკერი" +"Sticky note colors" = "სტიკერების ფერები" +"Sticky notes, shapes and connectors" = "სტიკერები, ფიგურები და შემაერთებლები" "Stop" = "შეწყვეტა" "Stop following {name}" = "{name}-ის მიყოლის შეწყვეტა" "Stop session" = "სესიის შეწყვეტა" "Stop sharing" = "გაზიარების შეწყვეტა" "Stop spotlighting" = "ჩემი ხედის ჩვენების შეწყვეტა" "Stop {n} {hex}" = "გადასვლის წერტილი {n} {hex}" +"Straight" = "სწორი" "Stretch" = "გაწელვა" "Strikethrough" = "გადახაზული" "String" = "ტექსტი" @@ -1357,6 +1379,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Text" = "ტექსტი" "Text align" = "ტექსტის სწორება" "Text content" = "ტექსტის შიგთავსი" +"Text size" = "ტექსტის ზომა" "Text style" = "ტექსტის სტილი" "Text styles" = "ტექსტის სტილები" "Texts" = "ტექსტები" @@ -1391,6 +1414,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "This server is open to the public: no key needed." = "ეს სერვერი საჯაროა: გასაღები საჭირო არ არის." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "ამ სერვერს გასაღები სჭირდება: მისამართს დაუმატეთ ?key=… (`figcraft-server key` გამოიტანს მას სერვერის კომპიუტერზე)." "This server takes files only with its key: see Advanced." = "ეს სერვერი ფაილებს მხოლოდ გასაღებით იღებს: იხილეთ „დამატებითი“." +"Thumbs up" = "ცერი ზემოთ" "Tidy up" = "მოწესრიგება" "Tile" = "ფილებად" "Tile type" = "ფილის ტიპი" @@ -1407,6 +1431,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Top left radius" = "ზედა მარცხენა რადიუსი" "Top right radius" = "ზედა მარჯვენა რადიუსი" "Transform" = "გარდაქმნა" +"Triangle" = "სამკუთხედი" "Triangle arrow" = "სამკუთხა ისარი" "Trigger" = "გამშვები" "True" = "ჭეშმარიტი" @@ -1768,6 +1793,15 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "arrange.roundToPixels" = "პიქსელებამდე დამრგვალება" "arrange.spacing" = "ინტერვალი" "arrange.tidy" = "მოწესრიგება" +"board.connector" = "შემაერთებელი" +"board.ink" = "მარკერის შტრიხი" +"board.label" = "შემაერთებლის წარწერა" +"board.nextSticky" = "შემდეგი სტიკერი" +"board.setColor" = "დაფის ფერი" +"board.setStyle" = "დაფის სტილი" +"board.shape" = "ფიგურა ტექსტით" +"board.stamp" = "შტამპი" +"board.sticky" = "სტიკერი" "bool.exclude" = "მონიშნულის გამორიცხვა" "bool.flatten" = "ლოგიკური ოპერაციის გაბრტყელება" "bool.intersect" = "მონიშნულის გადაკვეთა" @@ -1893,6 +1927,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "file.exportSvg" = "მონიშნულის SVG-ად ექსპორტირება" "file.import" = "იმპორტი" "file.new" = "ახალი დიზაინის ფაილი" +"file.newBoard" = "ახალი დაფა" "file.newFromTemplate" = "შაბლონიდან შექმნა" "file.open" = "გახსნა" "file.openRecent" = "ბოლოს გახსნილი ფაილების გახსნა" @@ -2163,10 +2198,14 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "text.weightUp" = "შრიფტის სისქის გაზრდა" "tool.arrow" = "ისარი" "tool.comment" = "კომენტარი" +"tool.connector" = "შემაერთებელი" "tool.ellipse" = "ელიფსი" +"tool.eraser" = "საშლელი" "tool.frame" = "ჩარჩო" "tool.hand" = "ხელი" +"tool.highlighter" = "ხაზგასმის მარკერი" "tool.line" = "ხაზი" +"tool.marker" = "მარკერი" "tool.move" = "გადაადგილება" "tool.pen" = "კალამი" "tool.pencil" = "ფანქარი" @@ -2178,9 +2217,12 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "tool.scaleAnchor" = "მასშტაბირების საყრდენი წერტილი" "tool.section" = "სექცია" "tool.set" = "ხელსაწყოს არჩევა" +"tool.shape" = "ფიგურები" "tool.shapeBuilder" = "ფიგურების კონსტრუქტორი" "tool.slice" = "ნაჭერი" +"tool.stamp" = "შტამპი" "tool.star" = "ვარსკვლავი" +"tool.sticky" = "სტიკერი" "tool.text" = "ტექსტი" "transform.move" = "გადაადგილება" "transform.moveTo" = "გადატანა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index f87e56d3..2192e29b 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -118,6 +118,7 @@ status = "draft — needs native review" "Archived" = "보관됨" "Arrange" = "배치" "Arrow" = "화살표" +"Arrow at the end" = "끝에 화살표" "As typed" = "입력한 대로" "Asking the server…" = "서버에 확인하는 중…" "Assets" = "에셋" @@ -189,7 +190,9 @@ status = "draft — needs native review" "C or ⇧C" = "C 또는 ⇧C" "CMYK colour" = "CMYK 색상" "Can view" = "보기 가능" +"Check" = "체크" "Checking the server…" = "서버 확인 중…" +"Chevron" = "셰브런" "Clear from this list" = "이 목록에서 지우기" "Code or invite link" = "코드 또는 초대 링크" "Color profile: {name}" = "색상 프로필: {name}" @@ -278,6 +281,7 @@ status = "draft — needs native review" "Connected" = "연결됨" "Connected to code: {from}" = "코드에 연결됨: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "연결이 끊어짐. 다시 연결하는 중({attempt}번째 시도)…" +"Connector" = "커넥터" "Constrain proportions" = "비율 고정" "Constraints" = "제약" "Content" = "콘텐츠" @@ -361,6 +365,7 @@ status = "draft — needs native review" "Description" = "설명" "Description (optional)" = "설명(선택 사항)" "Design" = "디자인" +"Design panel" = "디자인 패널" "Desktop" = "데스크톱" "Destination" = "대상" "Detach alias" = "별칭 분리" @@ -418,6 +423,7 @@ status = "draft — needs native review" "Effect settings" = "효과 설정" "Effect styles" = "효과 스타일" "Effects" = "효과" +"Elbow" = "꺾은선" "Ellipse" = "타원" "Ellipses" = "타원" "Else" = "그 외" @@ -548,6 +554,7 @@ status = "draft — needs native review" "Hanging list" = "내어쓰기 목록" "Hanging punctuation" = "문장 부호 내어쓰기" "Hard light" = "하드 라이트" +"Heart" = "하트" "Height" = "높이" "Help" = "도움말" "Help and resources" = "도움말 및 리소스" @@ -558,6 +565,7 @@ status = "draft — needs native review" "Hide from publishing" = "게시에서 숨기기" "Hide section contents" = "섹션 콘텐츠 숨기기" "Highlight layers on hover" = "마우스를 올리면 레이어 강조" +"Highlighter" = "형광펜" "Highlights" = "밝은 영역" "Hold Alt" = "Alt 누른 채로" "Hold Alt+Z" = "Alt+Z 누른 채로" @@ -713,6 +721,7 @@ status = "draft — needs native review" "Mark as resolved" = "해결됨으로 표시" "Mark as unread" = "읽지 않음으로 표시" "Mark as unresolved" = "미해결로 표시" +"Marker" = "마커" "Mask" = "마스크" "Mass · Stiffness · Damping" = "질량 · 강성 · 감쇠" "Match (empty: the whole name)" = "일치(비우면 이름 전체)" @@ -769,6 +778,7 @@ status = "draft — needs native review" "Named instance" = "명명된 인스턴스" "Navigate to" = "이동 대상" "Nested instance" = "중첩된 인스턴스" +"New board" = "새 보드" "New design file" = "새 디자인 파일" "Next frame" = "다음 프레임" "Next or previous control in the focused region" = "포커스된 영역의 다음 또는 이전 컨트롤" @@ -899,6 +909,7 @@ status = "draft — needs native review" "Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" +"Pink" = "분홍" "Placed on the current page" = "현재 페이지에 배치됨" "Pages: {pages}" = "페이지: {pages}" "Paint bucket" = "페인트 통" @@ -940,6 +951,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "플러그인은 FigCraft 샌드박스에서 파일, 네트워크, 시계 없이 실행됨. 신뢰할 수 있는 플러그인만 설치하세요." "Plus darker" = "더 어둡게" "Plus lighter" = "더 밝게" +"Plus one" = "플러스 원" "Point" = "점" "Points" = "점" "Polygon" = "다각형" @@ -986,6 +998,7 @@ status = "draft — needs native review" "Push" = "밀어내기" "Push changes to main component" = "변경 사항을 메인 컴포넌트에 푸시" "Put text on path" = "텍스트를 패스에 배치" +"Question" = "질문" "R" = "R" "Radial" = "방사형" "Radius" = "반경" @@ -1086,6 +1099,7 @@ status = "draft — needs native review" "Rotation jitter" = "회전 지터" "Round" = "둥글게" "Rounded" = "둥근" +"Rounded rectangle" = "둥근 사각형" "Row" = "행" "Row gap" = "행 간격" "Row sizes" = "행 크기" @@ -1155,6 +1169,7 @@ status = "draft — needs native review" "Shape" = "도형" "Shape builder" = "도형 빌더" "Shape tools" = "도형 도구" +"Shapes" = "도형" "Share" = "공유" "Share this file" = "이 파일 공유" "Share: live session, export" = "공유: 라이브 세션, 내보내기" @@ -1212,10 +1227,13 @@ status = "draft — needs native review" "Space+drag" = "Space+드래그" "Spacing" = "간격" "Spam, stolen work, abuse…" = "스팸, 도용, 악용…" +"Speech bubble" = "말풍선" "Splay" = "펼침" "Spotlight me" = "나를 스포트라이트" "Spread" = "확산" "Square" = "정사각형" +"Stamp" = "스탬프" +"Stamps" = "스탬프" "Standard" = "표준" "Standard ligatures" = "표준 합자" "Star" = "별" @@ -1230,12 +1248,16 @@ status = "draft — needs native review" "Starting to share…" = "공유를 시작하는 중…" "Status" = "상태" "Sticky" = "고정" +"Sticky note" = "스티커 메모" +"Sticky note colors" = "스티커 메모 색상" +"Sticky notes, shapes and connectors" = "스티커 메모, 도형, 커넥터" "Stop" = "중지" "Stop following {name}" = "{name} 따라가기 중지" "Stop session" = "세션 중지" "Stop sharing" = "공유 중지" "Stop spotlighting" = "스포트라이트 중지" "Stop {n} {hex}" = "정지점 {n} {hex}" +"Straight" = "직선" "Stretch" = "늘이기" "Strikethrough" = "취소선" "String" = "문자열" @@ -1279,6 +1301,7 @@ status = "draft — needs native review" "Text" = "텍스트" "Text align" = "텍스트 정렬" "Text content" = "텍스트 내용" +"Text size" = "텍스트 크기" "Text style" = "텍스트 스타일" "Text styles" = "텍스트 스타일" "Texts" = "텍스트" @@ -1309,6 +1332,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "이 서버는 누구에게나 열려 있음: 키가 필요 없음." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "이 서버는 키가 필요함: 주소에 ?key=…를 추가하세요(서버 컴퓨터에서 `figcraft-server key`로 확인)." "This server takes files only with its key: see Advanced." = "이 서버는 키가 있어야만 파일을 받음: 고급을 참고하세요." +"Thumbs up" = "좋아요" "This version can't be read." = "이 버전을 읽을 수 없습니다." "This version doesn't have it: all of it is new." = "이 버전에는 없습니다. 모두 새 항목입니다." "Tidy up" = "정돈" @@ -1327,6 +1351,7 @@ status = "draft — needs native review" "Top left radius" = "왼쪽 위 반경" "Top right radius" = "오른쪽 위 반경" "Transform" = "변형" +"Triangle" = "삼각형" "Triangle arrow" = "삼각 화살표" "Trigger" = "트리거" "True" = "참" @@ -1676,6 +1701,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "픽셀에 맞춰 반올림" "arrange.spacing" = "간격" "arrange.tidy" = "정돈" +"board.connector" = "커넥터" +"board.ink" = "마커 획" +"board.label" = "커넥터 레이블" +"board.nextSticky" = "다음 스티커 메모" +"board.setColor" = "보드 색상" +"board.setStyle" = "보드 스타일" +"board.shape" = "텍스트가 있는 도형" +"board.stamp" = "스탬프" +"board.sticky" = "스티커 메모" "bool.exclude" = "선택 항목 제외" "bool.flatten" = "불리언 평면화" "bool.intersect" = "선택 항목 교차" @@ -1800,6 +1834,7 @@ status = "draft — needs native review" "file.import" = "가져오기" "file.moveTab" = "탭 이동" "file.new" = "새 디자인 파일" +"file.newBoard" = "새 보드" "file.open" = "열기" "file.openRecent" = "최근 파일 열기" "file.placeImage" = "이미지 배치" @@ -2063,10 +2098,14 @@ status = "draft — needs native review" "text.weightUp" = "글꼴 굵기 늘리기" "tool.arrow" = "화살표" "tool.comment" = "댓글" +"tool.connector" = "커넥터" "tool.ellipse" = "타원" +"tool.eraser" = "지우개" "tool.frame" = "프레임" "tool.hand" = "손" +"tool.highlighter" = "형광펜" "tool.line" = "선" +"tool.marker" = "마커" "tool.move" = "이동" "tool.pen" = "펜" "tool.pencil" = "연필" @@ -2078,9 +2117,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "배율 기준점" "tool.section" = "섹션" "tool.set" = "도구 선택" +"tool.shape" = "도형" "tool.shapeBuilder" = "도형 빌더" "tool.slice" = "슬라이스" +"tool.stamp" = "스탬프" "tool.star" = "별" +"tool.sticky" = "스티커 메모" "tool.text" = "텍스트" "transform.move" = "이동" "transform.moveTo" = "위치로 이동" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 54851043..2b301b39 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -127,6 +127,7 @@ status = "draft — needs native review" "Archived" = "Gearchiveerd" "Arrange" = "Schikken" "Arrow" = "Pijl" +"Arrow at the end" = "Pijl aan het eind" "As typed" = "Zoals getypt" "Asking the server…" = "Bezig met vragen aan de server…" "Assets" = "Assets" @@ -221,8 +222,10 @@ status = "draft — needs native review" "Changes on main" = "Wijzigingen op main" "Changes on the branch" = "Wijzigingen op de branch" "Changes unknown" = "Onbekende wijzigingen" +"Check" = "Vinkje" "Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…" "Checking the server…" = "Server controleren…" +"Chevron" = "Punthaak" "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI of Compose om dit component aan zijn code te koppelen." "Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen." "Choose collection" = "Kies een verzameling" @@ -290,6 +293,7 @@ status = "draft — needs native review" "Connected" = "Verbonden" "Connected to code: {from}" = "Verbonden met code: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Verbinding verbroken, opnieuw verbinden (poging {attempt})…" +"Connector" = "Verbinding" "Constrain proportions" = "Verhoudingen vergrendelen" "Constraints" = "Constraints" "Content" = "Inhoud" @@ -380,6 +384,7 @@ status = "draft — needs native review" "Description" = "Beschrijving" "Description (optional)" = "Beschrijving (optioneel)" "Design" = "Design" +"Design panel" = "Ontwerppaneel" "Design system starter" = "Designsysteem-starter" "Desktop" = "Desktop" "Destination" = "Bestemming" @@ -442,6 +447,7 @@ status = "draft — needs native review" "Effect settings" = "Effectinstellingen" "Effect styles" = "Effectstijlen" "Effects" = "Effecten" +"Elbow" = "Haaks" "Ellipse" = "Ellips" "Ellipses" = "Ellipsen" "Else" = "Anders" @@ -578,6 +584,7 @@ status = "draft — needs native review" "Hanging list" = "Lijst met hangende inspringing" "Hanging punctuation" = "Uithangende leestekens" "Hard light" = "Hard light" +"Heart" = "Hart" "Height" = "Hoogte" "Help" = "Help" "Help and resources" = "Help en bronnen" @@ -590,6 +597,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Deze tips verbergen (Aan de slag in het menu Help toont ze opnieuw)" "Hide when publishing" = "Verbergen bij publiceren" "Highlight layers on hover" = "Lagen uitlichten bij aanwijzen" +"Highlighter" = "Markeerstift" "Highlights" = "Hooglichten" "Hold Alt" = "Houd Alt ingedrukt" "Hold Alt+Z" = "Houd Alt+Z ingedrukt" @@ -756,6 +764,7 @@ status = "draft — needs native review" "Mark as resolved" = "Markeren als opgelost" "Mark as unread" = "Markeren als ongelezen" "Mark as unresolved" = "Markeren als onopgelost" +"Marker" = "Stift" "Mask" = "Masker" "Mass · Stiffness · Damping" = "Massa · Stijfheid · Demping" "Match (empty: the whole name)" = "Overeenkomst (leeg: de hele naam)" @@ -815,6 +824,7 @@ status = "draft — needs native review" "Named instance" = "Benoemde instance" "Navigate to" = "Ga naar" "Nested instance" = "Geneste instance" +"New board" = "Nieuw whiteboard" "New design file" = "Nieuw ontwerpbestand" "Newest first" = "Nieuwste eerst" "Next frame" = "Volgend frame" @@ -987,6 +997,7 @@ status = "draft — needs native review" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Kies de Frame-tool en sleep daarna op het canvas of kies een apparaatgrootte" "Pin a property" = "Eigenschap vastspelden" "Pinch" = "Pinch" +"Pink" = "Roze" "Pixel grid" = "Pixelraster" "Pixel preview 1x" = "Pixelvoorvertoning 1x" "Pixel preview 2x" = "Pixelvoorvertoning 2x" @@ -1001,6 +1012,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins draaien in de sandbox van FigCraft, zonder bestanden, netwerk of klok. Installeer alleen plugins die je vertrouwt." "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" +"Plus one" = "Plus één" "Point" = "Punt" "Points" = "Punten" "Polygon" = "Veelhoek" @@ -1048,6 +1060,7 @@ status = "draft — needs native review" "Push" = "Pushen" "Push changes to main component" = "Wijzigingen naar hoofdcomponent doorvoeren" "Put text on path" = "Tekst op pad plaatsen" +"Question" = "Vraag" "R" = "R" "Radial" = "Radiaal" "Radius" = "Radius" @@ -1148,6 +1161,7 @@ status = "draft — needs native review" "Rotation jitter" = "Verspreide rotatie" "Round" = "Rond" "Rounded" = "Rounded" +"Rounded rectangle" = "Afgeronde rechthoek" "Row" = "Rij" "Row gap" = "Rijafstand" "Row sizes" = "Rijgroottes" @@ -1223,6 +1237,7 @@ status = "draft — needs native review" "Shape" = "Vorm" "Shape builder" = "Shape builder" "Shape tools" = "Vormgereedschap" +"Shapes" = "Vormen" "Share" = "Delen" "Share this file" = "Dit bestand delen" "Share with a code" = "Delen met een code" @@ -1289,10 +1304,13 @@ status = "draft — needs native review" "Space+drag" = "Space+drag" "Spacing" = "Afstand" "Spam, stolen work, abuse…" = "Spam, gestolen werk, misbruik…" +"Speech bubble" = "Tekstballon" "Splay" = "Splay" "Spotlight me" = "Spotlight mij" "Spread" = "Spread" "Square" = "Vierkant" +"Stamp" = "Stempel" +"Stamps" = "Stempels" "Standard" = "Standaard" "Standard ligatures" = "Standaardligaturen" "Star" = "Ster" @@ -1309,12 +1327,16 @@ status = "draft — needs native review" "Starting to share…" = "Bezig met delen…" "Status" = "Status" "Sticky" = "Sticky" +"Sticky note" = "Plakbriefje" +"Sticky note colors" = "Kleuren van plakbriefjes" +"Sticky notes, shapes and connectors" = "Plakbriefjes, vormen en verbindingen" "Stop" = "Stoppen" "Stop following {name}" = "Stop met {name} te volgen" "Stop session" = "Sessie stoppen" "Stop sharing" = "Stoppen met delen" "Stop spotlighting" = "Stop met uitlichten" "Stop {n} {hex}" = "Stop {n} {hex}" +"Straight" = "Recht" "Stretch" = "Uitrekken" "Strikethrough" = "Doorstrepen" "String" = "Tekenreeks" @@ -1359,6 +1381,7 @@ status = "draft — needs native review" "Text" = "Tekst" "Text align" = "Tekstuitlijning" "Text content" = "Tekstinhoud" +"Text size" = "Tekstgrootte" "Text style" = "Tekststijl" "Text styles" = "Tekststijlen" "Texts" = "Teksten" @@ -1393,6 +1416,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Deze server is voor iedereen toegankelijk: geen sleutel nodig." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Deze server heeft een sleutel nodig: voeg ?key=… toe aan het adres (`figcraft-server key` toont die op de computer van de server)." "This server takes files only with its key: see Advanced." = "Deze server accepteert bestanden alleen met de bijbehorende sleutel: zie Geavanceerd." +"Thumbs up" = "Duim omhoog" "Tidy up" = "Opruimen" "Tile" = "Tegelen" "Tile type" = "Tegeltype" @@ -1409,6 +1433,7 @@ status = "draft — needs native review" "Top left radius" = "Radius linksboven" "Top right radius" = "Hoekradius rechtsboven" "Transform" = "Transformeren" +"Triangle" = "Driehoek" "Triangle arrow" = "Driehoekspijl" "Trigger" = "Trigger" "True" = "Waar" @@ -1770,6 +1795,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Afronden op pixels" "arrange.spacing" = "Afstand" "arrange.tidy" = "Opruimen" +"board.connector" = "Verbinding" +"board.ink" = "Stiftlijn" +"board.label" = "Label van verbinding" +"board.nextSticky" = "Volgend plakbriefje" +"board.setColor" = "Whiteboardkleur" +"board.setStyle" = "Whiteboardstijl" +"board.shape" = "Vorm met tekst" +"board.stamp" = "Stempel" +"board.sticky" = "Plakbriefje" "bool.exclude" = "Exclude toepassen op selectie" "bool.flatten" = "Boolean afvlakken" "bool.intersect" = "Intersect toepassen op selectie" @@ -1896,6 +1930,7 @@ status = "draft — needs native review" "file.import" = "Importeren" "file.moveTab" = "Tabblad verplaatsen" "file.new" = "Nieuw ontwerpbestand" +"file.newBoard" = "Nieuw whiteboard" "file.newFromTemplate" = "Nieuw vanuit sjabloon" "file.open" = "Openen" "file.openRecent" = "Onlangs geopende bestanden" @@ -2166,10 +2201,14 @@ status = "draft — needs native review" "text.weightUp" = "Letterdikte vergroten" "tool.arrow" = "Pijl" "tool.comment" = "Reactie" +"tool.connector" = "Verbinding" "tool.ellipse" = "Ellips" +"tool.eraser" = "Gum" "tool.frame" = "Frame" "tool.hand" = "Hand" +"tool.highlighter" = "Markeerstift" "tool.line" = "Lijn" +"tool.marker" = "Stift" "tool.move" = "Verplaatsen" "tool.pen" = "Pen" "tool.pencil" = "Potlood" @@ -2181,9 +2220,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Schaalanker" "tool.section" = "Sectie" "tool.set" = "Gereedschap kiezen" +"tool.shape" = "Vormen" "tool.shapeBuilder" = "Shape builder" "tool.slice" = "Slice" +"tool.stamp" = "Stempel" "tool.star" = "Ster" +"tool.sticky" = "Plakbriefje" "tool.text" = "Tekst" "transform.move" = "Verplaatsen" "transform.moveTo" = "Verplaatsen naar" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..51c59f9e 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -124,6 +124,7 @@ status = "draft — needs native review" "Archived" = "Zarchiwizowano" "Arrange" = "Ułóż" "Arrow" = "Strzałka" +"Arrow at the end" = "Strzałka na końcu" "As typed" = "Zgodnie z wpisanym" "Asking the server…" = "Odpytywanie serwera…" "Assets" = "Zasoby" @@ -197,12 +198,15 @@ status = "draft — needs native review" "C or ⇧C" = "C lub ⇧C" "CMYK colour" = "Kolor CMYK" "Can view" = "Może przeglądać" +"Check" = "Ptaszek" "Checking the fonts installed on this computer…" = "Sprawdzanie czcionek zainstalowanych na tym komputerze…" "Checking the server…" = "Sprawdzanie serwera…" +"Chevron" = "Szewron" "Clear from this list" = "Usuń z tej listy" "Code or invite link" = "Kod lub link z zaproszeniem" "Color profile: {name}" = "Profil kolorów: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Style kolorów i tekstu, zmienne Jasne i Ciemne, przyciski, pola wprowadzania i karty" +"Connector" = "Łącznik" "Convert to frame" = "Konwertuj na ramkę" "Convert to section" = "Konwertuj na sekcję" "CSS custom properties…" = "Właściwości niestandardowe CSS…" @@ -377,6 +381,7 @@ status = "draft — needs native review" "Description" = "Opis" "Description (optional)" = "Opis (opcjonalnie)" "Design" = "Projektowanie" +"Design panel" = "Panel projektu" "Design system starter" = "Pakiet startowy systemu projektowego (Design System)" "Desktop" = "Pulpit" "Destination" = "Miejsce docelowe" @@ -439,6 +444,7 @@ status = "draft — needs native review" "Effect settings" = "Ustawienia efektów" "Effect styles" = "Style efektów" "Effects" = "Efekty" +"Elbow" = "Łamany" "Ellipse" = "Elipsa" "Ellipses" = "Elipsy" "Else" = "W przeciwnym razie" @@ -575,6 +581,7 @@ status = "draft — needs native review" "Hanging list" = "Lista z wysunięciem" "Hanging punctuation" = "Wiszące znaki interpunkcyjne" "Hard light" = "Mocne światło" +"Heart" = "Serce" "Height" = "Wysokość" "Help" = "Pomoc" "Help and resources" = "Pomoc i zasoby" @@ -587,6 +594,7 @@ status = "draft — needs native review" "Hide these tips (Getting started in the Help menu shows them again)" = "Ukryj te wskazówki (Pierwsze kroki w menu Pomoc wyświetli je ponownie)" "Hide when publishing" = "Ukryj podczas publikacji" "Highlight layers on hover" = "Wyróżniaj warstwy po najechaniu kursorem" +"Highlighter" = "Zakreślacz" "Highlights" = "Światła" "Hold Alt" = "Przytrzymaj Alt" "Hold Alt+Z" = "Przytrzymaj Alt+Z" @@ -753,6 +761,7 @@ status = "draft — needs native review" "Mark as resolved" = "Oznacz jako rozwiązane" "Mark as unread" = "Oznacz jako nieprzeczytane" "Mark as unresolved" = "Oznacz jako nierozwiązane" +"Marker" = "Marker" "Mask" = "Maska" "Mass · Stiffness · Damping" = "Masa · Sztywność · Tłumienie" "Match (empty: the whole name)" = "Dopasowanie (puste: cała nazwa)" @@ -812,6 +821,7 @@ status = "draft — needs native review" "Named instance" = "Nazwana instancja" "Navigate to" = "Przejdź do" "Nested instance" = "Zagnieżdżona instancja" +"New board" = "Nowa tablica" "New design file" = "Nowy plik projektu" "Newest first" = "Najpierw najnowsze" "Next or previous control in the focused region" = "Następny lub poprzedni element w aktywnym obszarze" @@ -952,6 +962,7 @@ status = "draft — needs native review" "Page" = "Strona" "Pages" = "Strony" "Pages added to the document" = "Strony dodane do dokumentu" +"Pink" = "Różowy" "Placed on the current page" = "Umieszczono na bieżącej stronie" "Pages: {pages}" = "Strony: {pages}" "Paint bucket" = "Wiadro z farbą" @@ -996,6 +1007,7 @@ status = "draft — needs native review" "Plugins & widgets" = "Wtyczki i widżety" "Plus darker" = "Plus ciemniejszy" "Plus lighter" = "Plus jaśniejszy" +"Plus one" = "Plus jeden" "Point" = "Punkt" "Points" = "Punkty" "Polygon" = "Wielokąt" @@ -1044,6 +1056,7 @@ status = "draft — needs native review" "Push" = "Wypychanie" "Push changes to main component" = "Wypchnij zmiany do głównego komponentu" "Put text on path" = "Umieść tekst na ścieżce" +"Question" = "Pytanie" "R" = "R" "Radial" = "Promienisty" "Radius" = "Promień" @@ -1144,6 +1157,7 @@ status = "draft — needs native review" "Rotation jitter" = "Drżenie obrotu (jitter)" "Round" = "Okrągłe" "Rounded" = "Zaokrąglone" +"Rounded rectangle" = "Zaokrąglony prostokąt" "Row" = "Wiersz" "Row gap" = "Odstęp między wierszami" "Row sizes" = "Rozmiary wierszy" @@ -1219,6 +1233,7 @@ status = "draft — needs native review" "Shape" = "Kształt" "Shape builder" = "Kreator kształtów" "Shape tools" = "Narzędzia kształtów" +"Shapes" = "Kształty" "Share" = "Udostępnij" "Share this file" = "Udostępnij ten plik" "Share with a code" = "Udostępnij za pomocą kodu" @@ -1285,10 +1300,13 @@ status = "draft — needs native review" "Space+drag" = "Spacja+przeciągnięcie" "Spacing" = "Odstępy" "Spam, stolen work, abuse…" = "Spam, plagiat, naruszenie zasad…" +"Speech bubble" = "Dymek" "Splay" = "Rozszerzenie" "Spotlight me" = "Wyróżnij mój widok (Spotlight me)" "Spread" = "Rozszerzenie" "Square" = "Kwadratowe" +"Stamp" = "Pieczątka" +"Stamps" = "Pieczątki" "Standard" = "Standardowy" "Standard ligatures" = "Ligatury standardowe" "Star" = "Gwiazda" @@ -1304,12 +1322,16 @@ status = "draft — needs native review" "Starting to share…" = "Rozpoczynanie udostępniania…" "Status" = "Stan" "Sticky" = "Przyklejony (sticky)" +"Sticky note" = "Karteczka" +"Sticky note colors" = "Kolory karteczek" +"Sticky notes, shapes and connectors" = "Karteczki, kształty i łączniki" "Stop" = "Zatrzymaj" "Stop following {name}" = "Przestań obserwować: {name}" "Stop session" = "Zatrzymaj sesję" "Stop sharing" = "Zatrzymaj udostępnianie" "Stop spotlighting" = "Zatrzymaj wyróżnianie widoku" "Stop {n} {hex}" = "Punkt przejścia {n} {hex}" +"Straight" = "Prosty" "Stretch" = "Rozciągnięcie" "Strikethrough" = "Przekreślenie" "String" = "Tekst (string)" @@ -1354,6 +1376,7 @@ status = "draft — needs native review" "Text" = "Tekst" "Text align" = "Wyrównanie tekstu" "Text content" = "Zawartość tekstu" +"Text size" = "Rozmiar tekstu" "Text style" = "Styl tekstu" "Text styles" = "Style tekstu" "Texts" = "Teksty" @@ -1388,6 +1411,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Ten serwer jest otwarty publicznie: klucz nie jest wymagany." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ten serwer wymaga klucza: dodaj ?key=… do adresu (polecenie `figcraft-server key` wyświetla go na maszynie serwera)." "This server takes files only with its key: see Advanced." = "Ten serwer przyjmuje pliki tylko z kluczem: zobacz Zaawansowane." +"Thumbs up" = "Kciuk w górę" "Tidy up" = "Uporządkuj" "Tile" = "Sąsiadująco" "Tile type" = "Typ kafelka" @@ -1404,6 +1428,7 @@ status = "draft — needs native review" "Top left radius" = "Promień górnego lewego narożnika" "Top right radius" = "Promień górnego prawego narożnika" "Transform" = "Przekształć" +"Triangle" = "Trójkąt" "Triangle arrow" = "Strzałka trójkątna" "Trigger" = "Wyzwalacz" "True" = "Prawda" @@ -1766,6 +1791,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Zaokrąglij do pikseli" "arrange.spacing" = "Odstępy" "arrange.tidy" = "Uporządkuj" +"board.connector" = "Łącznik" +"board.ink" = "Pociągnięcie markera" +"board.label" = "Etykieta łącznika" +"board.nextSticky" = "Następna karteczka" +"board.setColor" = "Kolor na tablicy" +"board.setStyle" = "Styl na tablicy" +"board.shape" = "Kształt z tekstem" +"board.stamp" = "Pieczątka" +"board.sticky" = "Karteczka" "bool.exclude" = "Wyklucz zaznaczenie" "bool.flatten" = "Spłaszcz operację logiczną" "bool.intersect" = "Część wspólna zaznaczenia" @@ -1891,6 +1925,7 @@ status = "draft — needs native review" "file.exportSvg" = "Eksportuj zaznaczenie jako SVG" "file.import" = "Importuj z pliku…" "file.new" = "Nowy plik projektu" +"file.newBoard" = "Nowa tablica" "file.newFromTemplate" = "Nowy z szablonu" "file.open" = "Otwórz…" "file.openRecent" = "Otwórz ostatnie" @@ -2161,10 +2196,14 @@ status = "draft — needs native review" "text.weightUp" = "Zwiększ grubość czcionki" "tool.arrow" = "Strzałka" "tool.comment" = "Komentarz" +"tool.connector" = "Łącznik" "tool.ellipse" = "Elipsa" +"tool.eraser" = "Gumka" "tool.frame" = "Ramka" "tool.hand" = "Rączka" +"tool.highlighter" = "Zakreślacz" "tool.line" = "Linia" +"tool.marker" = "Marker" "tool.move" = "Wskaźnik" "tool.pen" = "Pióro" "tool.pencil" = "Ołówek" @@ -2176,9 +2215,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Punkt zaczepienia skalowania" "tool.section" = "Sekcja" "tool.set" = "Wybierz narzędzie" +"tool.shape" = "Kształty" "tool.shapeBuilder" = "Generator kształtów" "tool.slice" = "Plaster" +"tool.stamp" = "Pieczątka" "tool.star" = "Gwiazda" +"tool.sticky" = "Karteczka" "tool.text" = "Tekst" "transform.move" = "Przenieś" "transform.moveTo" = "Przenieś do" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 35ebf52e..ed841775 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -116,6 +116,7 @@ status = "draft — needs native review" "Archived" = "Arquivada" "Arrange" = "Organizar" "Arrow" = "Seta" +"Arrow at the end" = "Seta no final" "As typed" = "Como digitado" "Asking the server…" = "Consultando o servidor…" "Assets" = "Recursos" @@ -187,7 +188,9 @@ status = "draft — needs native review" "C or ⇧C" = "C ou ⇧C" "CMYK colour" = "Cor CMYK" "Can view" = "Pode visualizar" +"Check" = "Visto" "Checking the server…" = "Verificando o servidor…" +"Chevron" = "Divisa" "Clear from this list" = "Limpar desta lista" "Code or invite link" = "Código ou link de convite" "Color profile: {name}" = "Perfil de cor: {name}" @@ -276,6 +279,7 @@ status = "draft — needs native review" "Connected" = "Conectado" "Connected to code: {from}" = "Conectado ao código: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Conexão perdida, reconectando (tentativa {attempt})…" +"Connector" = "Conector" "Constrain proportions" = "Manter proporções" "Constraints" = "Restrições" "Content" = "Conteúdo" @@ -360,6 +364,7 @@ status = "draft — needs native review" "Description" = "Descrição" "Description (optional)" = "Descrição (opcional)" "Design" = "Design" +"Design panel" = "Painel de design" "Desktop" = "Desktop" "Destination" = "Destino" "Detach alias" = "Desvincular alias" @@ -417,6 +422,7 @@ status = "draft — needs native review" "Effect settings" = "Configurações do efeito" "Effect styles" = "Estilos de efeito" "Effects" = "Efeitos" +"Elbow" = "Em ângulo" "Ellipse" = "Elipse" "Ellipses" = "Elipses" "Else" = "Senão" @@ -547,6 +553,7 @@ status = "draft — needs native review" "Hanging list" = "Lista com recuo deslocado" "Hanging punctuation" = "Pontuação pendente" "Hard light" = "Luz forte" +"Heart" = "Coração" "Height" = "Altura" "Help" = "Ajuda" "Help and resources" = "Ajuda e recursos" @@ -557,6 +564,7 @@ status = "draft — needs native review" "Hide from publishing" = "Não publicar" "Hide section contents" = "Ocultar conteúdo da seção" "Highlight layers on hover" = "Destacar camadas ao passar o cursor" +"Highlighter" = "Marca-texto" "Highlights" = "Realces" "Hold Alt" = "Segure Alt" "Hold Alt+Z" = "Segure Alt+Z" @@ -712,6 +720,7 @@ status = "draft — needs native review" "Mark as resolved" = "Marcar como resolvido" "Mark as unread" = "Marcar como não lido" "Mark as unresolved" = "Marcar como não resolvido" +"Marker" = "Marcador" "Mask" = "Máscara" "Mass · Stiffness · Damping" = "Massa · Rigidez · Amortecimento" "Match (empty: the whole name)" = "Correspondência (vazio: o nome inteiro)" @@ -768,6 +777,7 @@ status = "draft — needs native review" "Named instance" = "Instância nomeada" "Navigate to" = "Navegar para" "Nested instance" = "Instância aninhada" +"New board" = "Novo quadro" "New design file" = "Novo arquivo de design" "Next frame" = "Próximo quadro" "Next or previous control in the focused region" = "Controle seguinte ou anterior na região em foco" @@ -898,6 +908,7 @@ status = "draft — needs native review" "Page deleted" = "Página excluída" "Pages" = "Páginas" "Pages added to the document" = "Páginas adicionadas ao documento" +"Pink" = "Rosa" "Placed on the current page" = "Colocado na página atual" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Balde de tinta" @@ -939,6 +950,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Os plugins são executados na sandbox do FigCraft, sem arquivos, rede nem relógio. Instale apenas plugins em que você confia." "Plus darker" = "Mais escuro" "Plus lighter" = "Mais claro" +"Plus one" = "Mais um" "Point" = "Ponto" "Points" = "Pontos" "Polygon" = "Polígono" @@ -985,6 +997,7 @@ status = "draft — needs native review" "Push" = "Empurrar" "Push changes to main component" = "Enviar alterações ao componente principal" "Put text on path" = "Colocar texto no caminho" +"Question" = "Pergunta" "R" = "R" "Radial" = "Radial" "Radius" = "Raio" @@ -1085,6 +1098,7 @@ status = "draft — needs native review" "Rotation jitter" = "Variação de rotação" "Round" = "Arredondado" "Rounded" = "Arredondado" +"Rounded rectangle" = "Retângulo arredondado" "Row" = "Linha" "Row gap" = "Espaço entre linhas" "Row sizes" = "Tamanhos de linha" @@ -1154,6 +1168,7 @@ status = "draft — needs native review" "Shape" = "Forma" "Shape builder" = "Construtor de formas" "Shape tools" = "Ferramentas de forma" +"Shapes" = "Formas" "Share" = "Compartilhar" "Share this file" = "Compartilhar este arquivo" "Share: live session, export" = "Compartilhar: sessão ao vivo, exportar" @@ -1211,10 +1226,13 @@ status = "draft — needs native review" "Space+drag" = "Espaço+arrastar" "Spacing" = "Espaçamento" "Spam, stolen work, abuse…" = "Spam, trabalho roubado, abuso…" +"Speech bubble" = "Balão de fala" "Splay" = "Abertura" "Spotlight me" = "Colocar o foco em mim" "Spread" = "Expansão" "Square" = "Quadrado" +"Stamp" = "Carimbo" +"Stamps" = "Carimbos" "Standard" = "Padrão" "Standard ligatures" = "Ligaduras padrão" "Star" = "Estrela" @@ -1229,12 +1247,16 @@ status = "draft — needs native review" "Starting to share…" = "Começando a compartilhar…" "Status" = "Status" "Sticky" = "Fixado" +"Sticky note" = "Nota adesiva" +"Sticky note colors" = "Cores das notas adesivas" +"Sticky notes, shapes and connectors" = "Notas adesivas, formas e conectores" "Stop" = "Parar" "Stop following {name}" = "Parar de seguir {name}" "Stop session" = "Encerrar sessão" "Stop sharing" = "Parar de compartilhar" "Stop spotlighting" = "Remover o foco" "Stop {n} {hex}" = "Parada {n} {hex}" +"Straight" = "Reto" "Stretch" = "Esticar" "Strikethrough" = "Tachado" "String" = "String" @@ -1278,6 +1300,7 @@ status = "draft — needs native review" "Text" = "Texto" "Text align" = "Alinhamento do texto" "Text content" = "Conteúdo do texto" +"Text size" = "Tamanho do texto" "Text style" = "Estilo de texto" "Text styles" = "Estilos de texto" "Texts" = "Textos" @@ -1308,6 +1331,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor é aberto ao público: não precisa de chave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor precisa da própria chave: adicione ?key=… ao endereço (`figcraft-server key` a mostra na máquina do servidor)." "This server takes files only with its key: see Advanced." = "Este servidor só aceita arquivos com a própria chave: veja Avançado." +"Thumbs up" = "Joinha" "This version can't be read." = "Não é possível ler esta versão." "This version doesn't have it: all of it is new." = "Esta versão não o tem: tudo é novo." "Tidy up" = "Organizar" @@ -1326,6 +1350,7 @@ status = "draft — needs native review" "Top left radius" = "Raio superior esquerdo" "Top right radius" = "Raio superior direito" "Transform" = "Transformar" +"Triangle" = "Triângulo" "Triangle arrow" = "Seta triangular" "Trigger" = "Gatilho" "True" = "Verdadeiro" @@ -1789,6 +1814,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arredondar para pixels" "arrange.spacing" = "Espaçamento" "arrange.tidy" = "Organizar" +"board.connector" = "Conector" +"board.ink" = "Traço de marcador" +"board.label" = "Rótulo do conector" +"board.nextSticky" = "Próxima nota adesiva" +"board.setColor" = "Cor no quadro" +"board.setStyle" = "Estilo no quadro" +"board.shape" = "Forma com texto" +"board.stamp" = "Carimbo" +"board.sticky" = "Nota adesiva" "bool.exclude" = "Excluir sobreposição da seleção" "bool.flatten" = "Nivelar booleano" "bool.intersect" = "Interseção da seleção" @@ -1913,6 +1947,7 @@ status = "draft — needs native review" "file.import" = "Importar" "file.moveTab" = "Mover aba" "file.new" = "Novo arquivo de design" +"file.newBoard" = "Novo quadro" "file.open" = "Abrir" "file.openRecent" = "Abrir recente" "file.placeImage" = "Inserir imagem" @@ -2176,10 +2211,14 @@ status = "draft — needs native review" "text.weightUp" = "Aumentar peso da fonte" "tool.arrow" = "Seta" "tool.comment" = "Comentário" +"tool.connector" = "Conector" "tool.ellipse" = "Elipse" +"tool.eraser" = "Borracha" "tool.frame" = "Quadro" "tool.hand" = "Mão" +"tool.highlighter" = "Marca-texto" "tool.line" = "Linha" +"tool.marker" = "Marcador" "tool.move" = "Mover" "tool.pen" = "Caneta" "tool.pencil" = "Lápis" @@ -2191,9 +2230,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "Âncora de escala" "tool.section" = "Seção" "tool.set" = "Escolher ferramenta" +"tool.shape" = "Formas" "tool.shapeBuilder" = "Construtor de formas" "tool.slice" = "Fatia" +"tool.stamp" = "Carimbo" "tool.star" = "Estrela" +"tool.sticky" = "Nota adesiva" "tool.text" = "Texto" "transform.move" = "Mover" "transform.moveTo" = "Mover para" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6e173a92..38cfaa4e 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -125,6 +125,7 @@ name = "Русский" "Archived" = "В архиве" "Arrange" = "Упорядочить" "Arrow" = "Стрелка" +"Arrow at the end" = "Стрелка на конце" "As typed" = "Как введено" "Asking the server…" = "Запрос к серверу…" "Assets" = "Ресурсы" @@ -199,8 +200,10 @@ name = "Русский" "C or ⇧C" = "C или ⇧C" "CMYK colour" = "Цвет CMYK" "Can view" = "Может просматривать" +"Check" = "Галочка" "Checking the fonts installed on this computer…" = "Проверка шрифтов, установленных на этом компьютере…" "Checking the server…" = "Проверка сервера…" +"Chevron" = "Шеврон" "Clear from this list" = "Убрать из этого списка" "Code or invite link" = "Код или ссылка приглашения" "Color profile: {name}" = "Цветовой профиль: {name}" @@ -289,6 +292,7 @@ name = "Русский" "Connected" = "Подключено" "Connected to code: {from}" = "Связано с кодом: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Соединение потеряно, переподключение (попытка {attempt})…" +"Connector" = "Соединитель" "Constrain proportions" = "Сохранять пропорции" "Constraints" = "Ограничения" "Content" = "Содержимое" @@ -379,6 +383,7 @@ name = "Русский" "Description" = "Описание" "Description (optional)" = "Описание (необязательно)" "Design" = "Дизайн" +"Design panel" = "Панель дизайна" "Design system starter" = "Основа дизайн-системы" "Desktop" = "Компьютер" "Destination" = "Назначение" @@ -441,6 +446,7 @@ name = "Русский" "Effect settings" = "Настройки эффекта" "Effect styles" = "Стили эффектов" "Effects" = "Эффекты" +"Elbow" = "Ломаная" "Ellipse" = "Эллипс" "Ellipses" = "Эллипсы" "Else" = "Иначе" @@ -577,6 +583,7 @@ name = "Русский" "Hanging list" = "Выступающий список" "Hanging punctuation" = "Висячая пунктуация" "Hard light" = "Жёсткий свет" +"Heart" = "Сердце" "Height" = "Высота" "Help" = "Справка" "Help and resources" = "Справка и ресурсы" @@ -589,6 +596,7 @@ name = "Русский" "Hide these tips (Getting started in the Help menu shows them again)" = "Скрыть подсказки (их можно вернуть через «Начало работы» в меню справки)" "Hide when publishing" = "Скрывать при публикации" "Highlight layers on hover" = "Подсвечивать слои при наведении" +"Highlighter" = "Текстовыделитель" "Highlights" = "Света" "Hold Alt" = "Удерживайте Alt" "Hold Alt+Z" = "Удерживайте Alt+Z" @@ -758,6 +766,7 @@ name = "Русский" "Mark as resolved" = "Отметить как решённое" "Mark as unread" = "Отметить как непрочитанное" "Mark as unresolved" = "Отметить как нерешённое" +"Marker" = "Маркер" "Mask" = "Маска" "Mass · Stiffness · Damping" = "Масса · Жёсткость · Демпфирование" "Match (empty: the whole name)" = "Совпадение (пусто: всё имя)" @@ -817,6 +826,7 @@ name = "Русский" "Named instance" = "Именованный экземпляр" "Navigate to" = "Перейти к" "Nested instance" = "Вложенный экземпляр" +"New board" = "Новая доска" "New design file" = "Новый файл дизайна" "Newest first" = "Сначала новые" "Next frame" = "Следующий Frame" @@ -958,6 +968,7 @@ name = "Русский" "Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" +"Pink" = "Розовый" "Placed on the current page" = "Размещено на текущей странице" "Pages: {pages}" = "Страницы: {pages}" "Paint bucket" = "Заливка области" @@ -1002,6 +1013,7 @@ name = "Русский" "Plugins" = "Плагины" "Plus darker" = "Линейное затемнение" "Plus lighter" = "Линейное осветление" +"Plus one" = "Плюс один" "Point" = "Точка" "Points" = "Точки" "Polygon" = "Многоугольник" @@ -1050,6 +1062,7 @@ name = "Русский" "Push" = "Вытеснение" "Push changes to main component" = "Перенести изменения в основной компонент" "Put text on path" = "Разместить текст на пути" +"Question" = "Вопрос" "R" = "R" "Radial" = "Радиальный" "Radius" = "Радиус" @@ -1153,6 +1166,7 @@ name = "Русский" "Rotation jitter" = "Колебание угла" "Round" = "Круглое" "Rounded" = "Скруглённый" +"Rounded rectangle" = "Скруглённый прямоугольник" "Row" = "Строка" "Row gap" = "Интервал между строками" "Row sizes" = "Размеры строк" @@ -1229,6 +1243,7 @@ name = "Русский" "Shape" = "Фигура" "Shape builder" = "Конструктор фигур" "Shape tools" = "Инструменты фигур" +"Shapes" = "Фигуры" "Share" = "Поделиться" "Share this file" = "Поделиться файлом" "Share with a code" = "Поделиться по коду" @@ -1295,10 +1310,13 @@ name = "Русский" "Space+drag" = "Пробел+перетаскивание" "Spacing" = "Интервал" "Spam, stolen work, abuse…" = "Спам, украденные работы, оскорбления…" +"Speech bubble" = "Выноска" "Splay" = "Расхождение" "Spotlight me" = "Показывать мой экран всем" "Spread" = "Размах" "Square" = "Квадрат" +"Stamp" = "Штамп" +"Stamps" = "Штампы" "Standard" = "Стандартный" "Standard ligatures" = "Стандартные лигатуры" "Star" = "Звезда" @@ -1315,12 +1333,16 @@ name = "Русский" "Starting to share…" = "Включение общего доступа…" "Status" = "Статус" "Sticky" = "Закреплённый" +"Sticky note" = "Стикер" +"Sticky note colors" = "Цвета стикеров" +"Sticky notes, shapes and connectors" = "Стикеры, фигуры и соединители" "Stop" = "Остановить" "Stop following {name}" = "Перестать следить за {name}" "Stop session" = "Завершить сессию" "Stop sharing" = "Остановить общий доступ" "Stop spotlighting" = "Прекратить показ всем" "Stop {n} {hex}" = "Точка {n} {hex}" +"Straight" = "Прямая" "Stretch" = "Растянуть" "Strikethrough" = "Зачёркивание" "String" = "Строка" @@ -1365,6 +1387,7 @@ name = "Русский" "Text" = "Текст" "Text align" = "Выравнивание текста" "Text content" = "Содержимое текста" +"Text size" = "Размер текста" "Text style" = "Текстовый стиль" "Text styles" = "Текстовые стили" "Texts" = "Тексты" @@ -1401,6 +1424,7 @@ name = "Русский" "This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Сервер требует ключ: добавьте ?key=… к адресу (`figcraft-server key` выводит его на компьютере сервера)." "This server takes files only with its key: see Advanced." = "Сервер принимает файлы только с ключом: см. «Дополнительно»." +"Thumbs up" = "Палец вверх" "Tidy up" = "Упорядочить" "Tile" = "Повторение" "Tile type" = "Тип повторения" @@ -1418,6 +1442,7 @@ name = "Русский" "Top padding" = "Верхний внутренний отступ" "Top right radius" = "Радиус верхнего правого угла" "Transform" = "Трансформация" +"Triangle" = "Треугольник" "Triangle arrow" = "Треугольная стрелка" "Trigger" = "Триггер" "True" = "Истина" @@ -1783,6 +1808,15 @@ name = "Русский" "arrange.roundToPixels" = "Округлить до пикселей" "arrange.spacing" = "Интервал" "arrange.tidy" = "Упорядочить" +"board.connector" = "Соединитель" +"board.ink" = "Штрих маркера" +"board.label" = "Подпись соединителя" +"board.nextSticky" = "Следующий стикер" +"board.setColor" = "Цвет на доске" +"board.setStyle" = "Стиль на доске" +"board.shape" = "Фигура с текстом" +"board.stamp" = "Штамп" +"board.sticky" = "Стикер" "bool.exclude" = "Исключить выделение" "bool.flatten" = "Свести булеву группу" "bool.intersect" = "Пересечь выделение" @@ -1909,6 +1943,7 @@ name = "Русский" "file.import" = "Импорт" "file.moveTab" = "Переместить вкладку" "file.new" = "Новый файл дизайна" +"file.newBoard" = "Новая доска" "file.newFromTemplate" = "Создать из шаблона" "file.open" = "Открыть" "file.openRecent" = "Открыть недавние" @@ -2183,10 +2218,14 @@ name = "Русский" "text.weightUp" = "Увеличить толщину шрифта" "tool.arrow" = "Стрелка" "tool.comment" = "Комментарий" +"tool.connector" = "Соединитель" "tool.ellipse" = "Эллипс" +"tool.eraser" = "Ластик" "tool.frame" = "Frame" "tool.hand" = "Рука" +"tool.highlighter" = "Текстовыделитель" "tool.line" = "Линия" +"tool.marker" = "Маркер" "tool.move" = "Перемещение" "tool.pen" = "Перо" "tool.pencil" = "Карандаш" @@ -2198,9 +2237,12 @@ name = "Русский" "tool.scaleAnchor" = "Центр масштабирования" "tool.section" = "Секция" "tool.set" = "Выбрать инструмент" +"tool.shape" = "Фигуры" "tool.shapeBuilder" = "Конструктор фигур" "tool.slice" = "Срез" +"tool.stamp" = "Штамп" "tool.star" = "Звезда" +"tool.sticky" = "Стикер" "tool.text" = "Текст" "transform.move" = "Перемещение" "transform.moveTo" = "Переместить в" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d86779e0..a5f4d4ba 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -124,6 +124,7 @@ name = "Svenska" "Archived" = "Arkiverad" "Arrange" = "Ordna" "Arrow" = "Pil" +"Arrow at the end" = "Pil i slutet" "As typed" = "Som inskrivet" "Asking the server…" = "Frågar servern…" "Assets" = "Tillgångar" @@ -197,7 +198,9 @@ name = "Svenska" "C or ⇧C" = "C eller ⇧C" "CMYK colour" = "CMYK-färg" "Can view" = "Kan visa" +"Check" = "Bock" "Checking the server…" = "Kontrollerar servern…" +"Chevron" = "Vinkel" "Clear from this list" = "Ta bort från listan" "Code or invite link" = "Kod eller inbjudningslänk" "Color profile: {name}" = "Färgprofil: {name}" @@ -286,6 +289,7 @@ name = "Svenska" "Connected" = "Kopplad" "Connected to code: {from}" = "Kopplad till kod: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Anslutningen bröts, återansluter (försök {attempt})…" +"Connector" = "Koppling" "Constrain proportions" = "Behåll proportioner" "Constraints" = "Begränsningar" "Content" = "Innehåll" @@ -374,6 +378,7 @@ name = "Svenska" "Description" = "Beskrivning" "Description (optional)" = "Beskrivning (valfritt)" "Design" = "Design" +"Design panel" = "Designpanel" "Design system starter" = "Startmall för designsystem" "Desktop" = "Skrivbord" "Destination" = "Mål" @@ -436,6 +441,7 @@ name = "Svenska" "Effect settings" = "Effektinställningar" "Effect styles" = "Effektstilar" "Effects" = "Effekter" +"Elbow" = "Vinklad" "Ellipse" = "Ellips" "Ellipses" = "Ellipser" "Else" = "Annars" @@ -572,6 +578,7 @@ name = "Svenska" "Hanging list" = "Hängande lista" "Hanging punctuation" = "Hängande skiljetecken" "Hard light" = "Hårt ljus" +"Heart" = "Hjärta" "Height" = "Höjd" "Help" = "Hjälp" "Help and resources" = "Hjälp och resurser" @@ -584,6 +591,7 @@ name = "Svenska" "Hide these tips (Getting started in the Help menu shows them again)" = "Dölj de här tipsen (Kom igång i Hjälp-menyn visar dem igen)" "Hide when publishing" = "Dölj vid publicering" "Highlight layers on hover" = "Framhäv lager vid hovring" +"Highlighter" = "Överstrykningspenna" "Highlights" = "Högdagrar" "Hold Alt" = "Håll ned Alt" "Hold Alt+Z" = "Håll ned Alt+Z" @@ -750,6 +758,7 @@ name = "Svenska" "Mark as resolved" = "Märk som löst" "Mark as unread" = "Märk som oläst" "Mark as unresolved" = "Märk som olöst" +"Marker" = "Märkpenna" "Mask" = "Mask" "Mass · Stiffness · Damping" = "Massa · Styvhet · Dämpning" "Match (empty: the whole name)" = "Matcha (tomt: hela namnet)" @@ -808,6 +817,7 @@ name = "Svenska" "Named instance" = "Namngiven instans" "Navigate to" = "Navigera till" "Nested instance" = "Kapslad instans" +"New board" = "Ny whiteboard" "New design file" = "Ny designfil" "Newest first" = "Nyaste först" "Next frame" = "Nästa ram" @@ -946,6 +956,7 @@ name = "Svenska" "Page deleted" = "Sidan har raderats" "Pages" = "Sidor" "Pages added to the document" = "Sidor tillagda i dokumentet" +"Pink" = "Rosa" "Placed on the current page" = "Placerat på den aktuella sidan" "Pages: {pages}" = "Sidor: {pages}" "Paint bucket" = "Färghink" @@ -988,6 +999,7 @@ name = "Svenska" "Plugins" = "Plugin-program" "Plus darker" = "Plus mörkare" "Plus lighter" = "Plus ljusare" +"Plus one" = "Plus ett" "Point" = "Punkt" "Points" = "Punkter" "Polygon" = "Polygon" @@ -1036,6 +1048,7 @@ name = "Svenska" "Push" = "Skjut" "Push changes to main component" = "Skicka ändringar till huvudkomponenten" "Put text on path" = "Placera text på bana" +"Question" = "Fråga" "R" = "R" "Radial" = "Radiell" "Radius" = "Radie" @@ -1136,6 +1149,7 @@ name = "Svenska" "Rotation jitter" = "Rotationsdarr" "Round" = "Rund" "Rounded" = "Rundad" +"Rounded rectangle" = "Rundad rektangel" "Row" = "Rad" "Row gap" = "Radmellanrum" "Row sizes" = "Radstorlekar" @@ -1209,6 +1223,7 @@ name = "Svenska" "Shape" = "Form" "Shape builder" = "Formbyggare" "Shape tools" = "Formverktyg" +"Shapes" = "Former" "Share" = "Dela" "Share this file" = "Dela den här filen" "Share with a code" = "Dela med en kod" @@ -1275,10 +1290,13 @@ name = "Svenska" "Space+drag" = "Blanksteg+dra" "Spacing" = "Avstånd" "Spam, stolen work, abuse…" = "Skräppost, stulet material, kränkningar…" +"Speech bubble" = "Pratbubbla" "Splay" = "Utbredning" "Spotlight me" = "Rikta strålkastaren mot mig" "Spread" = "Spridning" "Square" = "Kvadrat" +"Stamp" = "Stämpel" +"Stamps" = "Stämplar" "Standard" = "Standard" "Standard ligatures" = "Standardligaturer" "Star" = "Stjärna" @@ -1295,12 +1313,16 @@ name = "Svenska" "Starting to share…" = "Börjar dela…" "Status" = "Status" "Sticky" = "Fast" +"Sticky note" = "Lapp" +"Sticky note colors" = "Lappfärger" +"Sticky notes, shapes and connectors" = "Lappar, former och kopplingar" "Stop" = "Stoppa" "Stop following {name}" = "Sluta följa {name}" "Stop session" = "Avsluta session" "Stop sharing" = "Sluta dela" "Stop spotlighting" = "Sluta använda strålkastaren" "Stop {n} {hex}" = "Stopp {n} {hex}" +"Straight" = "Rak" "Stretch" = "Sträck" "Strikethrough" = "Genomstruken" "String" = "Sträng" @@ -1345,6 +1367,7 @@ name = "Svenska" "Text" = "Text" "Text align" = "Textjustering" "Text content" = "Textinnehåll" +"Text size" = "Textstorlek" "Text style" = "Textstil" "Text styles" = "Textstilar" "Texts" = "Texter" @@ -1378,6 +1401,7 @@ name = "Svenska" "This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Servern kräver sin nyckel: lägg till ?key=… i adressen (`figcraft-server key` skriver ut den på serverns dator)." "This server takes files only with its key: see Advanced." = "Servern tar bara emot filer med sin nyckel: se Avancerat." +"Thumbs up" = "Tummen upp" "Tidy up" = "Snygga till" "Tile" = "Sida vid sida" "Tile type" = "Upprepningstyp" @@ -1394,6 +1418,7 @@ name = "Svenska" "Top left radius" = "Radie uppe till vänster" "Top right radius" = "Radie uppe till höger" "Transform" = "Transformera" +"Triangle" = "Triangel" "Triangle arrow" = "Triangelpil" "Trigger" = "Utlösare" "True" = "Sant" @@ -1752,6 +1777,15 @@ name = "Svenska" "arrange.roundToPixels" = "Avrunda till pixlar" "arrange.spacing" = "Avstånd" "arrange.tidy" = "Snygga till" +"board.connector" = "Koppling" +"board.ink" = "Märkpennsdrag" +"board.label" = "Kopplingsetikett" +"board.nextSticky" = "Nästa lapp" +"board.setColor" = "Whiteboardfärg" +"board.setStyle" = "Whiteboardstil" +"board.shape" = "Form med text" +"board.stamp" = "Stämpel" +"board.sticky" = "Lapp" "bool.exclude" = "Uteslut markering" "bool.flatten" = "Platta till boolesk form" "bool.intersect" = "Skärning av markering" @@ -1878,6 +1912,7 @@ name = "Svenska" "file.import" = "Importera" "file.moveTab" = "Flytta flik" "file.new" = "Nytt" +"file.newBoard" = "Ny whiteboard" "file.newFromTemplate" = "Nytt från mall" "file.open" = "Öppna" "file.openRecent" = "Öppna senaste" @@ -2147,10 +2182,14 @@ name = "Svenska" "text.weightUp" = "Öka teckenvikten" "tool.arrow" = "Pil" "tool.comment" = "Kommentar" +"tool.connector" = "Koppling" "tool.ellipse" = "Ellips" +"tool.eraser" = "Suddgummi" "tool.frame" = "Ram" "tool.hand" = "Hand" +"tool.highlighter" = "Överstrykningspenna" "tool.line" = "Linje" +"tool.marker" = "Märkpenna" "tool.move" = "Flytta" "tool.pen" = "Penna" "tool.pencil" = "Blyertspenna" @@ -2162,9 +2201,12 @@ name = "Svenska" "tool.scaleAnchor" = "Skalningsankare" "tool.section" = "Avsnitt" "tool.set" = "Välj verktyg" +"tool.shape" = "Former" "tool.shapeBuilder" = "Formbyggare" "tool.slice" = "Skiva" +"tool.stamp" = "Stämpel" "tool.star" = "Stjärna" +"tool.sticky" = "Lapp" "tool.text" = "Text" "transform.move" = "Flytta" "transform.moveTo" = "Flytta till" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index bd8bb7c9..aa3207a5 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -124,6 +124,7 @@ name = "Türkçe" "Archived" = "Arşivlendi" "Arrange" = "Konumlandır" "Arrow" = "Ok" +"Arrow at the end" = "Sonda ok" "As typed" = "Yazıldığı gibi" "Asking the server…" = "Sunucuya soruluyor…" "Assets" = "Varlıklar" @@ -197,7 +198,9 @@ name = "Türkçe" "C or ⇧C" = "C veya ⇧C" "CMYK colour" = "CMYK rengi" "Can view" = "Görüntüleyebilir" +"Check" = "Onay" "Checking the server…" = "Sunucu denetleniyor…" +"Chevron" = "Şerit ok" "Clear from this list" = "Bu listeden kaldır" "Code or invite link" = "Kod veya davet bağlantısı" "Color profile: {name}" = "Renk profili: {name}" @@ -286,6 +289,7 @@ name = "Türkçe" "Connected" = "Bağlandı" "Connected to code: {from}" = "Koda bağlı: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Bağlantı koptu, yeniden bağlanılıyor (deneme {attempt})…" +"Connector" = "Bağlayıcı" "Constrain proportions" = "Oranları koru" "Constraints" = "Kısıtlamalar" "Content" = "İçerik" @@ -372,6 +376,7 @@ name = "Türkçe" "Description" = "Açıklama" "Description (optional)" = "Açıklama (isteğe bağlı)" "Design" = "Tasarım" +"Design panel" = "Tasarım paneli" "Design system starter" = "Tasarım sistemi başlangıç seti" "Desktop" = "Masaüstü" "Destination" = "Hedef" @@ -431,6 +436,7 @@ name = "Türkçe" "Effect settings" = "Efekt ayarları" "Effect styles" = "Efekt stilleri" "Effects" = "Efektler" +"Elbow" = "Dirsekli" "Ellipse" = "Elips" "Ellipses" = "Elipsler" "Else" = "Değilse" @@ -566,6 +572,7 @@ name = "Türkçe" "Hanging list" = "Asılı liste" "Hanging punctuation" = "Asılı noktalama" "Hard light" = "Sert ışık" +"Heart" = "Kalp" "Height" = "Yükseklik" "Help" = "Yardım" "Help and resources" = "Yardım ve kaynaklar" @@ -578,6 +585,7 @@ name = "Türkçe" "Hide these tips (Getting started in the Help menu shows them again)" = "Bu ipuçlarını gizle (Yardım menüsündeki Başlarken onları yeniden gösterir)" "Hide when publishing" = "Yayımlarken gizle" "Highlight layers on hover" = "Üzerine gelince katmanları vurgula" +"Highlighter" = "Fosforlu kalem" "Highlights" = "Açık tonlar" "Hold Alt" = "Alt tuşunu basılı tutun" "Hold Alt+Z" = "Alt+Z tuşlarını basılı tutun" @@ -743,6 +751,7 @@ name = "Türkçe" "Mark as resolved" = "Çözüldü olarak işaretle" "Mark as unread" = "Okunmadı olarak işaretle" "Mark as unresolved" = "Çözülmedi olarak işaretle" +"Marker" = "Keçeli kalem" "Mask" = "Maske" "Mass · Stiffness · Damping" = "Kütle · Sertlik · Sönüm" "Match (empty: the whole name)" = "Eşleşme (boş: adın tamamı)" @@ -801,6 +810,7 @@ name = "Türkçe" "Named instance" = "Adlandırılmış örnek" "Navigate to" = "Şuraya git" "Nested instance" = "İç içe örnek" +"New board" = "Yeni pano" "New design file" = "Yeni tasarım dosyası" "Newest first" = "Önce en yeni" "Next frame" = "Sonraki çerçeve" @@ -937,6 +947,7 @@ name = "Türkçe" "Page deleted" = "Sayfa silindi" "Pages" = "Sayfalar" "Pages added to the document" = "Sayfalar belgeye eklendi" +"Pink" = "Pembe" "Placed on the current page" = "Geçerli sayfaya yerleştirildi" "Pages: {pages}" = "Sayfalar: {pages}" "Paint bucket" = "Boya kovası" @@ -979,6 +990,7 @@ name = "Türkçe" "Plugins" = "Eklentiler" "Plus darker" = "Artı koyu" "Plus lighter" = "Artı açık" +"Plus one" = "Artı bir" "Point" = "Nokta" "Points" = "Noktalar" "Polygon" = "Çokgen" @@ -1027,6 +1039,7 @@ name = "Türkçe" "Push" = "İt" "Push changes to main component" = "Değişiklikleri ana bileşene gönder" "Put text on path" = "Metni yola yerleştir" +"Question" = "Soru" "R" = "R" "Radial" = "Dairesel" "Radius" = "Yarıçap" @@ -1126,6 +1139,7 @@ name = "Türkçe" "Rotation jitter" = "Döndürme titreşimi" "Round" = "Yuvarlak" "Rounded" = "Yuvarlatılmış" +"Rounded rectangle" = "Yuvarlatılmış dikdörtgen" "Row" = "Satır" "Row gap" = "Satır aralığı" "Row sizes" = "Satır boyutları" @@ -1198,6 +1212,7 @@ name = "Türkçe" "Shape" = "Şekil" "Shape builder" = "Şekil oluşturucu" "Shape tools" = "Şekil araçları" +"Shapes" = "Şekiller" "Share" = "Paylaş" "Share this file" = "Bu dosyayı paylaş" "Share with a code" = "Kodla paylaş" @@ -1264,10 +1279,13 @@ name = "Türkçe" "Space+drag" = "Boşluk+sürükle" "Spacing" = "Aralık" "Spam, stolen work, abuse…" = "İstenmeyen içerik, çalıntı iş, kötüye kullanım…" +"Speech bubble" = "Konuşma balonu" "Splay" = "Açılma" "Spotlight me" = "Beni öne çıkar" "Spread" = "Yayılma" "Square" = "Kare" +"Stamp" = "Damga" +"Stamps" = "Damgalar" "Standard" = "Standart" "Standard ligatures" = "Standart bitişik harfler" "Star" = "Yıldız" @@ -1283,12 +1301,16 @@ name = "Türkçe" "Starting to share…" = "Paylaşım başlatılıyor…" "Status" = "Durum" "Sticky" = "Yapışkan" +"Sticky note" = "Yapışkan not" +"Sticky note colors" = "Yapışkan not renkleri" +"Sticky notes, shapes and connectors" = "Yapışkan notlar, şekiller ve bağlayıcılar" "Stop" = "Durdur" "Stop following {name}" = "{name} takibini bırak" "Stop session" = "Oturumu durdur" "Stop sharing" = "Paylaşmayı durdur" "Stop spotlighting" = "Öne çıkarmayı durdur" "Stop {n} {hex}" = "Durak {n} {hex}" +"Straight" = "Düz" "Stretch" = "Uzat" "Strikethrough" = "Üstü çizili" "String" = "Metin" @@ -1333,6 +1355,7 @@ name = "Türkçe" "Text" = "Metin" "Text align" = "Metin hizalaması" "Text content" = "Metin içeriği" +"Text size" = "Metin boyutu" "Text style" = "Metin stili" "Text styles" = "Metin stilleri" "Texts" = "Metinler" @@ -1363,6 +1386,7 @@ name = "Türkçe" "This server is open to the public: no key needed." = "Bu sunucu herkese açık: anahtar gerekmez." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Bu sunucu anahtarını istiyor: adrese ?key=… ekleyin (`figcraft-server key` anahtarı sunucunun makinesinde yazdırır)." "This server takes files only with its key: see Advanced." = "Bu sunucu dosyaları yalnızca anahtarıyla kabul ediyor: Gelişmiş'e bakın." +"Thumbs up" = "Başparmak yukarı" "Tidy up" = "Toparla" "Tile" = "Döşe" "Tile type" = "Döşeme türü" @@ -1379,6 +1403,7 @@ name = "Türkçe" "Top left radius" = "Sol üst yarıçap" "Top right radius" = "Sağ üst yarıçap" "Transform" = "Dönüştür" +"Triangle" = "Üçgen" "Triangle arrow" = "Üçgen ok" "Trigger" = "Tetikleyici" "True" = "Doğru" @@ -1733,6 +1758,15 @@ name = "Türkçe" "arrange.roundToPixels" = "Piksellere yuvarla" "arrange.spacing" = "Aralık" "arrange.tidy" = "Toparla" +"board.connector" = "Bağlayıcı" +"board.ink" = "Keçeli kalem çizgisi" +"board.label" = "Bağlayıcı etiketi" +"board.nextSticky" = "Sonraki yapışkan not" +"board.setColor" = "Pano rengi" +"board.setStyle" = "Pano stili" +"board.shape" = "Metinli şekil" +"board.stamp" = "Damga" +"board.sticky" = "Yapışkan not" "bool.exclude" = "Seçimi hariç tut" "bool.flatten" = "Boolean'ı düzleştir" "bool.intersect" = "Seçimi kesiştir" @@ -1858,6 +1892,7 @@ name = "Türkçe" "file.import" = "İçe aktar" "file.moveTab" = "Sekmeyi taşı" "file.new" = "Yeni tasarım dosyası" +"file.newBoard" = "Yeni pano" "file.newFromTemplate" = "Şablondan yeni" "file.open" = "Aç" "file.openRecent" = "Son kullanılanı aç" @@ -2124,10 +2159,14 @@ name = "Türkçe" "text.weightUp" = "Yazı tipi kalınlığını artır" "tool.arrow" = "Ok" "tool.comment" = "Yorum" +"tool.connector" = "Bağlayıcı" "tool.ellipse" = "Elips" +"tool.eraser" = "Silgi" "tool.frame" = "Çerçeve" "tool.hand" = "El" +"tool.highlighter" = "Fosforlu kalem" "tool.line" = "Çizgi" +"tool.marker" = "Keçeli kalem" "tool.move" = "Taşı" "tool.pen" = "Kalem" "tool.pencil" = "Kurşun kalem" @@ -2139,9 +2178,12 @@ name = "Türkçe" "tool.scaleAnchor" = "Ölçekleme bağlantı noktası" "tool.section" = "Bölüm" "tool.set" = "Araç seç" +"tool.shape" = "Şekiller" "tool.shapeBuilder" = "Şekil oluşturucu" "tool.slice" = "Dilim" +"tool.stamp" = "Damga" "tool.star" = "Yıldız" +"tool.sticky" = "Yapışkan not" "tool.text" = "Metin" "transform.move" = "Taşı" "transform.moveTo" = "Şuraya taşı" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 1942bd74..3d56e7a3 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -119,6 +119,7 @@ status = "draft — needs native review" "Archived" = "已归档" "Arrange" = "排列" "Arrow" = "箭头" +"Arrow at the end" = "末端箭头" "As typed" = "原样" "Asking the server…" = "正在询问服务器…" "Assets" = "资源" @@ -190,7 +191,9 @@ status = "draft — needs native review" "C or ⇧C" = "C 或 ⇧C" "CMYK colour" = "CMYK 颜色" "Can view" = "可查看" +"Check" = "对勾" "Checking the server…" = "正在检查服务器…" +"Chevron" = "箭头形" "Clear from this list" = "从此列表中清除" "Code or invite link" = "代码或邀请链接" "Color profile: {name}" = "颜色配置文件:{name}" @@ -279,6 +282,7 @@ status = "draft — needs native review" "Connected" = "已连接" "Connected to code: {from}" = "已关联代码:{from}" "Connection lost, reconnecting (attempt {attempt})…" = "连接已断开,正在重新连接(第 {attempt} 次尝试)…" +"Connector" = "连接线" "Constrain proportions" = "锁定比例" "Constraints" = "约束" "Content" = "内容" @@ -362,6 +366,7 @@ status = "draft — needs native review" "Description" = "描述" "Description (optional)" = "描述(可选)" "Design" = "设计" +"Design panel" = "设计面板" "Desktop" = "桌面" "Destination" = "目标" "Detach alias" = "分离别名" @@ -419,6 +424,7 @@ status = "draft — needs native review" "Effect settings" = "效果设置" "Effect styles" = "效果样式" "Effects" = "效果" +"Elbow" = "折线" "Ellipse" = "椭圆" "Ellipses" = "椭圆" "Else" = "否则" @@ -549,6 +555,7 @@ status = "draft — needs native review" "Hanging list" = "悬挂列表" "Hanging punctuation" = "标点悬挂" "Hard light" = "强光" +"Heart" = "爱心" "Height" = "高度" "Help" = "帮助" "Help and resources" = "帮助和资源" @@ -559,6 +566,7 @@ status = "draft — needs native review" "Hide from publishing" = "发布时隐藏" "Hide section contents" = "隐藏分区内容" "Highlight layers on hover" = "悬停时高亮图层" +"Highlighter" = "荧光笔" "Highlights" = "高光" "Hold Alt" = "按住 Alt" "Hold Alt+Z" = "按住 Alt+Z" @@ -714,6 +722,7 @@ status = "draft — needs native review" "Mark as resolved" = "标为已解决" "Mark as unread" = "标为未读" "Mark as unresolved" = "标为未解决" +"Marker" = "马克笔" "Mask" = "蒙版" "Mass · Stiffness · Damping" = "质量 · 刚度 · 阻尼" "Match (empty: the whole name)" = "匹配(留空:整个名称)" @@ -770,6 +779,7 @@ status = "draft — needs native review" "Named instance" = "具名实例" "Navigate to" = "导航到" "Nested instance" = "嵌套实例" +"New board" = "新建白板" "New design file" = "新建设计文件" "Next frame" = "下一个画框" "Next or previous control in the focused region" = "聚焦区域中的下一个或上一个控件" @@ -900,6 +910,7 @@ status = "draft — needs native review" "Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" +"Pink" = "粉色" "Placed on the current page" = "已放置在当前页面" "Pages: {pages}" = "页面:{pages}" "Paint bucket" = "油漆桶" @@ -941,6 +952,7 @@ status = "draft — needs native review" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "插件在 FigCraft 的沙盒中运行,无法使用文件、网络或时钟。请只安装你信任的插件。" "Plus darker" = "加深" "Plus lighter" = "增亮" +"Plus one" = "加一" "Point" = "点" "Points" = "点数" "Polygon" = "多边形" @@ -987,6 +999,7 @@ status = "draft — needs native review" "Push" = "推入" "Push changes to main component" = "将更改推送到主组件" "Put text on path" = "将文本放到路径上" +"Question" = "问号" "R" = "R" "Radial" = "径向" "Radius" = "半径" @@ -1087,6 +1100,7 @@ status = "draft — needs native review" "Rotation jitter" = "旋转抖动" "Round" = "圆形" "Rounded" = "圆润" +"Rounded rectangle" = "圆角矩形" "Row" = "行" "Row gap" = "行间距" "Row sizes" = "行高" @@ -1156,6 +1170,7 @@ status = "draft — needs native review" "Shape" = "形状" "Shape builder" = "形状生成器" "Shape tools" = "形状工具" +"Shapes" = "形状" "Share" = "分享" "Share this file" = "分享此文件" "Share: live session, export" = "分享:实时会话、导出" @@ -1213,10 +1228,13 @@ status = "draft — needs native review" "Space+drag" = "空格+拖动" "Spacing" = "间距" "Spam, stolen work, abuse…" = "垃圾信息、盗用作品、滥用…" +"Speech bubble" = "对话气泡" "Splay" = "张开" "Spotlight me" = "聚焦于我" "Spread" = "扩展" "Square" = "方形" +"Stamp" = "印章" +"Stamps" = "印章" "Standard" = "标准" "Standard ligatures" = "标准连字" "Star" = "星形" @@ -1231,12 +1249,16 @@ status = "draft — needs native review" "Starting to share…" = "正在开始分享…" "Status" = "状态" "Sticky" = "粘性" +"Sticky note" = "便利贴" +"Sticky note colors" = "便利贴颜色" +"Sticky notes, shapes and connectors" = "便利贴、形状和连接线" "Stop" = "停止" "Stop following {name}" = "停止跟随 {name}" "Stop session" = "停止会话" "Stop sharing" = "停止分享" "Stop spotlighting" = "停止聚焦" "Stop {n} {hex}" = "色标 {n} {hex}" +"Straight" = "直线" "Stretch" = "拉伸" "Strikethrough" = "删除线" "String" = "字符串" @@ -1280,6 +1302,7 @@ status = "draft — needs native review" "Text" = "文本" "Text align" = "文本对齐" "Text content" = "文本内容" +"Text size" = "文字大小" "Text style" = "文本样式" "Text styles" = "文本样式" "Texts" = "文本" @@ -1310,6 +1333,7 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "此服务器对公众开放:无需密钥。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "此服务器需要密钥:请在地址后加上 ?key=…(在服务器所在的机器上运行 `figcraft-server key` 可显示)。" "This server takes files only with its key: see Advanced." = "此服务器只接受带密钥的文件:请查看“高级”。" +"Thumbs up" = "点赞" "This version can't be read." = "无法读取此版本。" "This version doesn't have it: all of it is new." = "此版本中没有它:全部是新的。" "Tidy up" = "整理" @@ -1328,6 +1352,7 @@ status = "draft — needs native review" "Top left radius" = "左上圆角" "Top right radius" = "右上圆角" "Transform" = "变换" +"Triangle" = "三角形" "Triangle arrow" = "三角箭头" "Trigger" = "触发器" "True" = "真" @@ -1677,6 +1702,15 @@ status = "draft — needs native review" "arrange.roundToPixels" = "取整到像素" "arrange.spacing" = "间距" "arrange.tidy" = "整理" +"board.connector" = "连接线" +"board.ink" = "马克笔笔画" +"board.label" = "连接线标签" +"board.nextSticky" = "下一张便利贴" +"board.setColor" = "白板颜色" +"board.setStyle" = "白板样式" +"board.shape" = "带文字的形状" +"board.stamp" = "印章" +"board.sticky" = "便利贴" "bool.exclude" = "排除所选项" "bool.flatten" = "拼合布尔组" "bool.intersect" = "所选项相交" @@ -1801,6 +1835,7 @@ status = "draft — needs native review" "file.import" = "导入" "file.moveTab" = "移动标签页" "file.new" = "新建设计文件" +"file.newBoard" = "新建白板" "file.open" = "打开" "file.openRecent" = "打开最近的文件" "file.placeImage" = "置入图片" @@ -2064,10 +2099,14 @@ status = "draft — needs native review" "text.weightUp" = "增大字重" "tool.arrow" = "箭头" "tool.comment" = "评论" +"tool.connector" = "连接线" "tool.ellipse" = "椭圆" +"tool.eraser" = "橡皮擦" "tool.frame" = "画框" "tool.hand" = "抓手" +"tool.highlighter" = "荧光笔" "tool.line" = "直线" +"tool.marker" = "马克笔" "tool.move" = "移动" "tool.pen" = "钢笔" "tool.pencil" = "铅笔" @@ -2079,9 +2118,12 @@ status = "draft — needs native review" "tool.scaleAnchor" = "缩放锚点" "tool.section" = "分区" "tool.set" = "选择工具" +"tool.shape" = "形状" "tool.shapeBuilder" = "形状生成器" "tool.slice" = "切片" +"tool.stamp" = "印章" "tool.star" = "星形" +"tool.sticky" = "便利贴" "tool.text" = "文本" "transform.move" = "移动" "transform.moveTo" = "移动到" diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs new file mode 100644 index 00000000..c312ddb9 --- /dev/null +++ b/crates/ui-egui/src/board_bar.rs @@ -0,0 +1,294 @@ +//! Boards in the UI: the small bar floating over a board's selection (colour, shape, text size, +//! connector style; boards hide the Design panel until asked for), and the swatches it and the +//! board tools' flyouts share. + +use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeKind, Routing, Stamp, StrokeCap}; +use figcraft_engine::board::{BoardTool, TEXT_SIZES}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::a11y; +use crate::i18n::{tr, tr_noop}; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, semibold}; +use crate::toolbar::Options; + +/// What a swatch shows. +pub(crate) enum Mark { + Color(figcraft_color::Color), + Shape(BoardShape), + Stamp(Stamp), +} + +/// One choice of a board tool's flyout: its name, the tool's params, whether it is the tool's now. +pub(crate) struct Choice { + pub label: &'static str, + pub params: Value, + pub on: bool, + pub mark: Mark, +} + +/// The choices a board tool's flyout offers. +pub(crate) fn choices(options: Options, board: &BoardTool) -> Vec { + match options { + Options::Colors => BOARD_COLORS + .iter() + .map(|(n, c)| Choice { label: color_label(n), params: json!({"color": n}), on: board.sticky == *c, mark: Mark::Color(*c) }) + .collect(), + Options::Shapes => BoardShape::ALL + .iter() + .map(|b| Choice { label: b.label(), params: json!({"shape": b.id()}), on: board.shape == *b, mark: Mark::Shape(*b) }) + .collect(), + Options::Stamps => Stamp::ALL + .iter() + .map(|b| Choice { label: b.label(), params: json!({"stamp": b.id()}), on: board.stamp == *b, mark: Mark::Stamp(*b) }) + .collect(), + Options::None => Vec::new(), + } +} + +/// A board colour's name as the UI says it. +fn color_label(name: &str) -> &'static str { + match name { + "yellow" => tr_noop("Yellow"), + "orange" => tr_noop("Orange"), + "red" => tr_noop("Red"), + "pink" => tr_noop("Pink"), + "violet" => tr_noop("Violet"), + "blue" => tr_noop("Blue"), + "green" => tr_noop("Green"), + _ => tr_noop("Gray"), + } +} + +/// Paint `mark` in `r`: a colour chip, a shape's outline in `ink`, or a stamp badge. +pub(crate) fn paint_choice(p: &egui::Painter, r: Rect, mark: &Mark, ink: Color32) { + match mark { + Mark::Color(c) => { + p.rect_filled(r, CornerRadius::same(4), c32(*c)); + p.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, Color32::from_black_alpha(40)), StrokeKind::Inside); + } + Mark::Shape(s) => { + let path = s.path(14.0, 14.0); + let path = figcraft_geom::Affine::translate((1.0, 1.0)) * path; + icons::paint_outlines(p, r, 16.0, &[(&path, false)], ink, 1.2); + } + Mark::Stamp(s) => { + p.circle_filled(r.center(), r.width().min(r.height()) / 2.0, c32(s.color())); + let parts: Vec<(figcraft_geom::BezPath, bool)> = + s.mark().iter().filter_map(|m| figcraft_geom::BezPath::from_svg(m.d).ok().map(|b| (b, m.filled))).collect(); + let refs: Vec<(&figcraft_geom::BezPath, bool)> = parts.iter().map(|(b, f)| (b, *f)).collect(); + icons::paint_outlines(p, r, figcraft_doc::STAMP_SIZE, &refs, Color32::WHITE, 3.0); + } + } +} + +fn c32(c: figcraft_color::Color) -> Color32 { + let [r, g, b, a] = c.to_rgba8(); + Color32::from_rgba_unmultiplied(r, g, b, a) +} + +/// What the selection has that the bar can change. +#[derive(Default)] +struct Has { + color: bool, + shape: Option, + text: bool, + connector: Option<(Routing, bool)>, +} + +/// What the selected board objects take from the bar (layers that aren't board objects, a section +/// say, are left out); `None` when none takes anything. +fn has(app: &FigApp) -> Option { + let ds = app.session.ds().ok()?; + let mut h = Has::default(); + for n in ds.selection.iter().filter_map(|id| ds.doc.node(*id)) { + match &n.board { + Some(BoardItem::Sticky { .. } | BoardItem::Ink) => h.color = true, + Some(BoardItem::Shape { shape }) => { + h.color = true; + h.text = true; + h.shape = Some(*shape); + } + Some(BoardItem::Connector(c)) => { + h.color = true; + h.connector = Some((c.routing, n.stroke.cap_end != StrokeCap::None)); + } + Some(BoardItem::Label { .. }) => h.text = true, + Some(BoardItem::Stamp { .. }) | None => {} + } + } + (h.color || h.text || h.connector.is_some()).then_some(h) +} + +const CELL: f32 = 28.0; +const SWATCH: f32 = 20.0; +const GAP: f32 = 4.0; +/// The room each part takes after its controls (the divider and the gap around it). +const PART_END: f32 = 8.0; + +impl Has { + /// The bar's width for these parts. + fn width(&self) -> f32 { + let mut w = 8.0; + if self.color { + w += BOARD_COLORS.len() as f32 * (SWATCH + GAP) + PART_END; + } + if self.shape.is_some() { + w += CELL + 12.0 + GAP + PART_END; + } + if self.text { + w += TEXT_SIZES.len() as f32 * (CELL + 2.0) + PART_END; + } + if self.connector.is_some() { + w += 3.0 * (CELL + 2.0) + PART_END; + } + w + } +} + +/// Where a bar `size` big goes for the selection's screen box `sel`: centred above it, below it +/// when there is no room above, inside the canvas. +pub(crate) fn place(sel: Rect, size: egui::Vec2, canvas: Rect) -> Pos2 { + let w = size.x; + let cx = sel.center().x.clamp(canvas.min.x + w / 2.0 + 8.0, (canvas.max.x - w / 2.0 - 8.0).max(canvas.min.x + w / 2.0 + 8.0)); + let above = sel.min.y - 14.0 - size.y; + let y = if above > canvas.min.y + 8.0 { above } else { (sel.max.y + 14.0).min(canvas.max.y - size.y - 80.0) }; + pos2(cx - w / 2.0, y) +} + +/// The bar above a board's selection, while nothing is being dragged and the file can be edited. +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + let editable = app.session.active_read_only().is_none(); + if app.session.tool.busy() || !editable || !app.session.doc().is_ok_and(figcraft_engine::board::is_board) { + return; + } + let Some(h) = has(app) else { return }; + let Some(b) = app.session.ds().ok().and_then(|ds| ds.doc.bounds_of(&ds.selection)) else { return }; + let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let size = vec2(h.width(), 40.0); + let at = place(sel, size, app.canvas.rect); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::new("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::click()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect.expand(2.0).translate(vec2(0.0, 1.0)), CornerRadius::same(12), Color32::from_black_alpha(18)); + p.rect_filled(rect, CornerRadius::same(10), t.panel); + p.rect_stroke(rect, CornerRadius::same(10), Stroke::new(1.0, t.border), StrokeKind::Inside); + let cy = rect.center().y; + let mut x = rect.min.x + 8.0; + if h.color { + x = color_part(app, ui, x, cy, &t); + } + if let Some(shape) = h.shape { + x = shape_part(app, ui, shape, pos2(x, cy), &t); + } + if h.text { + x = size_part(app, ui, x, cy, &t); + } + if let Some((routing, arrow)) = h.connector { + line_part(app, ui, (routing, arrow), pos2(x, cy), &t); + } + }); +} + +/// The board colours from `x` (`board.setColor`); where the next part starts. +fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { + for (i, (name, c)) in BOARD_COLORS.iter().enumerate() { + let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); + let resp = ui.interact(r, ui.id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); + a11y::button(&resp, tr(color_label(name))); + if resp.hovered() { + ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); + } + paint_choice(ui.painter(), r, &Mark::Color(*c), t.icon); + if resp.clicked() { + app.run("board.setColor", json!({"color": name})); + } + x += SWATCH + GAP; + } + divider(ui, t, x, cy) +} + +/// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts. +fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 { + let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL)); + let resp = ui.interact(r, ui.id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); + a11y::menu_button(&resp, tr("Shape"), a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + paint_choice(ui.painter(), Rect::from_center_size(pos2(r.min.x + CELL / 2.0, at.y), vec2(18.0, 18.0)), &Mark::Shape(shape), t.icon); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, at.y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + crate::right::menu_popup(&resp).show(|ui| { + for b in BoardShape::ALL { + let art = |p: &egui::Painter, row: Rect, ink: Color32| { + paint_choice(p, Rect::from_center_size(pos2(row.max.x - 16.0, row.center().y), vec2(16.0, 16.0)), &Mark::Shape(b), ink) + }; + if crate::right::menu_item_with(ui, tr(b.label()), "", 24.0, art) { + app.run("board.setStyle", json!({"shape": b.id()})); + ui.close(); + } + } + }); + divider(ui, t, at.x + CELL + 12.0 + GAP, at.y) +} + +/// The text sizes from `x` (`board.setStyle {fontSize}`); where the next part starts. +fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { + for (label, size) in TEXT_SIZES { + let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL)); + let resp = ui.interact(r, ui.id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); + a11y::button(&resp, &format!("{} {label}", tr("Text size"))); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, semibold(11.0), t.text); + if resp.clicked() { + app.run("board.setStyle", json!({"fontSize": size})); + } + x += CELL + 2.0; + } + divider(ui, t, x, cy) +} + +/// A connector's routing (straight, elbow) and end arrow at `at`, as they are now (`line`). +fn line_part(app: &mut FigApp, ui: &mut Ui, line: (Routing, bool), at: Pos2, t: &Tokens) { + let (routing, arrow) = line; + let cells = [ + (Icon::Line, tr("Straight"), routing == Routing::Straight, json!({"style": "straight"})), + (Icon::Connector, tr("Elbow"), routing == Routing::Elbow, json!({"style": "elbow"})), + (Icon::Arrow, tr("Arrow at the end"), arrow, json!({"endCap": if arrow { "none" } else { "triangleArrow" }})), + ]; + let mut x = at.x; + for (i, (icon, tip, on, params)) in cells.into_iter().enumerate() { + let r = Rect::from_center_size(pos2(x + CELL / 2.0, at.y), vec2(CELL, CELL)); + if crate::toolbar::tool_cell(ui, t, r, ("bb_line", i), icon, on, tip).clicked() { + app.run("board.setStyle", params); + } + x += CELL + 2.0; + } +} + +/// A thin separator after `x`; where the next part starts. +fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { + ui.painter().line_segment([pos2(x + 2.0, cy - 10.0), pos2(x + 2.0, cy + 10.0)], Stroke::new(1.0, t.border)); + x + 8.0 +} + +/// The name of the person who placed the stamp under the pointer, beside it. +pub fn stamp_author(app: &FigApp, painter: &egui::Painter, pointer: Option) { + let (Some(at), Ok(ds)) = (pointer, app.session.ds()) else { return }; + let Some(n) = ds.hover.and_then(|h| ds.doc.node(h)) else { return }; + let Some(BoardItem::Stamp { author, .. }) = &n.board else { return }; + if author.is_empty() || !matches!(n.kind, NodeKind::Frame) { + return; + } + let t = Tokens::get(painter.ctx()); + let galley = painter.layout_no_wrap(author.clone(), semibold(11.0), t.text); + let r = Rect::from_min_size(at + vec2(14.0, 14.0), galley.size() + vec2(12.0, 6.0)); + painter.rect_filled(r, CornerRadius::same(4), t.menu); + painter.galley(r.min + vec2(6.0, 3.0), galley, t.menu_text); +} diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs new file mode 100644 index 00000000..4ffb3cf4 --- /dev/null +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -0,0 +1,212 @@ +//! Boards in the UI, drawn headless: the board toolbar (its flyouts pick a tool with a colour, a +//! shape or a stamp; the Design panel's button), the right panel hidden until asked for, the bar +//! over the selection (colours, shapes, text sizes, connector style, also with layers that aren't +//! board objects selected, never on a file you can only view), the home screen's New board card, +//! and the marks design files and boards get. + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2}; +use figcraft_doc::{BoardItem, BoardShape, FileKind, NodeId, Routing, Stamp, StrokeCap, board_color}; +use figcraft_engine::Session; +use figcraft_engine::tools::ToolId; +use serde_json::{Value, json}; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; + +fn click(u: &mut Ui, p: Pos2) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// The app on a new board. +fn board() -> Ui { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + u +} + +/// The board toolbar pill on screen. +fn pill(u: &Ui) -> Rect { + crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app)) +} + +/// The left edge of board toolbar group `i` (button 32 wide, a 10-wide caret after the groups with +/// flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp, comment. +fn group_x(u: &Ui, i: usize) -> f32 { + const WIDTHS: [f32; 9] = [46.0, 46.0, 46.0, 46.0, 36.0, 36.0, 36.0, 46.0, 36.0]; + pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::() +} + +/// Open group `i`'s flyout from its caret and click option `n` in it (a row of 32-wide cells, 4 +/// apart, 8 in from the flyout's edge, the flyout's bottom 8 above the pill). +fn pick_option(u: &mut Ui, i: usize, n: usize) { + let caret = pos2(group_x(u, i) + 37.0, pill(u).center().y); + click(u, caret); + let at = pos2(group_x(u, i) + 8.0 + n as f32 * 36.0 + 16.0, pill(u).min.y - 8.0 - 24.0); + click(u, at); +} + +/// Whether the bar over the selection showed in the last frame. +fn bar_shown(ctx: &egui::Context) -> bool { + ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::new("board_bar")))) +} + +/// The bar over the selection, when it shows. +fn bar(u: &Ui) -> Option { + bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::new("board_bar")))).flatten() +} + +#[test] +fn the_board_toolbar_picks_tools_with_their_colours_shapes_and_stamps() { + let mut u = board(); + pick_option(&mut u, 2, 5); + assert_eq!(u.app.session.tool.tool, ToolId::Sticky); + assert_eq!(Some(u.app.session.tool.board.sticky), board_color("blue")); + pick_option(&mut u, 3, 3); + assert_eq!((u.app.session.tool.tool, u.app.session.tool.board.shape), (ToolId::BoardShape, BoardShape::Diamond)); + pick_option(&mut u, 7, 1); + assert_eq!((u.app.session.tool.tool, u.app.session.tool.board.stamp), (ToolId::Stamp, Stamp::Heart)); + // The connector's button picks it; the ink group's flyout lists the marker's siblings. + let connector = pos2(group_x(&u, 4) + 16.0, pill(&u).center().y); + click(&mut u, connector); + assert_eq!(u.app.session.tool.tool, ToolId::Connector); + let ink_caret = pos2(group_x(&u, 1) + 37.0, pill(&u).center().y); + click(&mut u, ink_caret); + assert_eq!(u.app.ui.tool_flyout, Some("ink")); +} + +#[test] +fn a_boards_design_panel_opens_when_asked_for_and_prototyping_is_not_offered() { + let mut u = board(); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.frame(vec![]); + assert_eq!(u.app.ui.right_tab, crate::RightTab::Design, "boards have no Prototype tab"); + let canvas = u.app.canvas.rect; + assert!(canvas.max.x > 1400.0, "no right panel: the canvas reaches the window's edge ({canvas:?})"); + // The Design panel's button at the pill's right end. + let p = pill(&u); + click(&mut u, pos2(p.max.x - 8.0 - 16.0, p.center().y)); + assert!(u.app.ui.board_panel); + u.frame(vec![]); + assert!(u.app.canvas.rect.max.x < canvas.max.x - 200.0, "the panel takes its room"); + // On a design file the panel shows whatever was asked for a board. + u.app.run("file.new", json!({})); + u.app.run("view.design", json!({})); + u.frame(vec![]); + assert!(u.app.ui.board_panel, "view.design on a design file doesn't toggle the board's panel"); +} + +#[test] +fn the_selection_bar_recolours_reshapes_and_restyles() { + let mut u = board(); + let shape = id(&u.app.session.execute("board.shape", &json!({"x": 0, "y": 0, "shape": "rectangle", "text": "Hi"})).unwrap()); + let section = id(&u.app.session.execute("node.create", &json!({"type": "section", "x": 0, "y": 400, "width": 200, "height": 200})).unwrap()); + // A section selected with it (no board object) leaves the bar for the shape. + u.app.session.execute("select.set", &json!({"ids": [shape, section]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let r = bar(&u).expect("the bar shows"); + let cy = r.center().y; + // Swatch 5 (blue): 8 in, 24 apart, 20 wide. + click(&mut u, pos2(r.min.x + 8.0 + 5.0 * 24.0 + 10.0, cy)); + let outline = u.app.session.doc().unwrap().node(shape).unwrap().children[0]; + assert_eq!(u.app.session.doc().unwrap().node(outline).unwrap().fills[0].solid_color(), board_color("blue")); + // Text size L: after the colours (208) and the shape menu (52), the third of the 30-wide cells. + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 208.0 + 52.0 + 2.0 * 30.0 + 14.0, cy)); + let text = figcraft_engine::board::text_of(u.app.session.doc().unwrap(), shape).unwrap(); + assert_eq!(u.app.session.doc().unwrap().node(text).unwrap().kind.text().unwrap().style.font_size, 24.0); + // The shape menu lists the outlines. + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 208.0 + 20.0, cy)); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Ellipse")); + assert_eq!(u.app.session.doc().unwrap().node(shape).unwrap().board, Some(BoardItem::Shape { shape: BoardShape::Ellipse })); +} + +#[test] +fn the_selection_bar_sets_a_connectors_style_and_offers_nothing_for_a_stamp() { + let mut u = board(); + let a = u.app.session.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = u.app.session.execute("board.sticky", &json!({"x": 400, "y": 300})).unwrap()["id"].clone(); + let c = id(&u.app.session.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap()); + u.frame(vec![]); + u.frame(vec![]); + let r = bar(&u).expect("the bar shows for a connector"); + let cy = r.center().y; + // After the colours (208): Straight, Elbow, the end's arrow, 30 apart. + click(&mut u, pos2(r.min.x + 208.0 + 14.0, cy)); + let routing = |u: &Ui| match &u.app.session.doc().unwrap().node(c).unwrap().board { + Some(BoardItem::Connector(k)) => k.routing, + _ => panic!("not a connector"), + }; + assert_eq!(routing(&u), Routing::Straight); + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 208.0 + 60.0 + 14.0, cy)); + assert_eq!(u.app.session.doc().unwrap().node(c).unwrap().stroke.cap_end, StrokeCap::None); + // Nothing is offered while a stamp alone is selected: it takes neither colour nor style. + let st = id(&u.app.session.execute("board.stamp", &json!({"x": 900, "y": 0, "stamp": "star"})).unwrap()); + u.app.session.execute("select.set", &json!({"ids": [st]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + assert!(bar(&u).is_none()); +} + +#[test] +fn a_board_you_only_view_offers_no_bar() { + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.session.execute("file.newBoard", &json!({})).unwrap(); + let st = id(&h.app.session.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()); + h.frames(2); + assert!(bar_shown(&h.ctx), "the bar shows for the selected sticky"); + crate::live_server_tests::joined(&mut h, figcraft_engine::live::Access::View); + h.app.session.execute("select.set", &json!({"ids": [st]})).unwrap(); + h.frames(2); + assert!(h.app.session.active_read_only().is_some()); + assert!(!bar_shown(&h.ctx), "view only: nothing to edit"); +} + +#[test] +fn the_home_screen_makes_boards_and_marks_files_by_kind() { + let mut u = Ui::new(Session::empty()); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "New board")); + assert_eq!(u.app.session.doc().unwrap().kind, FileKind::Board); + assert_eq!(crate::icons::file_mark(FileKind::Board), crate::icons::Icon::Board); + assert_eq!(crate::icons::file_mark(FileKind::Design), crate::icons::Icon::PenNib); +} + +#[test] +fn a_stamp_shows_who_placed_it_on_hover() { + let mut s = Session::new(); + s.prefs.user_name = "Grace".into(); + s.execute("file.newBoard", &json!({})).unwrap(); + s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "check"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let at = u.app.to_screen(100.0, 100.0); + u.frame(vec![Event::PointerMoved(at)]); + u.frame(vec![Event::PointerMoved(at + egui::vec2(1.0, 0.0))]); + let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at + egui::vec2(1.0, 1.0))]).shapes); + assert!(has_text(&shapes, "Grace"), "the placer's name beside the pointer"); +} + +#[test] +fn the_next_sticky_from_the_keyboard_goes_on_typing() { + let mut u = board(); + u.app.run("board.sticky", json!({"x": 0, "y": 0})); + // Shift+Cmd/Ctrl+Enter runs it from the menu layer, asking to type in the new one. + crate::menus::invoke(&mut u.app, "board.nextSticky"); + let doc = u.app.session.doc().unwrap(); + let te = u.app.session.ds().unwrap().text_edit.clone().expect("typing in the new sticky"); + let owner = figcraft_engine::board::owner_of_text(doc, te.id).unwrap(); + assert_eq!(doc.node(owner).unwrap().transform.translation().x, 224.0); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b36af79..0b13f5a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -256,6 +256,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } crate::dev_mode::draw(app, &painter); crate::handles::draw(app, &painter); + crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); + crate::board_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); @@ -939,7 +941,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } } // The focus view's bar names its frame. - let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })); + // Board objects (stickies, shapes with text, stamps) are frames without a name label. + let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) && !n.is_board_atom(); if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index b532d9a7..326a3b14 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -321,13 +321,14 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Recents")); - let (mut new_file, mut open) = (false, false); - ui.horizontal(|ui| { - new_file = action_card(ui, t, Icon::Plus, tr("New design file"), tr("Start from a blank canvas")); + let (mut new_file, mut new_board, mut open) = (false, false, false); + ui.horizontal_wrapped(|ui| { + new_file = action_card(ui, t, Icon::PenNib, tr("New design file"), tr("Start from a blank canvas")); + new_board = action_card(ui, t, Icon::Board, tr("New board"), tr("Sticky notes, shapes and connectors")); open = action_card(ui, t, Icon::Page, tr("Open file"), tr("Open .figcraft, .svg or images")); }); - if new_file { - app.run("file.new", json!({})); + if new_file || new_board { + app.run(if new_board { "file.newBoard" } else { "file.new" }, json!({})); app.ui.home = false; } if open { @@ -376,8 +377,14 @@ fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { let mut switch = None; for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) { let detail = d.path.as_deref().unwrap_or_default(); - let row = - FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; + let row = FileRow { + mark: Some((icons::file_mark(d.doc.kind), ACCENT)), + name: &d.doc.name, + ink: t.text, + detail, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { switch = Some(i); } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index ca50d78f..2d39c6fe 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -252,6 +252,8 @@ pub struct Shown { pub name: String, /// `None` until the file has been looked at. pub stat: Option, + /// A design file or a board (design until its picture has been read). + pub kind: figcraft_doc::FileKind, } impl Shown { @@ -259,7 +261,7 @@ impl Shown { // Shown without its extension, like a document's name; the full path is the tooltip. let p = Path::new(path); let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.to_string(), |f| f.to_string_lossy().into_owned()); - Shown { path: path.to_string(), name, stat } + Shown { path: path.to_string(), name, stat, kind: figcraft_doc::FileKind::Design } } fn date(&self, key: SortKey) -> Option { @@ -311,6 +313,7 @@ struct Ready { modified: Option, page: Option, texture: Option, + kind: figcraft_doc::FileKind, /// When it came in (for its fade). arrived: f64, } @@ -479,7 +482,7 @@ impl Recents { let page = preview.page.map(|[r, g, b, a]| crate::theme::c32(figcraft_color::Color { r, g, b, a })); // A changed file's new picture shows at once; a first one fades in. let arrived = if k.shown.is_some() { f64::NEG_INFINITY } else { time }; - k.shown = Some(Ready { modified, page, texture, arrived }); + k.shown = Some(Ready { modified, page, texture, kind: preview.kind, arrived }); k.wanted = None; } } @@ -519,7 +522,13 @@ impl Recents { /// The files as listed, with what is known about them. fn shown(&self, paths: &[String]) -> Vec { - paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() + paths + .iter() + .map(|p| { + let k = self.files.get(p); + Shown { kind: k.and_then(|k| k.shown.as_ref()).map_or_else(Default::default, |r| r.kind), ..Shown::new(p, k.and_then(|k| k.stat)) } + }) + .collect() } /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. @@ -751,13 +760,13 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: } } -/// The square file-type mark before a name. -fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { +/// The square file-type mark before a name (a design file's pen nib, a board's note). +fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, file: &Shown) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - match FileKind::of(Path::new(path)) { + match FileKind::of(Path::new(&file.path)) { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), t.accent); - icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on); + icons::paint(ui.painter(), r.shrink(3.0), icons::file_mark(file.kind), t.accent_on); } kind => { ui.painter().rect_filled(r, CornerRadius::same(4), t.field); @@ -867,7 +876,7 @@ fn card( } p.line_segment([pos2(rect.min.x, area.max.y + 0.5), pos2(rect.max.x, area.max.y + 0.5)], Stroke::new(1.0, t.border)); let y = area.max.y + 1.0 + FOOTER_H / 2.0; - file_icon(ui, t, pos2(rect.min.x + 24.0, y), &file.path); + file_icon(ui, t, pos2(rect.min.x + 24.0, y), file); let (x, w) = (rect.min.x + 44.0, rect.width() - 56.0); let name = widgets::elide_font(ui, &file.name, w, semibold(12.5)); p.text(pos2(x, y - 8.0), Align2::LEFT_CENTER, name, semibold(12.5), t.text); @@ -935,6 +944,8 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 if !ui.is_rect_visible(r) { continue; } + // The row's mark says what kind of file it is: read it like a card's picture. + recents.picture(&file.path, true, ui.input(|i| i.time)); let selected = recents.selected.as_deref() == Some(file.path.as_str()); let p = ui.painter(); if selected { @@ -945,7 +956,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 if resp.has_focus() && !selected { p.rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, t.focus), StrokeKind::Inside); } - file_icon(ui, t, pos2(r.min.x + 20.0, r.center().y), &file.path); + file_icon(ui, t, pos2(r.min.x + 20.0, r.center().y), file); let name_w = date_x(1) - 52.0; p.text( pos2(r.min.x + 40.0, r.center().y), diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index e49866ab..d01e5222 100644 --- a/crates/ui-egui/src/home_recents_tests.rs +++ b/crates/ui-egui/src/home_recents_tests.rs @@ -74,7 +74,7 @@ fn columns_follow_the_width() { fn shown(path: &str, modified: Option, created: Option) -> Shown { let stat = Some(Stat { exists: modified.is_some(), modified, created }); - Shown { path: path.into(), name: Path::new(path).file_stem().unwrap().to_string_lossy().into(), stat } + Shown { path: path.into(), name: Path::new(path).file_stem().unwrap().to_string_lossy().into(), stat, kind: Default::default() } } #[test] diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 3921cb1b..fa7da946 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -130,6 +130,9 @@ fn dynamic_labels() -> Vec<&'static str> { use figcraft_engine::keymap::{GESTURES, Group}; let mut v: Vec<&'static str> = figcraft_engine::ToolId::ALL.iter().map(|t| t.label()).collect(); v.extend(figcraft_engine::vectoredit::VTool::ALL.iter().map(|t| t.label())); + // Board shapes and stamps (the board tools' flyouts and the contextual bar). + v.extend(figcraft_doc::BoardShape::ALL.iter().map(|b| b.label())); + v.extend(figcraft_doc::Stamp::ALL.iter().map(|b| b.label())); v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 5eb7d209..c7b10388 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -193,6 +193,17 @@ icons! { LiveShared, /// A file of yours on a server that only you get into: a point inside a closed ring. LivePrivate, + /// A design file: a pen nib leaning to the left, its grip filled. + PenNib, + /// A board file (and the sticky note tool): a note with its bottom corner folded up. + Board, + /// Board tools: a chisel-tipped marker; a broad highlighter over its band; a connector + /// between two boxes; shapes (a square behind a circle); a stamp (a badge with a check). + Marker, + Highlighter, + Connector, + Shapes, + Stamp, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -1118,6 +1129,49 @@ fn draw(p: &P, icon: Icon) { p.line(8.0, 6.0, 8.0, 9.5); p.dot(8.0, 11.5, 0.9); } + PenNib => { + // Drawn pointing down, then leant 35° to the left about the middle. + let (sin, cos) = (0.61f32, 0.79f32); + let at = |x: f32, y: f32| (8.0 + x * cos - y * sin, 8.0 + x * sin + y * cos); + let nib: Vec<(f32, f32)> = [(0.0, 6.5), (3.4, 1.0), (2.4, -2.5), (-2.4, -2.5), (-3.4, 1.0)].iter().map(|&(x, y)| at(x, y)).collect(); + p.path(&nib, true); + let (a, b) = (at(0.0, 6.5), at(0.0, 2.0)); + p.path(&[a, b], false); + let (dx, dy) = at(0.0, 0.6); + p.dot(dx, dy, 1.0); + let grip: Vec<(f32, f32)> = [(-2.4, -3.4), (2.4, -3.4), (2.4, -5.6), (-2.4, -5.6)].iter().map(|&(x, y)| at(x, y)).collect(); + p.fill(&grip); + } + Board => { + p.poly(&[(2.5, 2.5), (13.5, 2.5), (13.5, 9.5), (9.5, 13.5), (2.5, 13.5)], true); + p.fill(&[(9.5, 13.5), (9.5, 9.5), (13.5, 9.5)]); + p.line(5.0, 6.5, 11.0, 6.5); + p.line(5.0, 9.5, 8.0, 9.5); + } + Marker => { + p.poly(&[(3.0, 13.0), (3.0, 10.5), (10.0, 3.5), (12.5, 6.0), (5.5, 13.0)], true); + p.line(8.5, 5.0, 11.0, 7.5); + p.fill(&[(2.0, 14.0), (2.0, 11.5), (4.5, 14.0)]); + } + Highlighter => { + p.poly(&[(4.0, 10.0), (10.5, 3.5), (13.5, 6.5), (7.0, 13.0)], true); + p.poly(&[(4.0, 10.0), (2.5, 12.5), (4.5, 12.5), (7.0, 13.0)], false); + p.fill_rect(1.0, 14.0, 15.0, 15.5, 0.0); + } + Connector => { + p.rect(1.5, 1.5, 6.5, 6.5, 1.0); + p.rect(9.5, 9.5, 14.5, 14.5, 1.0); + p.poly(&[(6.5, 4.0), (12.0, 4.0), (12.0, 9.0)], false); + p.head((12.0, 9.0), FRAC_PI_2, 2.4); + } + Shapes => { + p.rect(1.5, 5.5, 9.5, 13.5, 1.0); + p.circle(10.5, 5.5, 4.0); + } + Stamp => { + p.circle(8.0, 8.0, 6.0); + p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); + } Logo => { // FigCraft's own mark: the curled tail of the chameleon on the app icon. One bold // stroke: a shaft along the bottom, then three half turns, each tighter than the last. @@ -1162,6 +1216,46 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, active: bool, tooltip: & resp } +/// The mark of a file of `kind` on tabs and file rows: a pen nib for design files, a sticky note +/// for boards. +pub fn file_mark(kind: figcraft_doc::FileKind) -> Icon { + match kind { + figcraft_doc::FileKind::Design => Icon::PenNib, + figcraft_doc::FileKind::Board => Icon::Board, + } +} + +/// SVG path data outlines on a `size`-unit box, painted into `rect`: each part filled, or +/// stroked `width` box units wide. Board shapes and stamp marks are drawn this way in the UI from +/// the same outlines the canvas has. +pub fn paint_outlines(painter: &Painter, rect: Rect, size: f64, parts: &[(&figcraft_geom::BezPath, bool)], color: Color32, width: f32) { + let s = rect.width().min(rect.height()) / size as f32; + let px = 1.0 / painter.ctx().pixels_per_point(); + let o = rect.center() - Vec2::splat(size as f32 * s / 2.0); + let p = P { painter, o, s, px, stroke: Stroke::new((width * s).max(px), color), color }; + for (path, filled) in parts { + let mut sub: Vec<(f32, f32)> = Vec::new(); + let flush = |sub: &mut Vec<(f32, f32)>, closed: bool| { + if sub.len() >= 2 { + if *filled { p.fill(sub) } else { p.path(sub, closed) } + } + sub.clear(); + }; + let mut closed = false; + figcraft_geom::kurbo::flatten(path.iter(), 0.25, |el| match el { + figcraft_geom::kurbo::PathEl::MoveTo(q) => { + flush(&mut sub, closed); + closed = false; + sub.push((q.x as f32, q.y as f32)); + } + figcraft_geom::kurbo::PathEl::LineTo(q) => sub.push((q.x as f32, q.y as f32)), + figcraft_geom::kurbo::PathEl::ClosePath => closed = true, + _ => {} + }); + flush(&mut sub, closed); + } +} + /// Every icon at the sizes the UI uses, labelled: for reviewing the set (`ui.set {iconGallery}`). pub fn gallery(ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b123f6e5..11fc307a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod a11y; pub mod assets_panel; pub mod bidi; +pub mod board_bar; pub mod branch_ui; pub mod canvas; pub mod cjk; @@ -69,6 +70,8 @@ pub mod versions; pub mod widgets; pub mod windows; +#[cfg(test)] +mod board_ui_tests; #[cfg(test)] mod theme_startup_tests; @@ -471,15 +474,16 @@ pub const PANEL_DEFAULT: f32 = 240.0; pub const CANVAS_MIN: f32 = 160.0; /// The widths the side panels are drawn at in `avail` points (the window less the icon rail), from -/// the widths asked for (`left`: none while it is closed): each within its limits, and narrower -/// in a small window so the canvas keeps [`CANVAS_MIN`] (the right panel gives way last). -pub fn fit_panels(avail: f32, left: Option, right: f32) -> (f32, f32) { +/// the widths asked for (none while a panel is closed, which takes no room): each within its +/// limits, and narrower in a small window so the canvas keeps [`CANVAS_MIN`] (the right panel +/// gives way last). +pub fn fit_panels(avail: f32, left: Option, right: Option) -> (f32, f32) { let fit = |w: f32, range: &std::ops::RangeInclusive, room: f32| { let w = if w.is_finite() { w } else { PANEL_DEFAULT }; w.clamp(*range.start(), room.clamp(*range.start(), *range.end())) }; let left_min = left.map_or(0.0, |_| *LEFT_PANEL.start()); - let right = fit(right, &RIGHT_PANEL, avail - left_min - CANVAS_MIN); + let right = right.map_or(0.0, |r| fit(r, &RIGHT_PANEL, avail - left_min - CANVAS_MIN)); (left.map_or(0.0, |l| fit(l, &LEFT_PANEL, avail - right - CANVAS_MIN)), right) } @@ -579,6 +583,8 @@ pub struct UiState { pub dark: bool, pub follow_system: bool, pub tool_flyout: Option<&'static str>, + /// On a board: the Design panel is shown (boards hide it until asked for). + pub board_panel: bool, pub last_error: Option, /// Files dropped on the home screen or the canvas, opened once their card has been on screen /// ([`pending_open::pending_opens`]), and the frames it has been drawn. @@ -718,6 +724,7 @@ impl Default for UiState { dark: false, follow_system: false, tool_flyout: None, + board_panel: false, last_error: None, pending_opens: Vec::new(), pending_open_frames: 0, @@ -999,7 +1006,15 @@ impl FigApp { "dialog.missingFonts" => missing_fonts::request(self), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), + "panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel, "panel.design" => self.ui.right_tab = RightTab::Design, + "board.editText" => { + if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) + && let Err(e) = figcraft_engine::board::edit_text(&mut self.session, id) + { + self.toast(e.to_string()); + } + } "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype }; @@ -1568,6 +1583,16 @@ impl FigApp { } } + /// Whether the right panel shows: never with the UI `hidden`; always on a design file; on a + /// board, which has no prototypes or Dev Mode (its tab stays Design), only when asked for. + fn right_panel_shown(&mut self, hidden: bool) -> bool { + let board = self.session.doc().is_ok_and(figcraft_engine::board::is_board); + if board { + self.ui.right_tab = RightTab::Design; + } + !hidden && (!board || self.ui.board_panel) + } + /// One frame of the window drawing now. pub(crate) fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1619,6 +1644,7 @@ impl FigApp { return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); + let right_open = self.right_panel_shown(hidden); if !hidden { egui::Panel::left("left_rail") .exact_size(56.0) @@ -1631,7 +1657,7 @@ impl FigApp { } // The panels are as wide as asked, less in a window too small for them and the canvas. let left_open = !hidden && !self.ui.left_collapsed; - let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), self.ui.right_width); + let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), right_open.then_some(self.ui.right_width)); let rows = ui.available_rect_before_wrap().y_range(); let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new(); if left_open { @@ -1650,7 +1676,7 @@ impl FigApp { layers::left_panel(self, ui) }); } - if !hidden { + if right_open { edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0)); egui::Panel::right("right_panel") .resizable(false) diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 9c2cb22c..4a92edf0 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -25,7 +25,7 @@ fn focused(h: &Harness, id: &str) -> bool { } /// Joined through a server's link with `access`: the welcome and the document (a new tab). -fn joined(h: &mut Harness, access: Access) { +pub(crate) fn joined(h: &mut Harness, access: Access) { h.app.session.execute("collab.join", &json!({"link": "figcraft://join?host=127.0.0.1:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv"})).unwrap(); let mut server = Server::new(h.app.session.doc().unwrap().clone(), "", Limits::default()); let ClientMsg::Hello(me) = Client::hello("f", "", "Ada", "") else { panic!("no hello") }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index db0c0458..c48fa401 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -163,6 +163,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.run(id, json!({})); } "file.open" => app.open_dialog(), + // From the keyboard or a menu, typing goes on in the new sticky. + "board.nextSticky" => { + app.run(id, json!({"edit": true})); + } "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), "file.placeImage" => app.import_dialog(true), @@ -1062,7 +1066,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // Same centre and width as the toolbar pill (it centres on the canvas, not the window), // floating 8 px above it. Fixed size: switching tabs never resizes the panel. - let bar_w = crate::toolbar::main_width(); + let bar_w = crate::toolbar::bar_width(app); let bar = crate::toolbar::pill_rect(app.canvas.rect, bar_w); let area = Rect::from_min_max(pos2(bar.min.x, bar.min.y - 8.0 - (PALETTE_H + 16.0)), pos2(bar.max.x, bar.min.y - 8.0)); let mut run: Option = None; diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index a5a84d04..882a97a7 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -67,16 +67,17 @@ fn panels_fit_the_window() { let (l0, l1, r0, r1) = (*LEFT_PANEL.start(), *LEFT_PANEL.end(), *RIGHT_PANEL.start(), *RIGHT_PANEL.end()); // (room beside the icon rail, left asked (none: closed), right asked) → (left, right) drawn let rows = [ - ((1384.0, Some(240.0), 240.0), (240.0, 240.0), "as asked in a wide window"), - ((1384.0, Some(100.0), 100.0), (l0, r0), "never narrower than their least"), - ((1384.0, Some(900.0), 900.0), (l1, r1), "never wider than their most"), - ((1384.0, None, 300.0), (0.0, 300.0), "the left panel closed"), - ((744.0, Some(240.0), 240.0), (240.0, 240.0), "the smallest window with the default widths"), - ((744.0, Some(480.0), 480.0), (l0, 744.0 - l0 - CANVAS_MIN), "the smallest window: the left panel gives way first, then the right"), - ((744.0, Some(480.0), 240.0), (744.0 - 240.0 - CANVAS_MIN, 240.0), "the left panel gives the canvas its room"), - ((744.0, None, 480.0), (0.0, 480.0), "with the left panel closed the right one has room"), - ((300.0, Some(240.0), 240.0), (l0, r0), "a window smaller than any layout: the least widths"), - ((1384.0, Some(f32::NAN), f32::INFINITY), (PANEL_DEFAULT, PANEL_DEFAULT), "junk widths: the default"), + ((1384.0, Some(240.0), Some(240.0)), (240.0, 240.0), "as asked in a wide window"), + ((1384.0, Some(100.0), Some(100.0)), (l0, r0), "never narrower than their least"), + ((1384.0, Some(900.0), Some(900.0)), (l1, r1), "never wider than their most"), + ((1384.0, None, Some(300.0)), (0.0, 300.0), "the left panel closed"), + ((744.0, Some(240.0), Some(240.0)), (240.0, 240.0), "the smallest window with the default widths"), + ((744.0, Some(480.0), Some(480.0)), (l0, 744.0 - l0 - CANVAS_MIN), "the smallest window: the left panel gives way first, then the right"), + ((744.0, Some(480.0), Some(240.0)), (744.0 - 240.0 - CANVAS_MIN, 240.0), "the left panel gives the canvas its room"), + ((744.0, None, Some(480.0)), (0.0, 480.0), "with the left panel closed the right one has room"), + ((300.0, Some(240.0), Some(240.0)), (l0, r0), "a window smaller than any layout: the least widths"), + ((1384.0, Some(f32::NAN), Some(f32::INFINITY)), (PANEL_DEFAULT, PANEL_DEFAULT), "junk widths: the default"), + ((744.0, Some(480.0), None), (480.0, 0.0), "the right panel closed (a board's): the left one keeps its width"), ]; for ((avail, left, right), want, why) in rows { assert_eq!(fit_panels(avail, left, right), want, "{why}"); @@ -85,7 +86,7 @@ fn panels_fit_the_window() { for avail in [744.0, 900.0, 1384.0] { for left in [None, Some(l0), Some(300.0), Some(l1)] { for right in [r0, 333.0, r1] { - let (l, r) = fit_panels(avail, left, right); + let (l, r) = fit_panels(avail, left, Some(right)); assert!(avail - l - r >= CANVAS_MIN, "{avail} {left:?} {right}: canvas {}", avail - l - r); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a5af6a9c..38b8f0dc 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -420,6 +420,15 @@ fn enter_text(app: &mut FigApp) -> bool { return false; } let Some(id) = sel.first().copied() else { return false }; + // A board object (a sticky, a shape with text, a label): type in its text. + match figcraft_engine::board::edit_text(&mut app.session, id) { + Ok(true) => return true, + Ok(false) => {} + Err(e) => { + app.toast(e.to_string()); + return true; + } + } let kind = app.session.doc().ok().and_then(|d| d.node(id)).map(|n| (matches!(n.kind, figcraft_doc::NodeKind::Text(_)), n.kind.is_shape())); match kind { Some((true, _)) => { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index c10edcfd..3a4e9ecb 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -212,7 +212,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { icons::paint( &painter, Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)), - Icon::Logo, + icons::file_mark(d.doc.kind), crate::theme::ACCENT.linear_multiply(progress), ); // The unsaved-changes dot follows the name on screen, whichever way the name reads. diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a5ff426b..b95575b8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -28,23 +28,35 @@ struct Group { /// The flyout button's name for screen readers. name: &'static str, tools: &'static [(ToolId, Icon)], + /// What a one-tool group's flyout offers instead of tools (boards: colours, shapes, stamps). + options: Options, +} + +/// The choices a board tool's flyout shows. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Options { + None, + Colors, + Shapes, + Stamps, +} + +impl Group { + const fn tools(key: &'static str, name: &'static str, tools: &'static [(ToolId, Icon)]) -> Group { + Group { key, name, tools, options: Options::None } + } + fn has_flyout(&self) -> bool { + self.tools.len() > 1 || self.options != Options::None + } } const GROUPS: &[Group] = &[ - Group { - key: "move", - name: tr_noop("Move tools"), - tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)], - }, - Group { - key: "frame", - name: tr_noop("Frame tools"), - tools: &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)], - }, - Group { - key: "shape", - name: tr_noop("Shape tools"), - tools: &[ + Group::tools("move", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)]), + Group::tools("frame", tr_noop("Frame tools"), &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)]), + Group::tools( + "shape", + tr_noop("Shape tools"), + &[ (ToolId::Rectangle, Icon::Rectangle), (ToolId::Line, Icon::Line), (ToolId::Arrow, Icon::Arrow), @@ -52,14 +64,31 @@ const GROUPS: &[Group] = &[ (ToolId::Polygon, Icon::Polygon), (ToolId::Star, Icon::Star), ], - }, - Group { - key: "pen", - name: tr_noop("Drawing tools"), - tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], - }, - Group { key: "text", name: tr_noop("Text"), tools: &[(ToolId::Text, Icon::Text)] }, - Group { key: "comment", name: tr_noop("Comment"), tools: &[(ToolId::Comment, Icon::Comment)] }, + ), + Group::tools( + "pen", + tr_noop("Drawing tools"), + &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], + ), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), +]; + +/// A board's toolbar: moving around, ink, stickies, shapes, connectors, text, sections, stamps. +const BOARD_GROUPS: &[Group] = &[ + Group::tools("bmove", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)]), + Group::tools( + "ink", + tr_noop("Drawing tools"), + &[(ToolId::Marker, Icon::Marker), (ToolId::Highlighter, Icon::Highlighter), (ToolId::Eraser, Icon::Eraser)], + ), + Group { key: "sticky", name: tr_noop("Sticky note colors"), tools: &[(ToolId::Sticky, Icon::Board)], options: Options::Colors }, + Group { key: "bshape", name: tr_noop("Shapes"), tools: &[(ToolId::BoardShape, Icon::Shapes)], options: Options::Shapes }, + Group::tools("connector", tr_noop("Connector"), &[(ToolId::Connector, Icon::Connector)]), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("section", tr_noop("Section"), &[(ToolId::Section, Icon::Section)]), + Group { key: "stamp", name: tr_noop("Stamps"), tools: &[(ToolId::Stamp, Icon::Stamp)], options: Options::Stamps }, + Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { @@ -85,74 +114,120 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { builder_toolbar(app, ui); return; } - main_toolbar(app, ui, canvas, &t); + let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board); + main_toolbar(app, ui, canvas, &t, board); } -/// The main toolbar pill's width: the Actions palette matches it so the two read as one bar. -pub(crate) fn main_width() -> f32 { - let (cell, gap) = (32.0, 4.0); - GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 +/// The main toolbar pill's width for the active file: the Actions palette matches it so the two +/// read as one bar. +pub(crate) fn bar_width(app: &FigApp) -> f32 { + let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board); + main_width(if board { BOARD_GROUPS } else { GROUPS }, board) +} + +/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and +/// Actions, and the mode switcher (boards: the Design panel's button instead). +fn main_width(groups: &[Group], board: bool) -> f32 { + let (cell, gap) = (TOOL_CELL, 4.0); + let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * 10.0; + let end = if board { cell + 16.0 } else { 4.0 * 34.0 + 24.0 }; + groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end } /// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the -/// Draw · Design · Prototype · Inspect mode switcher. -fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { - let (cell, gap) = (32.0, 4.0); - let width = main_width(); - let rect = pill(ui, &mut app.ui.nav, canvas, width, t); - let current = app.session.tool.tool; - let mut x = rect.min.x + 8.0; +/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button). +fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) { + let groups = if board { BOARD_GROUPS } else { GROUPS }; + let rect = pill(ui, &mut app.ui.nav, canvas, main_width(groups, board), t); let cy = rect.center().y; - for g in GROUPS { - let in_group = g.tools.iter().any(|(tid, _)| *tid == current); - let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; - let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); - let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); - if in_group { - a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id); + let mut x = rect.min.x + 8.0; + for g in groups { + x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t); + } + x = side_buttons(app, ui, x, cy, t); + divider(ui, t, x, cy); + x += 8.0; + if board { + // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for. + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); + let tip = crate::shortcuts::tip(tr("Design panel"), "view.design"); + if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() { + app.run("view.design", json!({})); } - if resp.clicked() { - // Enter (a keyboard click) on the shape or frame tool already chosen places one; the - // canvas takes the keyboard. - // In Dev Mode the tool goes through `choose`, which returns to the design first. - if in_group && shown.draws_shape() && app.ui.right_tab != crate::RightTab::Inspect && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - app.run("tool.place", json!({})); - app.ui.nav.go(Region::Canvas); - } else { - choose(app, shown); - } - } - x += cell; - // Flyout chevron. - if g.tools.len() > 1 { - let cr = Rect::from_center_size(pos2(x + 5.0, cy), vec2(10.0, cell)); - let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click()); - let open = app.ui.tool_flyout == Some(g.key); - a11y::menu_button(&cresp, tr(g.name), open); - if open { - a11y::popup(&mut app.ui.nav, cresp.id, flyout_id(g.key)); - } - if cresp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); - } - icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); - if cresp.clicked() { - app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) }; - } - x += 10.0; - } - x += gap; - if app.ui.tool_flyout == Some(g.key) { - flyout(app, ui, g, pos2(r.min.x, rect.min.y - 8.0)); + } else { + mode_switcher(app, ui, x, cy, t); + } + help_button(app, ui, canvas, t); +} + +/// A toolbar button's size. +const TOOL_CELL: f32 = 32.0; + +/// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout +/// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts. +fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 { + let current = app.session.tool.tool; + let in_group = g.tools.iter().any(|(tid, _)| *tid == current); + let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; + let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); + let r = Rect::from_center_size(pos2(at.x + TOOL_CELL / 2.0, at.y), vec2(TOOL_CELL, TOOL_CELL)); + let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); + if in_group { + a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id); + } + if resp.clicked() { + // Enter (a keyboard click) on the shape or frame tool already chosen places one; the + // canvas takes the keyboard. + // In Dev Mode the tool goes through `choose`, which returns to the design first. + if in_group && shown.draws_shape() && app.ui.right_tab != crate::RightTab::Inspect && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + app.run("tool.place", json!({})); + app.ui.nav.go(Region::Canvas); + } else { + choose(app, shown); } } - // Resources (components, plugins) opens the Assets panel; Actions palette. + let mut x = at.x + TOOL_CELL; + if g.has_flyout() { + flyout_caret(app, ui, g, pos2(x, at.y), t); + x += 10.0; + } + if app.ui.tool_flyout == Some(g.key) { + if g.options == Options::None { + flyout(app, ui, g, pos2(r.min.x, top - 8.0)); + } else { + options_flyout(app, ui, g, pos2(r.min.x, top - 8.0)); + } + } + x + 4.0 +} + +/// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its +/// flyout. +fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &Tokens) { + let cr = Rect::from_center_size(pos2(at.x + 5.0, at.y), vec2(10.0, TOOL_CELL)); + let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click()); + let open = app.ui.tool_flyout == Some(g.key); + a11y::menu_button(&cresp, tr(g.name), open); + if open { + a11y::popup(&mut app.ui.nav, cresp.id, flyout_id(g.key)); + } + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); + if cresp.clicked() { + app.ui.tool_flyout = if open { None } else { Some(g.key) }; + } +} + +/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`. +/// Where the next part starts. +fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (icon, tip, id) in [ (Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"), (Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"), ] { - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let resp = ui.interact(r, ui.id().with(id), Sense::click()).tip(tip); if id == "actions" { app.ui.actions_rect = Some(r); @@ -172,11 +247,13 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { app.run("view.assets", json!({})); } } - x += cell + 4.0; + x += TOOL_CELL + 4.0; } - divider(ui, t, x, cy); - x += 8.0; - // Mode switcher: Draw · Design · Prototype · Inspect (code). + x +} + +/// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code). +fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) { let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))]; let active = match app.ui.right_tab { crate::RightTab::Design if app.session.tool.tool == ToolId::Pencil => 0, @@ -198,23 +275,31 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary }); if resp.clicked() { - match i { - 0 => { - app.ui.right_tab = crate::RightTab::Design; - app.run("tool.set", json!({"tool": "pencil"})); - } - 1 => { - app.ui.right_tab = crate::RightTab::Design; - if app.session.tool.tool == ToolId::Pencil { - app.run("tool.set", json!({"tool": "move"})); - } - } - 2 => app.ui.right_tab = crate::RightTab::Prototype, - _ => app.ui.right_tab = crate::RightTab::Inspect, - } + pick_mode(app, i); } } - // Floating help button at the bottom right of the canvas. +} + +/// Mode `i` of the switcher chosen. +fn pick_mode(app: &mut FigApp, i: usize) { + match i { + 0 => { + app.ui.right_tab = crate::RightTab::Design; + app.run("tool.set", json!({"tool": "pencil"})); + } + 1 => { + app.ui.right_tab = crate::RightTab::Design; + if app.session.tool.tool == ToolId::Pencil { + app.run("tool.set", json!({"tool": "move"})); + } + } + 2 => app.ui.right_tab = crate::RightTab::Prototype, + _ => app.ui.right_tab = crate::RightTab::Inspect, + } +} + +/// The floating help button at the bottom right of the canvas. +fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0)); let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).tip(tr("Help and resources")); ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20)); @@ -381,7 +466,7 @@ fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { } /// A square tool button: accent when active, tinted on hover; `tip` is its tooltip and name. -fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response { +pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response { let resp = ui.interact(r, ui.id().with(id), Sense::click()).on_hover_ui(|ui| { ui.label(tip); }); @@ -415,6 +500,41 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { resp.clicked() } +/// A board tool's flyout: the colours, shapes or stamps it places, in a row. A pick chooses the +/// tool with it. +fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { + let t = Tokens::get(ui.ctx()); + let Some(&(tool, _)) = g.tools.first() else { return }; + let items = crate::board_bar::choices(g.options, &app.session.tool.board); + let (cell, pad) = (32.0, 8.0); + let size = vec2(items.len() as f32 * (cell + 4.0) - 4.0 + 2.0 * pad, cell + 2.0 * pad); + let mut chosen = None; + egui::Area::new(flyout_id(g.key)).fixed_pos(pos2(bottom_left.x, bottom_left.y - size.y)).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu); + for (i, c) in items.iter().enumerate() { + let r = Rect::from_min_size(pos2(rect.min.x + pad + i as f32 * (cell + 4.0), rect.min.y + pad), vec2(cell, cell)); + let resp = ui.interact(r, ui.id().with(("opt", g.key, i)), Sense::click()).on_hover_text(tr(c.label)); + a11y::menu_item(&resp, tr(c.label), "", Some(c.on)); + a11y::own_focus(&resp); + if c.on || resp.hovered() || resp.has_focus() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + crate::board_bar::paint_choice(ui.painter(), r.shrink(5.0), &c.mark, t.menu_text); + if resp.clicked() { + chosen = Some(c.params.clone()); + } + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.tool_flyout = None; + } + if let Some(params) = chosen { + app.run(&format!("tool.{}", tool.id()), params); + app.ui.tool_flyout = None; + } +} + fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { let t = Tokens::get(ui.ctx()); let item_h = 32.0; diff --git a/docs/boards-parity.md b/docs/boards-parity.md new file mode 100644 index 00000000..b6928dd7 --- /dev/null +++ b/docs/boards-parity.md @@ -0,0 +1,61 @@ +# Boards parity + +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** major (created: board files, the first slice of the whiteboard workflow) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) + +**Breadth ~35%, ready ~20% (estimated, erring low).** A board is a FigCraft file of its own kind +(`file.newBoard`) with sticky notes, shapes with text, connectors that follow what they join, +marker, highlighter and eraser, stamps, text and sections, edited together in the existing live +sessions. The facilitation tools (timer, voting, widgets), templates, tables, mind maps and the +rest of the tail are missing. Remaining: ~30–60 h. Tracked as area 22 of +[`target-app-parity.md`](target-app-parity.md) (not in its weighted sum: the target there is the +design product). The work item is gap 24 in [`gaps.md`](gaps.md). + +## How boards are built + +Board objects are ordinary layers with a small `board` field on the node (`figcraft_doc::BoardItem`), +so rendering, hit testing, undo, saving, SVG/PNG/PDF export, copy and paste and live sessions work +on them unchanged. The file kind (`Document.kind`) is saved in `.figcraft` and reaches people who +join a live session in their snapshot. What follows from the metadata (a sticky's text size, a +shape's text room, connector routes and their labels) is recomputed in the engine's normalize pass, +deterministically from the document, so local edits, undo and edits from others in a live session +all end with the same geometry. + +## Features + +| Feature | Status | Notes | +|---|---|---| +| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) | +| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button | +| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow and its arrow; the Design panel opens with its button or View ▸ Design panel | +| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why | +| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it | +| Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | +| Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | +| Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight or elbow, arrowheads (the stroke's caps), a label in the middle (double-click to add); re-routed when either end moves or resizes; a deleted end stays where it was | +| Curved connectors, connector waypoints, dragging an end to re-attach | missing | ends are attached when drawn or through `board.connector` | +| Quick-create handles (drag out a connected copy) | missing | | +| Marker and highlighter | done | the Pencil's stroke fitting with board presets: marker opaque, highlighter wide, see-through and multiplying | +| Eraser | done | drag over marker and highlighter strokes to remove them, one undo step | +| Pen colours and widths in the toolbar | partial | `tool.marker {color}`, `tool.highlighter {color}`; colour changes on a selected stroke from the contextual bar | +| Stamps | done | thumbs up, heart, plus one, star, question, check: round badges drawn as vectors, the placer's name on hover | +| Stamps sticking to the object they land on, the stamp wheel, emotes, stickers | missing | | +| Text and sections | done | the existing Text tool and sections | +| Live sessions: cursors, selections, cursor chat, spotlight | done | the existing sessions; board objects, connectors following moved objects, colours and stamps sync both ways (checked with two app instances) | +| Timer and music | missing | | +| Voting sessions | missing | | +| Board widgets | missing | design-file widgets exist (`crates/engine/src/widgets.rs`), not wired to boards | +| Templates (retrospective, brainstorm, flow chart…) | missing | | +| Tables | missing | | +| Mind maps | missing | | +| Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | +| Audio and huddles | missing | | +| AI features (summaries, sorting stickies, generating boards) | missing | | +| Dot grid on the page | missing | the canvas grid is drawn over the render; a dot grid needs a render pass under the layers | +| `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-24 | major | Created with board files (M12): sticky notes, shapes with text, connectors, ink and eraser, stamps, the board toolbar and contextual bar, live-session sync | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a701cf6b..a0ccd076 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (`.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (`.figcraft` carries the file kind (boards; `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -18,9 +18,9 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | +| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | -| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305) | — | +| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | ## Import (place / paste / drop) @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards | | 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 7b89cc20..531a230a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -37,6 +37,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | | 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | | 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | +| 24 | Boards: the whiteboard tail (timer, voting, widgets, templates, tables, mind maps…) | Feature | 30–60 | [boards-parity](boards-parity.md) | ## Entries @@ -451,10 +452,23 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe repository (licensing). - **Estimate:** 2–4 h once an openly licensed profile is found. +### 24. Boards: the whiteboard tail +- **Landed (2026-10-24, M12):** board files (`file.newBoard`) with sticky notes, shapes with text, + connectors that re-route when what they join moves, marker, highlighter and eraser, stamps, text + and sections; a board toolbar and a contextual bar; live sessions sync it all. +- **Missing:** timer and music, voting sessions, board widgets, templates, tables, mind maps, washi + tape, code blocks, audio and huddles, quick-create handles, curved connectors and waypoints, + stamps sticking to the objects they land on, a dot grid, `.jam` files + ([`boards-parity.md`](boards-parity.md)). +- **User impact:** boards cover a brainstorm or a flow sketch; facilitated workshops need the + timer, voting and templates. +- **Estimate:** 30–60 h. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 1b235b49..d73cf78f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -330,6 +330,53 @@ tips; headless, it only sets the preference. {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export","arguments":{"format":"png","path":"dashboard.png"}}} ``` +### Boards + +A board is a file of its own kind for thinking together: `file.newBoard {name?}` opens one (its +page is light; `inspect_document` says `kind: "board"`). Board objects are ordinary layers with a +`board` entry `get_node` shows (`{type: sticky|shape|connector|label|stamp|ink, …}`), so the usual +commands move, resize, recolour, copy, delete and undo them. On a design file the board commands +refuse; on a board `proto.*`, `dev.*`, `view.prototype` and `view.devMode` refuse. Each of these is +one undo step and returns the ids it made: + +- `board.sticky {x, y, text?, color?, author?, parent?}` → `{id, text}`: a sticky note (a square + frame whose height grows with its note; its text size shrinks as the note gets longer). `color` + is a board colour (`yellow orange red pink violet blue green gray`) or a hex colour; `author` + defaults to your name (`pref.userName`, or the one a live session gave you; `false` for none). +- `board.nextSticky {id?, side?: right|below|left|above, edit?}` → `{id, text}`: a sticky of the + same colour beside the selected (or edited) one; with `edit: true` the app goes on typing in it + (`Shift+Cmd/Ctrl+Enter` in the app does both). +- `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred, + wrapping text: `rectangle roundedRectangle ellipse diamond triangle speechBubble chevron`. +- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow, startCap?, endCap?, label?}` + → `{id, label?}`: `from`/`to` are layer ids or `{x, y}` page points; sides are `auto` (the side + facing the other end) or `top right bottom left`; caps are stroke caps (default an arrow at the + end). The connector re-routes itself whenever what it joins moves or resizes, also when someone + else in a live session moves it; a deleted end stays where it was as a point. `board.label {id, + text}` sets its label (empty removes it). +- `board.stamp {x, y, stamp: thumbsUp|heart|plusOne|star|question|check}` → `{id}`: a round badge + centred there, with your name on it for hover. +- `board.ink {path | points, highlighter?, color?, select? (default true)}` → `{id}`: a marker (or + wide, see-through highlighter) stroke. +- `board.setColor {ids?, color}`: stickies and shapes take it as their fill, connectors and ink as + their line. `board.setStyle {ids?, shape?, fontSize?, style?, startCap?, endCap?}`: a shape's + outline and text size, a connector's routing and arrowheads. +- Tools for `pointer`: `tool.sticky {color?}`, `tool.shape {shape?}`, `tool.connector {style?}` + (drag from one object to another), `tool.marker {color?}`, `tool.highlighter {color?}`, + `tool.eraser` (drag over strokes), `tool.stamp {stamp?}`; Text and Section are the usual tools. + +```json +{"command": "file.newBoard", "params": {"name": "Retro"}} +{"command": "board.sticky", "params": {"x": 0, "y": 0, "text": "Went well", "color": "green"}} +{"command": "board.sticky", "params": {"x": 240, "y": 0, "text": "To improve", "color": "pink"}} +{"command": "board.connector", "params": {"from": 2, "to": 4, "style": "elbow", "label": "leads to"}} +{"command": "board.stamp", "params": {"x": 200, "y": 0, "stamp": "plusOne"}} +``` + +`from` and `to` are the `id`s the two `board.sticky` calls returned (here `{"id": 2, "text": 3}` and +`{"id": 4, "text": 5}` in a new board without an author name; a sticky with its author's name takes +one more id). In a `batch`, step references (`{"from": {"$step": 1}, "to": {"$step": 2}}`, the `id`s steps 1 and 2 returned) avoid counting. + ### Sections, handoff and arrow keys `run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or @@ -364,6 +411,10 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `view.designPrototype` (`Shift+E`), `view.devMode` (`Shift+D`) and `view.leftSidebar` (`Shift+Cmd+\`) switch the right panel's tab or hide the left sidebar in the app (no-ops headless). - `doc.nextTab` / `doc.previousTab` (`Ctrl+Tab` / `Ctrl+Shift+Tab`): the next or previous open file. +- Board tools (boards only): `tool.sticky` (`Q`), `tool.shape` (`B`), `tool.connector` (`X`), + `tool.marker` (`M`), `tool.highlighter` (`U`), `tool.eraser` (`E`), `tool.stamp` (`W`), and + `board.nextSticky` (`Shift+Cmd/Ctrl+Enter`, also while typing in a sticky). Keys no design-file + shortcut uses; on a design file they refuse. - `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same) pan the view by 20 screen pixels a step, 200 with `big` (Shift). - `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session. diff --git a/docs/roadmap.md b/docs/roadmap.md index b18b7b68..8e093ac9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -60,7 +60,8 @@ blockers. | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | | M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | -| M12 AI features (new, owner decision) | not started | local models | 30–60 | +| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions | timer, voting, widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 30–60 | +| M13 AI features (new, owner decision) | not started | local models | 30–60 | ## Next, after the current focus @@ -74,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | | 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7531a455..62bea024 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -9,7 +9,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c [`hardware-parity.md`](hardware-parity.md), [`localization-parity.md`](localization-parity.md), [`auto-layout-parity.md`](auto-layout-parity.md), [`components-parity.md`](components-parity.md), [`prototyping-parity.md`](prototyping-parity.md), [`dev-mode-parity.md`](dev-mode-parity.md), -[`collaboration-parity.md`](collaboration-parity.md). +[`collaboration-parity.md`](collaboration-parity.md), [`boards-parity.md`](boards-parity.md). ## Headline @@ -84,8 +84,10 @@ Weights are the share of a typical product designer's working time in Figma Desi so they can be argued with. Areas 1–16 keep the original rubric's weights (they sum to 94, not the 100 the rubric claimed); areas 17–21 are added (sum 19) because Figma users depend on them. Percentages below divide by the total, 113. -Out of scope as separate products: FigJam, Figma Slides, Sites, Make, Buzz, Weave (the `.jam` and -`.deck` document kinds belong to them). +Out of scope as separate products: Figma Slides, Sites, Make, Buzz, Weave (the `.deck` document +kind belongs to them). Whiteboarding is covered by FigCraft's boards (area 22, weight 0: a separate +workflow, tracked here but not in the weighted sum; [`boards-parity.md`](boards-parity.md)); the +`.jam` document kind is not read or written. ## By feature area @@ -115,6 +117,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | +| 22 | Boards (whiteboarding) | 0 | ~35% | ~20% | Board files with sticky notes, shapes with text, connectors that follow what they join, marker, highlighter and eraser, stamps, text and sections, in live sessions (M12); missing: timer, voting, board widgets, templates, tables, mind maps, washi tape, audio, quick-create handles, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 30–60 | | | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | @@ -638,6 +641,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6e509859..980889ec 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -129,7 +129,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there | | Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two | | Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build | -| Undocumented bindings pinned | 49 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops | +| Undocumented bindings pinned | 57 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops | - **Newly bound:** A for the Frame tool, Shift+' for the pixel grid (Cmd+' kept: the articles disagree), Alt+Cmd+\ / Ctrl+Alt+\ for others' cursors (`view.multiplayerCursors`, new), Ctrl+F6 @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | | 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |