diff --git a/ROADMAP.md b/ROADMAP.md index e9ae9c3d1..3f9e03f2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,6 +101,7 @@ native speakers' reviews of the nine language drafts need people. |---|---|---|---| | English | en | 100% | full | | Simplified Chinese | zh-Hans | 95% | draft (needs native review) | +| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 entries (100%, counted 2026-11-03) | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; the window mirrors (panels swap sides); native review and mirrored panel rows pending | @@ -114,9 +115,9 @@ native speakers' reviews of the nine language drafts need people. Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸ -Language; Traditional Chinese has no catalog; CJK UI -text uses an installed CJK font, loaded only when needed; adding a language: +French, German, Brazilian Portuguese, Japanese, Korean, Simplified and Traditional Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸ +Language; Traditional Chinese reads zh-TW, zh-HK, zh-MO and zh-Hant, while an explicit Hans stays +Simplified; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md). @@ -127,7 +128,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)). +4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, zh-Hant, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log @@ -363,6 +364,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it while an explicit Hans stays Simplified; the desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled; native review pending. Numbers unchanged | | 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged | | 2026-11-06 | patch | M5, #2027: `proto.settings` refuses an unknown device name with the list of devices (it saved any string, and the presenter showed no device), takes known names in any case, and answers the page's prototype settings, so `proto.settings {}` reads them back over MCP. Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back`. Numbers unchanged | | 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes asks Don't Save / Cancel / Save, as closing the window does, instead of a toast that left the tab open; Alt+close still discards at once. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Numbers unchanged | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 1eb4962a9..99b9d9c39 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1,6 +1,6 @@ //! Node property types: strokes, effects, corners, constraints, auto layout, grids, exports. -use figcraft_color::{BlendMode, Color}; +use figcraft_color::{BlendMode, Color, ColorProfile}; use serde::{Deserialize, Serialize}; /// Where a stroke sits relative to the shape edge. @@ -1535,11 +1535,56 @@ pub struct ExportSetting { /// Android Vector Drawable: what to do with what a drawable can't hold exactly. #[serde(skip_serializing_if = "VdCompat::is_default")] pub vd_compat: VdCompat, + /// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always + /// sRGB, whatever this says. + #[serde(skip_serializing_if = "ExportColorProfile::is_default")] + pub color_profile: ExportColorProfile, /// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]). #[serde(skip_serializing_if = "is_default_quality")] pub quality: u8, } +/// The color profile an export setting writes PNG, JPG and WebP files in. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ExportColorProfile { + /// The document's own profile at the time of the export. + #[default] + SameAsFile, + Srgb, + DisplayP3, +} + +impl ExportColorProfile { + pub const ALL: [ExportColorProfile; 3] = [ExportColorProfile::SameAsFile, ExportColorProfile::Srgb, ExportColorProfile::DisplayP3]; + + fn is_default(&self) -> bool { + *self == ExportColorProfile::SameAsFile + } + + /// The name commands and files use ("sameAsFile", "srgb", "displayP3"). + pub fn key(self) -> &'static str { + match self { + ExportColorProfile::SameAsFile => "sameAsFile", + ExportColorProfile::Srgb => "srgb", + ExportColorProfile::DisplayP3 => "displayP3", + } + } + + pub fn from_key(s: &str) -> Option { + Self::ALL.into_iter().find(|c| c.key() == s) + } + + /// The profile the file is written in when the document is in `file`. + pub fn resolve(self, file: ColorProfile) -> ColorProfile { + match self { + ExportColorProfile::SameAsFile => file, + ExportColorProfile::Srgb => ColorProfile::Srgb, + ExportColorProfile::DisplayP3 => ColorProfile::DisplayP3, + } + } +} + /// How an Android Vector Drawable export handles what the format can't draw exactly (layer /// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -1588,6 +1633,7 @@ impl Default for ExportSetting { svg_id_attribute: false, svg_simplify_stroke: true, vd_compat: VdCompat::BestEffort, + color_profile: ExportColorProfile::SameAsFile, quality: ExportSetting::DEFAULT_QUALITY, } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ba1555f88..efcb0fc52 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -448,7 +448,7 @@ pub fn specs() -> Vec { spec!( "export.add", "Add export setting", - "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", + "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile|srgb|displayP3), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", true, |s, p| { let cmd = "export.add"; @@ -466,7 +466,7 @@ pub fn specs() -> Vec { spec!( "export.update", "Edit export setting", - "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}", + "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile (the document's profile when it exports)|srgb|displayP3; kept when the format changes), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}", true, |s, p| { let cmd = "export.update"; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9be42bb0e..0ed12b8d3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use base64::Engine as _; use figcraft_doc::{ - BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle, - VdCompat, + BOARD_PAGE, Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, + StyleKind, TextStyle, VdCompat, }; use figcraft_format::Version; use serde_json::{Value, json}; @@ -120,14 +120,14 @@ pub fn specs() -> Vec { spec!( "file.export", "Export", - "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), quality? (JPG: 1-100, default 90)}", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, PDF and XML are always sRGB), quality? (JPG: 1-100, default 90)}", false, export ), spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own), colorProfile? (sameAsFile|srgb|displayP3: for every PNG, JPG and WebP instead of each setting's own)} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -408,14 +408,38 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new())) } _ => { - let pix = figcraft_render::render_area(doc, &drawn, area, scale) + let mut pix = figcraft_render::render_area(doc, &drawn, area, scale) .ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?; - let bytes = figcraft_render::encode_export_with(&pix, setting.format, doc.color_profile, setting.quality()); + // The render is in the document's profile; the setting's profile converts the pixels + // (out-of-gamut colors clip) and tags the file. + let profile = setting.color_profile.resolve(doc.color_profile); + let conversion = doc.color_profile.conversion_to(profile); + // JPG has no alpha: the render is flattened over white in the document's profile + // before the conversion, so translucent pixels match what the document shows. + if setting.format == ExportFormat::Jpg && !conversion.is_identity() { + for [r, g, b, a] in pix.data_mut().as_chunks_mut::<4>().0.iter_mut() { + let under = 255 - *a; + *r = r.saturating_add(under); + *g = g.saturating_add(under); + *b = b.saturating_add(under); + *a = 255; + } + } + conversion.apply_pixels(pix.data_mut()); + let bytes = figcraft_render::encode_export_with(&pix, setting.format, profile, setting.quality()); Ok((bytes.map_err(EngineError::Other)?, Vec::new())) } } } +/// The `colorProfile` `p` passes: sameAsFile, srgb or displayP3 (anything else is an error). +fn color_profile_param(cmd: &str, p: &Value) -> Result> { + match get(p, "colorProfile") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(ExportColorProfile::from_key).map(Some).ok_or_else(|| bad(cmd, "colorProfile: sameAsFile|srgb|displayP3")), + } +} + /// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error). fn vd_compat_param(cmd: &str, p: &Value) -> Result> { match get(p, "vdCompat") { diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs index 25b5629a1..542ee246c 100644 --- a/crates/engine/src/cmd/file/export.rs +++ b/crates/engine/src/cmd/file/export.rs @@ -175,6 +175,9 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) if let Some(c) = vd_compat_param(cmd, p)? { e.vd_compat = c; } + if let Some(c) = color_profile_param(cmd, p)? { + e.color_profile = c; + } if let Some(q) = quality_param(cmd, p)? { e.quality = q; } @@ -342,10 +345,16 @@ pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result { let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; // And a compatibility mode for every Android Vector Drawable. let compat = vd_compat_param(cmd, p)?; + // And a color profile for every PNG, JPG and WebP. + let profile = color_profile_param(cmd, p)?; let mut files = Vec::with_capacity(jobs.len()); for job in &jobs { - let setting = - ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; + let setting = ExportSetting { + print: print.unwrap_or(job.setting.print), + vd_compat: compat.unwrap_or(job.setting.vd_compat), + color_profile: profile.unwrap_or(job.setting.color_profile), + ..job.setting.clone() + }; let scale = export_scale(doc, &[job.id], &setting); let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 305db5399..c33878066 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -8,10 +8,12 @@ use crate::params::{boolean, get, num, num_or, req_index, req_num, req_string, s use crate::tools::ToolId; use crate::{EngineError, Result, Session, ViewState, bad, spec}; +/// The active document's view state, for changing it. fn view(s: &mut Session) -> Result<&mut ViewState> { Ok(&mut s.ds_mut()?.view) } +/// Set one of a view's on/off settings from `on`, or toggle it when `on` is absent. fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Result { let v = view(s)?; let slot = f(v); @@ -90,6 +92,7 @@ fn pixel_preview(s: &mut Session, p: &Value) -> Result { Ok(json!({"on": v.pixel_preview, "scale": v.pixel_scale})) } +/// Zoom the active document to `z` about the view's centre, keeping that point in place. fn zoom_to(s: &mut Session, z: f64) -> Result { let v = view(s)?; let c = (v.cx, v.cy); @@ -114,6 +117,7 @@ fn pan(s: &mut Session, p: &Value) -> Result { /// Figma's zoom steps. const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0]; +/// The next zoom step above (`up`) or below `z` in Figma's fixed steps. fn step(z: f64, up: bool) -> f64 { if up { STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0) @@ -141,6 +145,7 @@ fn zoom_at(s: &mut Session, p: &Value) -> Result { Ok(json!({"zoom": v.zoom, "cx": v.cx, "cy": v.cy})) } +/// Select and fit the next (`dir` 1) or previous (-1) top-level frame on the page. fn zoom_frame(s: &mut Session, dir: i64) -> Result { let ds = s.ds()?; let frames = ds.doc.top_frames(ds.page); @@ -161,6 +166,7 @@ fn zoom_frame(s: &mut Session, dir: i64) -> Result { Ok(json!({"frame": f})) } +/// Move to the next (`dir` 1) or previous (-1) page, skipping dividers, and fit it. fn page_step(s: &mut Session, dir: i64) -> Result { let ds = s.ds()?; let pages = &ds.doc.pages; @@ -183,6 +189,7 @@ fn page_step(s: &mut Session, dir: i64) -> Result { Ok(json!({"page": pg})) } +/// Ask the UI to open or toggle the panel or dialog named `kind`. fn ui(s: &mut Session, kind: &str) -> Result { s.request_ui(kind, json!({})); ok() @@ -210,12 +217,14 @@ fn ui_scale_step(s: &mut Session, dir: i32) -> Result { Ok(json!({"scale": s.prefs.ui_scale})) } +/// Set one of the preferences' on/off settings from `on`, or toggle it when `on` is absent. fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result { let slot = f(&mut s.prefs); *slot = boolean(p, "on").unwrap_or(!*slot); Ok(json!({"on": *slot})) } +/// Switch the tool, unless the file is read-only and this tool needs editing. 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, and // commenting with a comment link. @@ -226,6 +235,7 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result { Ok(json!({"tool": t.id()})) } +/// The View menu's commands and their parameters. pub fn specs() -> Vec { vec![ spec!("view.zoomIn", "Zoom in", "{}", false, |s, _| { @@ -480,7 +490,7 @@ pub fn specs() -> Vec { spec!( "pref.language", "Interface language", - "{language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}", + "{language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|zh-Hant|…; else English}", false, |s, p| { let lang = req_string("pref.language", p, "language")?; diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 58ecfb473..7b1a08bc2 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -319,9 +319,10 @@ fn a_dropped_connection_keeps_editing_and_catches_up() { } impl Room { - /// Person `i` has been told to join again: the server takes them as someone new. - fn welcome_anew(&mut self, i: usize) { - let ClientMsg::Hello(h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") }; + /// Person `i` has been told to join again: the server takes them as `resume` says. + fn welcome_as(&mut self, i: usize, resume: Option) { + let ClientMsg::Hello(mut h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") }; + h.resume = resume; let (id, out) = self.server.join(h).unwrap(); let w = welcome_of(&out, id); let p = &mut self.people[i]; @@ -351,7 +352,7 @@ fn reconnecting_can_be_told_to_try_now_and_shows_as_disconnected() { } #[test] -fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { +fn a_session_that_cant_go_on_stays_on_its_tab_and_merges_when_it_joins_again() { let mut room = Room::new(base(), 2, 7); let (f, r) = frame_and_rect(room.server.document()); let uid = room.s(0).ds().unwrap().uid; @@ -363,7 +364,8 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { let live = room.s(0).live(); assert!(live.disconnected() && live.rejoinable() && live.doc_uid == Some(uid)); assert!(room.s(0).live_read_only(uid).is_none()); - room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Offline too"}})).unwrap(); + let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let made = NodeId(made["id"].as_u64().unwrap()); // A try that fails leaves it just as it was. assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "connecting"); assert!(room.s(0).live().disconnected() && !room.s(0).live().rejoinable()); @@ -371,23 +373,85 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { room.event(0, Event::Status(Status::Failed { reason: "Couldn't join the live session: no answer".into() })); assert!(room.s(0).live().rejoinable()); // Someone else's edit lands meanwhile. - room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 5, "dy": 5})).unwrap(); + room.s(1).execute("transform.move", &json!({"ids": [f], "dx": 5, "dy": 5})).unwrap(); + let theirs = |d: &Document| d.node(f).unwrap().transform; room.settle(); - // The next try joins in the same tab, and what was done offline opens beside it. + let moved = theirs(room.server.document()); + // The next try joins in the same tab, as the same client, and everything goes on top. room.s(0).execute("collab.reconnect", &json!({})).unwrap(); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed.expect("as who you were"); + assert_eq!((resumed.client_id, resumed.seq), (room.people[0].id, u64::MAX)); + room.welcome_as(0, Some(resumed)); + room.settle(); + room.check(); let s = room.s(0); assert_eq!(s.live().status, Status::Live); assert!(!s.live().disconnected()); - assert_eq!(s.docs.len(), tabs + 1, "the copy's is the only new tab"); - let (tab, copy) = (s.docs.iter().find(|d| d.uid == uid).unwrap(), s.docs.last().unwrap()); - assert_eq!(s.live().doc_uid, Some(uid)); - assert_ne!(tab.doc.node(r).unwrap().name, "Offline", "the session's tab shows the server's version"); - assert_eq!(copy.doc.name, format!("{} (offline copy)", tab.doc.name)); - assert_eq!(copy.doc.node(r).unwrap().name, "Offline"); - assert_eq!(copy.doc.node(f).unwrap().name, "Offline too"); - assert!(copy.is_dirty(), "closing the copy asks first"); - assert!(s.message.as_deref().is_some_and(|m| m.contains("offline copy"))); + assert_eq!(s.docs.len(), tabs, "no copy: it all went in"); + let doc = room.server.document(); + assert_eq!(doc.node(r).unwrap().name, "Offline"); + assert!(doc.contains(made), "layers you made too"); + assert_eq!(theirs(doc), moved, "and the other edit is still there"); + assert!(room.s(0).message.as_deref().is_some_and(|m| m.contains("is in the file"))); +} + +#[test] +fn the_same_thing_changed_both_ways_keeps_the_others_and_opens_your_version_beside_it() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let tabs = room.s(0).docs.len(); + room.disconnect(0); + room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Mine"}})).unwrap(); + room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Only mine"}})).unwrap(); + room.event(0, Event::Status(Status::Failed { reason: "gone".into() })); + room.s(1).execute("node.set", &json!({"id": r, "props": {"name": "Theirs"}})).unwrap(); + room.settle(); + room.s(0).execute("collab.reconnect", &json!({})).unwrap(); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).unwrap().name, "Theirs", "what conflicts isn't written over"); + assert_eq!(doc.node(f).unwrap().name, "Only mine", "what doesn't goes in"); + let s = room.s(0); + assert_eq!(s.docs.len(), tabs + 1); + let copy = s.docs.last().unwrap(); + assert!(copy.doc.name.ends_with("(offline copy)") && copy.is_dirty(), "closing the copy asks first"); + assert_eq!(copy.doc.node(r).unwrap().name, "Mine"); +} + +#[test] +fn layers_the_new_client_cant_make_open_beside_the_file() { + let mut room = Room::new(base(), 1, 7); + let tabs = room.s(0).docs.len(); + room.disconnect(0); + let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let made = NodeId(made["id"].as_u64().unwrap()); + room.event(0, Event::Status(Status::Failed { reason: "gone".into() })); + room.s(0).execute("collab.reconnect", &json!({})).unwrap(); + // The server takes you as someone new. + room.welcome_as(0, None); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + let s = room.s(0); + assert_eq!(s.docs.len(), tabs + 1); + assert!(s.docs.last().unwrap().doc.contains(made), "it isn't lost"); +} + +#[test] +fn a_server_that_forgot_you_keeps_your_offline_edits() { + let mut room = Room::new(base(), 1, 7); + let (_, r) = frame_and_rect(room.server.document()); + room.disconnect(0); + room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Offline"}})).unwrap(); + // The same file under a new session: it knows no one. + room.server = Server::new(room.server.document().clone(), "t", Limits::default()); + room.welcome_as(0, None); + room.settle(); + assert_eq!(room.server.document().node(r).unwrap().name, "Offline"); + assert_eq!(room.s(0).live().status, Status::Live); } #[test] @@ -399,7 +463,8 @@ fn joining_again_with_nothing_changed_opens_no_copy() { assert!(room.s(0).live().rejoinable(), "the end of a session you were in shows on its tab too"); assert_eq!(room.s(0).message.as_deref(), Some("Sharing was stopped")); room.s(0).execute("collab.reconnect", &json!({})).unwrap(); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); assert_eq!(room.s(0).docs.len(), tabs); assert_eq!(room.s(0).live().status, Status::Live); } @@ -414,7 +479,8 @@ fn a_session_out_of_step_joins_again_by_itself() { assert_eq!(room.s(0).live().status, Status::Connecting); assert!(room.s(0).live().disconnected()); assert_eq!(room.people[0].script.lock().unwrap().joined.len(), joins + 1); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); assert_eq!(room.s(0).live().status, Status::Live); assert_eq!(room.s(0).docs.len(), tabs); } diff --git a/crates/engine/src/export_profile_tests.rs b/crates/engine/src/export_profile_tests.rs new file mode 100644 index 000000000..21b657856 --- /dev/null +++ b/crates/engine/src/export_profile_tests.rs @@ -0,0 +1,214 @@ +//! The color profile an export setting writes PNG, JPG and WebP files in: same as the file (the +//! default, following the document's profile when it exports), sRGB or Display P3, with the pixels +//! converted and the file tagged; stored per setting, kept across a format switch, passed to +//! export layers, ignored by the always-sRGB SVG, PDF and XML, and bad values refused. + +use base64::Engine as _; +use figcraft_color::{Color, ColorProfile}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +/// A session with a red 20 × 10 rectangle in an sRGB document. +fn session() -> (Session, NodeId) { + let mut s = Session::new(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "Red"})).unwrap()["id"]).unwrap(); + s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap(); + (s, r) +} + +fn bytes(v: &Value) -> Vec { + base64::engine::general_purpose::STANDARD.decode(v["data"].as_str().unwrap()).unwrap() +} + +fn export(s: &mut Session, params: Value) -> Vec { + bytes(&s.execute("file.export", ¶ms).unwrap()) +} + +/// Whether `bytes` holds a PNG chunk of `kind` (with `data`, when given). +fn has_chunk(bytes: &[u8], kind: &[u8; 4], data: Option<&[u8]>) -> bool { + bytes.windows(4).enumerate().any(|(i, w)| w == kind && data.is_none_or(|d| bytes.get(i + 4..i + 4 + d.len()) == Some(d))) +} + +/// An embedded ICC display profile (its device class). +fn has_icc(bytes: &[u8]) -> bool { + bytes.windows(4).any(|w| w == b"mntr") +} + +/// The middle pixel's RGB. +fn middle(bytes: &[u8]) -> [u8; 3] { + let img = image::load_from_memory(bytes).unwrap().to_rgba8(); + let [r, g, b, _] = img.get_pixel(img.width() / 2, img.height() / 2).0; + [r, g, b] +} + +/// `c` as 8-bit RGB. +fn rgb8(c: Color) -> [u8; 3] { + [c.r, c.g, c.b].map(|v| (v * 255.0).round() as u8) +} + +fn close(a: [u8; 3], b: [u8; 3], tolerance: u8) -> bool { + a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= tolerance) +} + +/// sRGB red in Display P3 values. +fn red_in_p3() -> [u8; 3] { + rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(Color::new(1.0, 0.0, 0.0, 1.0))) +} + +#[test] +fn a_display_p3_png_from_an_srgb_file_is_converted_and_tagged() { + let (mut s, r) = session(); + let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"})); + assert!(has_chunk(&png, b"iCCP", None) && has_chunk(&png, b"cICP", Some(&[12, 13, 0, 1])) && !has_chunk(&png, b"sRGB", None)); + let want = red_in_p3(); + assert!(close(middle(&png), want, 1), "{:?} vs {want:?}", middle(&png)); + // The default stays the file's own sRGB. + let png = export(&mut s, json!({"ids": [r], "format": "png"})); + assert!(has_chunk(&png, b"sRGB", None) && !has_chunk(&png, b"iCCP", None)); + assert_eq!(middle(&png), [255, 0, 0]); + // The document is not changed by the export. + assert_eq!(s.doc().unwrap().color_profile, ColorProfile::Srgb); + assert_eq!(crate::paint_tests::fill(&s, r).to_hex(), "FF0000"); +} + +#[test] +fn an_srgb_export_of_a_display_p3_file_converts_back() { + let (mut s, r) = session(); + s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap(); + let same = export(&mut s, json!({"ids": [r], "format": "png"})); + assert!(has_chunk(&same, b"iCCP", None)); + assert!(close(middle(&same), red_in_p3(), 1), "{:?}", middle(&same)); + for p in ["srgb", "displayP3", "sameAsFile"] { + let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": p})); + assert_eq!(has_chunk(&png, b"sRGB", None), p == "srgb", "{p}"); + let want = if p == "srgb" { [255, 0, 0] } else { red_in_p3() }; + assert!(close(middle(&png), want, 1), "{p}: {:?} vs {want:?}", middle(&png)); + } + // A wide-gamut color clips in sRGB and keeps its values in Display P3. + s.execute("fill.set", &json!({"ids": [r], "color": "#00FF00"})).unwrap(); + let srgb = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "srgb"})); + let p3 = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"})); + assert_eq!(middle(&p3), [0, 255, 0]); + let [red, green, blue] = middle(&srgb); + assert!(red == 0 && green == 255 && blue == 0, "P3 green clips to sRGB green: {:?}", middle(&srgb)); +} + +#[test] +fn jpg_and_webp_follow_the_setting() { + let (mut s, r) = session(); + for f in ["jpg", "webp"] { + // sRGB JPGs embed their profile and sRGB WebPs stay untagged (read as sRGB); Display P3 files + // embed the generated profile. + assert_eq!(has_icc(&export(&mut s, json!({"ids": [r], "format": f}))), f == "jpg", "{f}"); + let p3 = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "displayP3"})); + assert!(has_icc(&p3), "{f}"); + let want = red_in_p3(); + assert!(close(middle(&p3), want, 8), "{f}: {:?} vs {want:?}", middle(&p3)); + } + s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap(); + for f in ["jpg", "webp"] { + let srgb = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "srgb"})); + assert_eq!(has_icc(&srgb), f == "jpg", "{f}"); + assert!(close(middle(&srgb), [255, 0, 0], 8), "{f}: {:?}", middle(&srgb)); + } +} + +#[test] +fn a_translucent_jpg_is_flattened_before_its_conversion() { + let (mut s, r) = session(); + s.execute("fill.set", &json!({"ids": [r], "opacity": 0.5})).unwrap(); + // The sRGB document shows 50% red over white; the Display P3 JPG holds that color converted. + let flat = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "srgb"})); + let [fr, fg, fb] = middle(&flat); + let shown = Color::new(f32::from(fr) / 255.0, f32::from(fg) / 255.0, f32::from(fb) / 255.0, 1.0); + let want = rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(shown)); + let p3 = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "displayP3"})); + assert!(close(middle(&p3), want, 2), "{:?} vs {want:?} (sRGB {:?})", middle(&p3), [fr, fg, fb]); +} + +#[test] +fn svg_pdf_and_xml_stay_srgb() { + let (mut s, r) = session(); + // A Display P3 file: its red converts back to sRGB #FF0000 whatever the setting says. + s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap(); + for p in ["displayP3", "sameAsFile", "srgb"] { + let svg = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "svg", "colorProfile": p}))).unwrap().to_ascii_uppercase(); + assert!(svg.contains("#FF0000"), "{p}: {svg}"); + let xml = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "xml", "colorProfile": p}))).unwrap().to_ascii_uppercase(); + assert!(xml.contains("#FF0000"), "{p}: {xml}"); + let pdf = export(&mut s, json!({"ids": [r], "format": "pdf", "colorProfile": p})); + assert!(pdf.starts_with(b"%PDF") && !has_icc(&pdf), "{p}"); + } +} + +#[test] +fn same_as_file_follows_the_document_and_a_choice_stays() { + let (mut s, r) = session(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-srgb", "colorProfile": "srgb"}})).unwrap(); + s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-p3", "colorProfile": "displayP3"}})).unwrap(); + let files = |s: &mut Session| -> Vec<(String, bool)> { + let v = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap(); + v["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), has_chunk(&bytes(f), b"iCCP", None))).collect() + }; + let names = |icc: [bool; 3]| -> Vec<(String, bool)> { + ["Red.png", "Red-srgb.png", "Red-p3.png"].into_iter().zip(icc).map(|(n, i)| (n.to_string(), i)).collect() + }; + assert_eq!(files(&mut s), names([false, false, true])); + s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap(); + assert_eq!(files(&mut s), names([true, false, true])); + // A profile passed to export layers stands in for every setting's own. + let v = s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": "srgb"})).unwrap(); + assert!(v["files"].as_array().unwrap().iter().all(|f| has_chunk(&bytes(f), b"sRGB", None)), "{v}"); +} + +#[test] +fn export_settings_keep_their_color_profile() { + let (mut s, r) = session(); + s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": "displayP3"}})).unwrap(); + let e = s.doc().unwrap().node(r).unwrap().exports[0].clone(); + assert_eq!(e.color_profile, ExportColorProfile::DisplayP3); + // Stored camelCase, the default left out, and a row saved without it reads as same as file. + assert_eq!(serde_json::to_value(&e).unwrap()["colorProfile"], "displayP3"); + assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("colorProfile").is_none()); + let old: ExportSetting = serde_json::from_value(json!({"size": {"by": "scale", "value": 2.0}, "suffix": "@2x", "format": "jpg"})).unwrap(); + assert_eq!(old.color_profile, ExportColorProfile::SameAsFile); + // Saved and loaded. + let saved = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&saved).unwrap(); + assert_eq!(back.node(r).unwrap().exports[0], e); + // Edited and undone; a format switch keeps it. + s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": "srgb"})).unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::Srgb); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3); + for f in ["svg", "pdf", "png"] { + s.execute("export.update", &json!({"ids": [r], "index": 0, "format": f})).unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3, "{f}"); + } + // `null` changes nothing. + s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": null})).unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3); + for p in ExportColorProfile::ALL { + assert_eq!(ExportColorProfile::from_key(p.key()), Some(p)); + } +} + +#[test] +fn bad_color_profiles_are_refused_and_change_nothing() { + let (mut s, r) = session(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + for junk in [json!("cmyk"), json!(3), json!(true), json!("SRGB"), json!({})] { + assert!(s.execute("file.export", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}"); + assert!(s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": junk})).is_err(), "{junk}"); + assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": junk}})).is_err(), "{junk}"); + assert!(s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}"); + } + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.exports.len(), n.exports[0].color_profile), (1, ExportColorProfile::SameAsFile)); + let err = s.execute("file.export", &json!({"ids": [r], "colorProfile": "p3"})).unwrap_err().to_string(); + assert!(err.contains("sameAsFile|srgb|displayP3"), "{err}"); +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index adf6310b7..fea0f531b 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -366,6 +366,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[ (Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"), (Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"), (Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"), + (Arrange, "Edit padding on all sides", "⌘ click a padding field", "Ctrl+click a padding field"), (Arrange, "Set hug contents", "Double-click an edge of an auto layout frame", "Double-click an edge of an auto layout frame"), (Arrange, "Set fill container", "⌥ double-click an edge of an auto layout child", "Alt+double-click an edge of an auto layout child"), (Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3b9495a20..302e7053e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -317,6 +317,8 @@ mod export_bench_tests; #[cfg(test)] mod export_depth_tests; #[cfg(test)] +mod export_profile_tests; +#[cfg(test)] mod extended_chain_tests; #[cfg(test)] mod eyedropper_create_tests; diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index ac01ad429..4d19617ce 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -18,6 +18,8 @@ pub(crate) struct Script { pub(crate) sent: Vec, pub(crate) hosted: Vec, pub(crate) joined: Vec, + /// What the last join asked to resume as. + pub(crate) resumed: Option, /// The sessions told to reconnect now, in order. pub(crate) retried: Vec, pub(crate) left: usize, @@ -48,6 +50,11 @@ impl LiveLink for Mock { i.url = format!("ws://{}/?file=f&token=t", i.address); Ok(i) } + fn join_resuming(&mut self, session: u64, link: &str, name: &str, resume: Option) -> Result<(), String> { + self.0.lock().unwrap().resumed = resume; + self.join(session, link, name) + } + fn retry(&mut self, session: u64) { self.0.lock().unwrap().retried.push(session); } diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index adb5fa651..1d41d0912 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -152,6 +152,8 @@ struct Stranded { link: String, /// The server's state when it stopped: what's different from it in the tab is your own work. confirmed: Box, + /// Who you were: the layers you made are in that client's id space. + resume: Resume, } impl Live { @@ -182,17 +184,19 @@ impl Live { *self = Live { id: self.id, tab: self.tab, ..Live::default() }; } - /// Keep this session on its tab for joining again (see [`Stranded`]): a session you joined, or - /// one that already is; any other goes back to no session. - fn strand(&mut self) { + /// Remember what joining this session again takes, if it is one you joined (see [`Stranded`]). + fn hold(&mut self) { let joined = self.info.as_ref().filter(|i| i.role == Role::Guest && !i.link.is_empty()); - let held = self.stranded.take().or_else(|| { - let (client, info) = (self.client.as_ref()?, joined?); - Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()) }) - }); - if held.is_none() { - return self.reset(); + if let (None, Some(client), Some(info)) = (&self.stranded, &self.client, joined) { + self.stranded = Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()), resume: client.resume() }); } + } + + /// Keep this session on its tab for joining again: a session you joined, or one that already + /// is; any other goes back to no session. + fn strand(&mut self) { + self.hold(); + let Some(held) = self.stranded.take() else { return self.reset() }; *self = Live { id: self.id, tab: self.tab, @@ -201,7 +205,7 @@ impl Live { server: std::mem::take(&mut self.server), published: self.published.take(), share: self.share.take(), - stranded: held, + stranded: Some(held), ..Live::default() }; } diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index 5bc94c0f4..86473ea52 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -1,8 +1,11 @@ use super::host::{clean_hosting, clean_shared}; +use figcraft_collab::Change; +use figcraft_collab::merge::{self, Side}; + use super::types::{Event, MAX_PEERS, PageInfo, Role, SessionId, SessionInfo, Status}; use super::{ - Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal, - ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, + Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, Resume, ServerMsg, Session, + Signal, ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, }; /// What a live session's document holding components that contain each other's instances shows @@ -371,11 +374,12 @@ impl Session { /// Session `id`'s welcome: start the sync client; the host keeps editing the document it /// shared, a guest opens the host's file as a new tab (or gets it again in that tab after a /// reconnect). - fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { - let client = match Client::join(welcome) { + pub(super) fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { + let mut client = match Client::join(welcome) { Ok(c) => c, Err(e) => return self.live_fail(id, format!("Couldn't open the shared file: {e}")), }; + let mut merged = None; let Some(live) = self.live.sessions.get(&id) else { return }; let existing = live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u)); let (uid, synced) = match (live.role(), existing) { @@ -396,7 +400,7 @@ impl Session { } (Some(Role::Guest), Some(uid)) => { // Joining again in this tab, or the file you published arriving from the server. - self.live_keep_offline_work(id, uid); + merged = self.live_merge_offline_work(id, uid, &mut client); let doc = Arc::new(client.document().clone()); let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty); self.apply_remote(uid, doc.clone()); @@ -444,31 +448,50 @@ impl Session { live.client = Some(client); live.deferred.clear(); live.stranded = None; + self.live.send(id, merged); } /// Session `id` is back on document `uid` after it couldn't go on, and the server's version is - /// about to replace the tab's: what you did since opens beside it, unsaved, so nothing is lost. - fn live_keep_offline_work(&mut self, id: SessionId, uid: u64) { - let Some(held) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()) else { return }; - let Some(local) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return }; + /// about to replace the tab's: what you did since goes on top of it as one edit of `client`, + /// returned to send, the way two people's edits merge. What can't (the same thing changed both + /// ways, or layers `client` can't make) leaves the server's as it is, and your whole version opens + /// beside it, unsaved, so nothing is lost. + fn live_merge_offline_work(&mut self, id: SessionId, uid: u64, client: &mut Client) -> Option { + let held = self.live.sessions.get(&id)?.stranded.as_ref()?; + let local = self.docs.iter().find(|d| d.uid == uid)?.doc.clone(); let name = local.name.clone(); - // A document that can't be compared is kept too. - if figcraft_collab::diff(&held.confirmed, &local).is_ok_and(|d| d.changes.is_empty()) { + let plan = merge::plan(held.confirmed.document(), &local, client.document()).ok(); + let mine = plan.as_ref().map(|p| p.changes(Side::Branch)); + if mine.is_some_and(<[Change]>::is_empty) { self.message = Some(format!("Reconnected to {name}")); - return; + return None; + } + let me = u64::from(client.id()); + let own = mine.is_some_and(|m| m.iter().all(|c| !matches!(c, Change::Create { id, .. } if id.0 >> 32 != me))); + let merged = plan.as_ref().filter(|_| own).and_then(|p| p.merge(client.document(), Side::Branch, &|_| Some(Side::Main)).ok()); + let sent = merged.and_then(|(mut doc, touched)| { + doc.mark_dirty(touched); + client.edit_with(&doc, Some("Changes made while disconnected")).ok() + }); + if sent.is_some() && plan.is_some_and(|p| p.conflicts.is_empty()) { + self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is in the file")); + return sent.flatten(); } let mut copy = DocState::new((*local).clone(), None); let copy_name = format!("{name} (offline copy)"); Arc::make_mut(&mut copy.doc).name.clone_from(©_name); self.docs.push(copy); - self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is open beside it as “{copy_name}”")); + self.message = Some(format!( + "Reconnected to {name}. Some of what you changed while disconnected couldn't go in: your whole version is open beside it as “{copy_name}”" + )); + sent.flatten() } - /// Join session `id` again in its tab (see [`Live::strand`]), as someone new: a fresh join needs - /// nothing from the connection that was lost. + /// Join session `id` again in its tab (see [`Live::strand`]): nothing is needed from the + /// connection that was lost. pub(super) fn live_rejoin(&mut self, cmd: &str, id: SessionId) -> Result<()> { let name = display_name(&self.prefs); - let Some(link) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| s.link.clone()) else { + let Some((link, resume)) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| (s.link.clone(), s.resume)) else { return Err(crate::bad(cmd, "this file has no live session to join again")); }; self.live.leave(id); @@ -476,7 +499,9 @@ impl Session { return Err(EngineError::Disabled(cmd.to_string(), why)); } let Some(net) = self.live.link() else { return Err(super::unavailable(cmd)) }; - net.join_resuming(id, &link, &name, None).map_err(|e| crate::bad(cmd, e))?; + // As the client you were when the server still knows it, so the layers you made are yours to + // send; the whole document is asked for (no change is "after" this number). + net.join_resuming(id, &link, &name, Some(Resume { seq: u64::MAX, ..resume })).map_err(|e| crate::bad(cmd, e))?; if let Some(live) = self.live.sessions.get_mut(&id) { live.status = Status::Connecting; } diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 866510744..badc1fd91 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -222,7 +222,7 @@ pub fn specs() -> Vec { spec!( "collab.reconnect", "Reconnect live session", - "{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since opening beside it. Refused when nothing was lost", + "{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since going on top (your whole version opening beside it when some of it can't). Refused when nothing was lost", false, reconnect ), diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs index ac098c04e..4aa04c85b 100644 --- a/crates/engine/src/sharing/live/sync.rs +++ b/crates/engine/src/sharing/live/sync.rs @@ -1,8 +1,8 @@ use super::host::hosting_json; use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status}; use super::{ - Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code, - editing_of, json, + Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, ServerMsg, Session, Value, ViewState, bad, busy, + close, code, editing_of, json, }; impl Session { @@ -115,7 +115,23 @@ impl Session { let Some(live) = self.live.sessions.get_mut(&id) else { return }; let Some(client) = live.client.as_mut() else { return }; let mut out = Vec::new(); - for m in std::mem::take(&mut live.deferred) { + let mut waiting = std::mem::take(&mut live.deferred).into_iter(); + while let Some(m) = waiting.next() { + // The server no longer knows this client (it restarted without its saved state, say): + // it welcomed you as someone new. What you did goes on top like after any rejoin. + if let ServerMsg::Welcome { session, client_id, .. } = &m { + let me = client.resume(); + if (*session, *client_id) != (me.session, me.client_id) { + live.hold(); + live.client = None; + let rest: Vec<_> = waiting.collect(); + self.live_welcome(id, m); + if let Some(live) = self.live.sessions.get_mut(&id) { + live.deferred = rest; + } + return; + } + } match client.receive(m, &EngineRecompute) { Ok(more) => out.extend(more), Err(e) => { diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index 46eb14447..c2d400589 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -279,6 +279,7 @@ static ROWS: &[Row] = &[ doc(Arrange, "Add auto layout", &["Shift+A"], &[], Bound("layout.add"), ""), doc(Arrange, "Remove auto layout", &["Alt+Shift+A"], &[], Bound("layout.remove"), ""), doc(Arrange, "Suggest auto layout", &["Ctrl+Shift+A"], &["Ctrl+Alt+Shift+A"], Bound("layout.suggest"), "was unbound"), + doc(Arrange, "Edit padding on all sides (from right panel)", &["Cmd"], &["Ctrl"], Gesture("Edit padding on all sides"), "held while clicking a padding field: the value typed goes to every side"), pin(Arrange, "distribute.horizontal", &["Ctrl+Alt+H"], &["Alt+Shift+H"]), pin(Arrange, "distribute.vertical", &["Ctrl+Alt+V"], &["Alt+Shift+V"]), pin(Arrange, "arrange.tidy", &["Ctrl+Alt+T"], &["Ctrl+Alt+Shift+T"]), @@ -413,7 +414,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), (134, 133, 1, 0, 2, 57)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (135, 134, 1, 0, 2, 57)); } #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 23f871b92..9e901f5bb 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -262,7 +262,7 @@ fn tools() -> Vec { tool( "export", "Export", - "Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.", + "Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`; for png, jpg and webp `colorProfile`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.", obj( json!({ "ids": ids_schema("Layers to export (several are composited into one image)"), @@ -280,6 +280,7 @@ fn tools() -> Vec { "svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"), "svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"), "vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"}, + "colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, PDF and XML are always sRGB"}, "quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"}, }), &[], @@ -1060,6 +1061,7 @@ fn export(b: &mut dyn Backend, a: &Map) -> Result Vec { + let r = c.call("export", args); + assert_eq!(r["isError"], false, "{r}"); + let image = r["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap(); + base64::engine::general_purpose::STANDARD.decode(image["data"].as_str().unwrap()).unwrap() +} + +fn has_chunk(bytes: &[u8], kind: &[u8; 4]) -> bool { + bytes.windows(4).any(|w| w == kind) +} + +#[test] +fn color_profile_tags_the_png() { + let mut c = Client::new(); + let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 24, "height": 16})); + let srgb = png(&mut c, json!({"ids": [id], "format": "png"})); + assert!(has_chunk(&srgb, b"sRGB") && !has_chunk(&srgb, b"iCCP")); + let p3 = png(&mut c, json!({"ids": [id], "format": "png", "colorProfile": "displayP3"})); + assert!(has_chunk(&p3, b"iCCP") && has_chunk(&p3, b"cICP") && !has_chunk(&p3, b"sRGB")); + assert!(c.err("export", json!({"ids": [id], "format": "png", "colorProfile": "cmyk"})).contains("colorProfile")); +} diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index 862f46064..7d5195fe7 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -13,6 +13,8 @@ mod common; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod export_hidden_roots; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod export_profile; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod export_xml; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod garbage; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 4711ccd01..38020e5c9 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -388,6 +388,8 @@ name = "English" "Crop marks" = "Crop marks" "Ctrl+arrows" = "Ctrl+arrows" "Ctrl+click" = "Ctrl+click" +"Ctrl+click a padding field" = "Ctrl+click a padding field" +"⌘ click a padding field" = "⌘ click a padding field" "Ctrl+scroll" = "Ctrl+scroll" "Current name" = "Current name" "Current version" = "Current version" @@ -522,6 +524,7 @@ name = "English" "Edit dev resource" = "Edit dev resource" "Edit link" = "Edit link" "Edit link copied" = "Edit link copied" +"Edit padding on all sides" = "Edit padding on all sides" "Edit style" = "Edit style" "Edit variable" = "Edit variable" "Edit with the builder" = "Edit with the builder" @@ -1327,6 +1330,7 @@ name = "English" "See all templates" = "See all templates" "Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes" "SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as file ({profile})" = "Same as file ({profile})" "Same as layer" = "Same as layer" "Saturation" = "Saturation" "Save" = "Save" diff --git a/crates/ui-egui/locales/zh-Hant.toml b/crates/ui-egui/locales/zh-Hant.toml new file mode 100644 index 000000000..f14698b00 --- /dev/null +++ b/crates/ui-egui/locales/zh-Hant.toml @@ -0,0 +1,2659 @@ +# FigCraft interface strings: Traditional Chinese (Taiwan). +# +# Draft, translated from the English source (locales/en.toml); needs a native speaker's +# review. Taiwan terms: 畫布, 圖層, 框架, 元件, 原型, Auto Layout, 約束, 填色, 邊框, 效果, +# 匯出, 版本. Placeholders and command ids stay as in English. + +[meta] +name = "繁體中文(台灣)" +status = "draft — needs native review" + +[ui] +"% of all commits" = "所有提交的百分比" +"(no longer published)" = "(已不再發佈)" +"({n} warnings: some entries were skipped)" = { other = "({n} 個警告:部分項目已略過)" } +"+ Create variable" = "+ 建立變數" +".fig (experimental)" = ".fig (實驗性)" +"1 year" = "1 年" +"1…9, 0" = "1…9, 0" +"30 days" = "30 天" +"90 days" = "90 天" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 個 PR、{commits} 次提交,+{added} / −{deleted} 行 (Δ {delta}),+{bin_added} / −{bin_deleted} 個二進位資源,{first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "此名稱的 .figcraft 檔案已存在。請選擇完整檔名以確認取代。" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "此檔案的集合,包含這些模式與變數:依模式覆寫其值,並加入專屬變數" +"A later vote" = "較晚的投票" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "單一圖層會對齊其框架:請選取更多圖層,或選取框架內的圖層" +"A responsive product page: hero, features, figures, a quote and a footer" = "響應式產品頁面:主視覺、功能、資料、引言與頁尾" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "彈簧會持續播放直到穩定:其質量、勁度與阻尼決定持續時間" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "表格最多接受 {rows} 列與 {columns} 欄:其餘內容未貼上" +"A title, an agenda, a big figure, a chart and a closing slide" = "標題、議程、大型資料、圖表與結尾投影片" +"A to Z" = "A 到 Z" +"About" = "關於" +"About FigCraft" = "關於 FigCraft" +"About this server" = "關於此伺服器" +"Action" = "動作" +"Actions" = "動作" +"Actions for {name}" = "{name} 的動作" +"Actions…" = "動作…" +"Active" = "啟用中" +"Actual size (100%)" = "實際大小 (100%)" +"Add a category" = "新增類別" +"Add a comment" = "新增留言" +"Add a connected shape" = "新增連接圖形" +"Add a description" = "新增說明" +"Add a frame (F) or a shape (R) to get started" = "新增框架 (F) 或圖形 (R) 以開始" +"Add a layer or component to tile first" = "先新增圖層或元件以並排" +"Add a minute" = "新增一分鐘" +"Add a note for developers" = "為開發人員新增附註" +"Add action" = "新增動作" +"Add actions for when no condition holds" = "新增無條件成立時執行的動作" +"Add an annotation for developers" = "為開發人員新增註解" +"Add another condition" = "新增另一個條件" +"Add auto layout" = "新增 Auto Layout" +"Add background behind overlay" = "在覆蓋層後方新增背景" +"Add column" = "新增欄" +"Add content to this slot" = "將內容新增至此插槽" +"Add dev resource" = "新增開發資源" +"Add effect" = "新增效果" +"Add export setting" = "新增匯出設定" +"Add fill" = "新增填色" +"Add interaction" = "新增互動" +"Add layout grid" = "新增版面格線" +"Add library" = "新增資源庫" +"Add library file…" = "新增資源庫檔案…" +"Add max height" = "新增最大高度" +"Add max width" = "新增最大寬度" +"Add measurement" = "新增測量" +"Add min height" = "新增最小高度" +"Add min width" = "新增最小寬度" +"Add mode" = "新增模式" +"Add new page" = "新增頁面" +"Add or update collections from a variables JSON" = "從變數 JSON 新增或更新集合" +"Add properties to make instances configurable" = "新增屬性讓實例可設定" +"Add reaction" = "新增反應" +"Add row" = "新增列" +"Add starting point" = "新增起始點" +"Add stroke" = "新增邊框" +"Add variant" = "新增變體" +"Add variant with a new value" = "以新值新增變體" +"Added" = "已新增" +"Added the library \"{name}\"" = "已新增資源庫「{name}」" +"Adding to {name}" = "正在新增至 {name}" +"Adjust" = "調整" +"Adjustment presets" = "調整預設集" +"Advanced" = "進階" +"Advanced stroke" = "進階邊框" +"After delay" = "延遲後" +"After delay {ms}ms" = "延遲 {ms}ms 後" +"Agent" = "代理程式" +"Align" = "對齊" +"Align bottom" = "對齊底部" +"Align center" = "置中" +"Align horizontal centers" = "水平置中對齊" +"Align left" = "靠左對齊" +"Align middle" = "對齊中間" +"Align right" = "靠右對齊" +"Align text baselines" = "對齊文字基準線" +"Align top" = "靠上對齊" +"Align vertical centers" = "垂直置中對齊" +"Alignment" = "對齊方式" +"All" = "全部" +"All categories" = "所有類別" +"All comments" = "所有留言" +"All components" = "所有元件" +"All fonts" = "所有字型" +"All objects" = "所有物件" +"All supported properties" = "所有支援的屬性" +"All the fonts this file uses are installed" = "此檔案使用的所有字型皆已安裝" +"All variables" = "所有變數" +"Allow and download" = "允許並下載" +"Allow downloading fonts?" = "允許下載字型?" +"Along the bottom" = "沿底部" +"Along the left" = "沿左側" +"Along the right" = "沿右側" +"Along the top" = "沿頂部" +"Alpha" = "Alpha" +"Alphabetical" = "依字母順序" +"Alt+double-click an edge of an auto layout child" = "Alt + 按兩下 Auto Layout 子物件的邊緣" +"Alt+drag" = "Alt + 拖曳" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "格線上的分析畫面:資料、以向量繪製的圖表與表格" +"An earlier vote" = "較早的投票" +"An invite link that opens FigCraft and joins" = "可開啟 FigCraft 並加入的邀請連結" +"Anchor" = "錨點" +"Android" = "Android" +"Android Vector Drawable" = "Android Vector Drawable" +"Angular" = "角度" +"Animation" = "動畫" +"Annotations" = "註解" +"Annotations shown on the canvas" = "畫布上顯示的註解" +"Another server, or one that needs its key. The usual server is set in Preferences." = "其他伺服器,或需要金鑰的伺服器。一般伺服器可在偏好設定中設定。" +"Any status" = "任何狀態" +"Anyone on your network with this code can edit the file, while this app stays open." = "在你保持此應用程式開啟時,網路上任何有此代碼的人都能編輯檔案。" +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "任何擁有編輯連結的人都能變更檔案;檢視連結僅供查看。連結含有其密鑰:請像密碼一樣分享。`figcraft-server links` 可建立更多連結並撤銷現有連結。" +"Anyone with this code can edit the file." = "任何有此代碼的人都能編輯檔案。" +"Anyone with this code can look at the file and comment on it, not change it." = "任何有此代碼的人都能查看檔案並留言,但不能變更。" +"Anyone with this code can look at the file, not change it." = "任何有此代碼的人都能查看檔案,但不能變更。" +"Appearance" = "外觀" +"Apply a component property" = "套用元件屬性" +"Apply variable" = "套用變數" +"Apply variable to text" = "將變數套用至文字" +"Apply variable to visibility" = "將變數套用至可見度" +"Apps" = "應用程式" +"Apr" = "4 月" +"Arc" = "弧形" +"Arc ratio" = "弧形比例" +"Arc start" = "弧形起點" +"Arc sweep" = "弧形掃掠" +"Archive" = "封存" +"Archived" = "已封存" +"Arrange" = "排列" +"Arrow" = "箭頭" +"Arrow left" = "左箭頭" +"Arrow right" = "右箭頭" +"As typed" = "依輸入內容" +"Asking the server…" = "正在詢問伺服器…" +"Assets" = "資源" +"Aug" = "8 月" +"Auto" = "自動" +"Auto (space around)" = "自動 (環繞留白)" +"Auto (space between)" = "自動 (平均間距)" +"Auto (space evenly)" = "自動 (平均分配)" +"Auto ({mode})" = "自動 ({mode})" +"Auto columns" = "自動欄" +"Auto gap" = "自動間距" +"Auto height" = "自動高度" +"Auto layout" = "Auto Layout" +"Auto rows" = "自動列" +"Auto width" = "自動寬度" +"Auto: as many words as fit on each line" = "自動:每行容納盡可能多的單字" +"Back" = "返回" +"Back to Ready for dev" = "返回「準備交付開發」" +"Back to Ready for dev: the next change compares with how it is now" = "返回「準備交付開發」:下次變更將與目前狀態比較" +"Back to assets" = "返回資源" +"Back to current" = "返回目前版本" +"Back to styles" = "返回樣式" +"Back to the FigCraft server" = "返回 FigCraft 伺服器" +"Back to the canvas from a focused region" = "從聚焦區域返回畫布" +"Back to the current version" = "返回目前版本" +"Back to the font's value" = "返回字型設定值" +"Back to the values the font style implies" = "返回字型樣式隱含的值" +"Background" = "背景" +"Background blur" = "背景模糊" +"Balance" = "平衡" +"Balance: every line close to the same length" = "平衡:每一行長度盡量相近" +"Basics" = "基本" +"Before" = "之前" +"Bend" = "彎曲" +"Best effort" = "盡力而為" +"Between the selected layers" = "在選取的圖層之間" +"Bevel" = "斜角" +"Binary assets added" = "已新增二進位資源" +"Binary assets removed" = "已移除二進位資源" +"Black & white" = "黑白" +"Bleed" = "出血" +"Bleed marks" = "出血標記" +"Bleed marks need a bleed" = "出血標記需要出血設定" +"Blend" = "混合" +"Blend mode" = "混合模式" +"Blue" = "藍色" +"Blur" = "模糊" +"Board grid" = "白板格線" +"Boolean" = "布林" +"Boolean groups" = "布林群組" +"Both directions" = "雙向" +"Bottom" = "底部" +"Bottom left radius" = "左下圓角半徑" +"Bottom padding" = "下內距" +"Bottom right radius" = "右下圓角半徑" +"Bound to {name}. Click to detach the variable." = "已綁定至 {name}。點擊以分離變數。" +"Brainstorm" = "腦力激盪" +"Branch" = "分支" +"Branch archived" = "分支已封存" +"Branch merged" = "分支已合併" +"Branch name" = "分支名稱" +"Branch of {file}" = "{file} 的分支" +"Branch of {file} · merged or archived" = "{file} 的分支 · 已合併或封存" +"Branch updated from main" = "分支已從主線更新" +"Branch {name}" = "分支 {name}" +"Branches" = "分支" +"Bring forward" = "上移一層" +"Bring the main file's changes into the branch" = "將主檔案的變更帶入分支" +"Bring to front" = "移至最上層" +"Bristle" = "鬃毛" +"Browse" = "瀏覽" +"Brush" = "筆刷" +"Brush rotation jitter" = "筆刷旋轉抖動" +"Brush scatter" = "筆刷散佈" +"Brush size jitter" = "筆刷大小抖動" +"Brush spacing" = "筆刷間距" +"Bulleted list" = "項目符號清單" +"C or Shift+C" = "C 或 Shift+C" +"C or ⇧C" = "C 或 ⇧C" +"CMYK colour" = "CMYK 顏色" +"CSS custom properties…" = "CSS 自訂屬性…" +"Calculated from other variables; it follows them" = "由其他變數計算;會跟隨這些變數" +"Can comment" = "可留言" +"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "可留言:你能即時看到每項變更並留言,但不能編輯。離開即可保留一份可編輯的副本。" +"Can edit" = "可編輯" +"Can view" = "可檢視" +"Can't read this library: {e}" = "無法讀取此資源庫:{e}" +"Cancel" = "取消" +"Canvas" = "畫布" +"Canvas stacking" = "畫布堆疊" +"Cap height to baseline" = "大寫高度至基準線" +"Case" = "大小寫" +"Case-sensitive forms" = "大小寫感應形式" +"Categories" = "類別" +"Center" = "置中" +"Change alias" = "變更別名" +"Change resolved" = "變更已解析" +"Change style" = "變更樣式" +"Change to" = "變更為" +"Change to needs a layer inside a variant" = "「變更為」需要變體內的圖層" +"Changed" = "已變更" +"Changed on both sides: keep one" = "兩側皆有變更:保留其中一個" +"Changed {date}" = "已變更 {date}" +"Changes on main" = "主線上的變更" +"Changes on the branch" = "分支上的變更" +"Changes unknown" = "變更未知" +"Check" = "檢查" +"Check for Updates" = "檢查更新" +"Check for updates" = "檢查更新" +"Check for updates…" = "檢查更新…" +"Checking the fonts installed on this computer…" = "正在檢查此電腦安裝的字型…" +"Checking the server…" = "正在檢查伺服器…" +"Chevron" = "箭號" +"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "請在上方選擇 React、Leptos、Vue、SwiftUI、Compose、ArkUI 或 Flutter,將此元件連接至程式碼。" +"Choose another profile to change it." = "選擇其他設定檔以變更。" +"Choose collection" = "選擇集合" +"Choose image" = "選擇圖片" +"Choose mode" = "選擇模式" +"Choose variable" = "選擇變數" +"Circle" = "圓形" +"Clear from this list" = "從此清單清除" +"Clear recent" = "清除最近項目" +"Clear results" = "清除結果" +"Click + to replace mixed content" = "點擊 + 以取代混合內容" +"Click anywhere on the canvas to leave one." = "點擊畫布任意處即可離開。" +"Click objects to vote; click your badge to take a vote back" = "點擊物件投票;點擊你的徽章可收回一票" +"Click or drag to merge · {alt} removes" = "點擊或拖曳以合併 · {alt} 移除" +"Click or drag to remove · {alt} merges" = "點擊或拖曳以移除 · {alt} 合併" +"Click to record a key" = "點擊以錄製按鍵" +"Clip content" = "裁切內容" +"Clip to shape" = "裁切至圖形" +"Close" = "關閉" +"Close overlay" = "關閉覆蓋層" +"Close preview" = "關閉預覽" +"Close tab" = "關閉分頁" +"Close version history" = "關閉版本記錄" +"Close when clicking outside" = "點擊外部時關閉" +"Code" = "程式碼" +"Code or invite link" = "代碼或邀請連結" +"Code syntax" = "程式碼語法" +"Collapse" = "收合" +"Collection" = "集合" +"Collections" = "集合" +"Color" = "顏色" +"Color burn" = "色彩加深" +"Color dodge" = "色彩加亮" +"Color format" = "顏色格式" +"Color profile" = "色彩設定檔" +"Color profile: {name}" = "色彩設定檔:{name}" +"Color styles" = "顏色樣式" +"Color values in this file are in this color space." = "此檔案的顏色值位於此色彩空間。" +"Color variables" = "顏色變數" +"Colour" = "顏色" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "顏色與文字樣式、淺色與深色變數、按鈕、輸入欄位與卡片" +"Column" = "欄" +"Column gap" = "欄間距" +"Column sizes" = "欄大小" +"Column span" = "欄合併" +"Columns" = "欄" +"Columns {count}" = "欄 {count}" +"Comment" = "留言" +"Comments" = "留言" +"Commits" = "提交" +"Company" = "公司" +"Compare changes" = "比較變更" +"Compare with" = "比較對象" +"Compatibility" = "相容性" +"Compatibility notes" = "相容性附註" +"Completed" = "已完成" +"Component" = "元件" +"Component name" = "元件名稱" +"Component property" = "元件屬性" +"Component set" = "元件集" +"Component sets" = "元件集" +"Components" = "元件" +"Conditional" = "條件式" +"Confetti" = "彩紙" +"Conflicts" = "衝突" +"Connect to code" = "連接至程式碼" +"Connected" = "已連接" +"Connected to code: {from}" = "已連接至程式碼:{from}" +"Connection lost, reconnecting (attempt {attempt})…" = "連線中斷,正在重新連線 (第 {attempt} 次嘗試)…" +"Connector" = "連接線" +"Constrain proportions" = "維持比例" +"Constraints" = "約束" +"Content" = "內容" +"Contents hidden" = "內容已隱藏" +"Contextual alternates" = "上下文替代字" +"Contrast" = "對比" +"Contributors" = "貢獻者" +"Contributors: {people} · Commits: {commits}" = "貢獻者:{people} · 提交:{commits}" +"Controller" = "控制器" +"Convert to frame" = "轉換為框架" +"Convert to section" = "轉換為區段" +"Cool" = "冷色" +"Copied as CSS" = "已複製為 CSS" +"Copied as PNG" = "已複製為 PNG" +"Copied as SVG" = "已複製為 SVG" +"Copied as text" = "已複製為文字" +"Copied comments for AI" = "已複製供 AI 使用的留言" +"Copied {value}" = "已複製 {value}" +"Copy" = "複製" +"Copy as" = "複製為" +"Copy as CSS" = "複製為 CSS" +"Copy as PNG" = "複製為 PNG" +"Copy as SVG" = "複製為 SVG" +"Copy browser link" = "複製瀏覽器連結" +"Copy code" = "複製程式碼" +"Copy effect" = "複製效果" +"Copy fill" = "複製填色" +"Copy for AI" = "複製供 AI 使用" +"Copy link" = "複製連結" +"Copy stroke" = "複製邊框" +"Copy the {label}" = "複製{label}" +"Copy {value}" = "複製 {value}" +"Corner radius" = "圓角半徑" +"Corner smoothing" = "圓角平滑度" +"Couldn't download the variables" = "無法下載變數" +"Couldn't download {name}" = "無法下載 {name}" +"Couldn't reach {server} for its team list." = "無法連線至 {server} 以取得團隊清單。" +"Couldn't reach {server}: showing what this computer remembers." = "無法連線至 {server}:顯示此電腦記住的內容。" +"Couldn't read {name}." = "無法讀取 {name}。" +"Couldn't save automatically: {error}" = "無法自動儲存:{error}" +"Couldn't save the file" = "無法儲存檔案" +"Count" = "數量" +"Create" = "建立" +"Create a variable collection first" = "請先建立變數集合" +"Create a variable first" = "請先建立變數" +"Create alias" = "建立別名" +"Create branch" = "建立分支" +"Create collection" = "建立集合" +"Create component" = "建立元件" +"Create component property" = "建立元件屬性" +"Create components, styles or variables to publish them as a library." = "建立元件、樣式或變數,即可將其發佈為資源庫。" +"Create from color" = "從顏色建立" +"Create property…" = "建立屬性…" +"Create style" = "建立樣式" +"Create style from the selected text" = "從選取的文字建立樣式" +"Created" = "已建立" +"Crop" = "裁切" +"Crop image" = "裁切圖片" +"Crop marks" = "裁切標記" +"Ctrl+Alt+← ↑ → ↓" = "Ctrl+Alt+← ↑ → ↓" +"Ctrl+arrows" = "Ctrl+方向鍵" +"Ctrl+click" = "Ctrl+點擊" +"Ctrl+scroll" = "Ctrl+捲動" +"Current name" = "目前名稱" +"Current version" = "目前版本" +"Cursor chat" = "游標聊天" +"Curved" = "曲線" +"Custom" = "自訂" +"Custom size" = "自訂大小" +"Cut" = "剪下" +"Dark" = "深色" +"Darken" = "變暗" +"Dart constants for Flutter…" = "Flutter 的 Dart 常數…" +"Dash" = "虛線" +"Dash cap" = "虛線端點" +"Dashboard" = "儀表板" +"Dashes" = "虛線" +"Dashes take numbers separated by commas" = "虛線請輸入以逗號分隔的數字" +"Database" = "資料庫" +"Date created" = "建立日期" +"Dec" = "12 月" +"Decoration" = "裝飾" +"Decoration style" = "裝飾樣式" +"Deep select" = "深度選取" +"Default" = "預設" +"Default content" = "預設內容" +"Default value (the top-left variant)" = "預設值 (左上角的變體)" +"Delay" = "延遲" +"Delete" = "刪除" +"Delete category (its annotations stay)" = "刪除類別 (其註解會保留)" +"Delete collection" = "刪除集合" +"Delete group" = "刪除群組" +"Delete mode" = "刪除模式" +"Delete page" = "刪除頁面" +"Delete preset" = "刪除預設集" +"Delete property" = "刪除屬性" +"Delete results" = "刪除結果" +"Delete the style (layers keep their values)" = "刪除樣式 (圖層會保留其值)" +"Delete thread" = "刪除討論串" +"Delete variable" = "刪除變數" +"Delete “{name}”? This can't be undone." = "要刪除「{name}」嗎?此操作無法復原。" +"Density" = "密度" +"Depth" = "深度" +"Describe how and when to use this component" = "說明此元件的使用方式與時機" +"Describe what changed" = "說明變更內容" +"Description" = "說明" +"Description (optional)" = "說明 (選填)" +"Design" = "設計" +"Design panel" = "設計面板" +"Design system starter" = "設計系統範本" +"Desktop" = "桌面" +"Destination" = "目的地" +"Detach alias" = "分離別名" +"Detach calculation" = "分離計算" +"Detach instance" = "分離實例" +"Detach property" = "分離屬性" +"Detach style" = "分離樣式" +"Detach styles" = "分離樣式" +"Detach variable" = "分離變數" +"Details" = "詳細資料" +"Dev Mode" = "Dev Mode" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode 僅供檢視:切換至「設計」(Shift+D) 以編輯" +"Dev resources" = "開發資源" +"Development" = "開發" +"Device" = "裝置" +"Device at 100%" = "裝置 100%" +"Diamond" = "菱形" +"Difference" = "差值" +"Dimensions" = "尺寸" +"Direction" = "方向" +"Disconnect from code" = "中斷程式碼連接" +"Dispersion" = "色散" +"Display name" = "顯示名稱" +"Dissolve" = "溶解" +"Distribute and tidy" = "平均分佈並整理" +"Distribute horizontal spacing" = "平均分佈水平間距" +"Distribute vertical spacing" = "平均分佈垂直間距" +"Divider" = "分隔線" +"Document" = "文件" +"Don't Save" = "不儲存" +"Don't show this again" = "不要再顯示" +"Don't truncate" = "不截斷" +"Done" = "完成" +"Done editing" = "完成編輯" +"Dots" = "圓點" +"Dotted" = "點線" +"Double-click an edge of an auto layout frame" = "按兩下 Auto Layout 框架的邊緣" +"Down" = "下" +"Download" = "下載" +"Downloading… {percent}%" = "正在下載… {percent}%" +"Drafts" = "草稿" +"Drag the stroke to add a width point" = "拖曳邊框以新增寬度節點" +"Draw" = "繪製" +"Draw a frame" = "繪製框架" +"Drawing tools" = "繪圖工具" +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "拖放 .figcraft、.fig、.jam、.sketch、.json、.svg 或圖片檔案。" +"Drop shadow" = "陰影" +"Drop to import" = "放開以匯入" +"Drop to open" = "放開以開啟" +"Drop to use this image" = "放開以使用此圖片" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "以 MIT 或 Apache-2.0 雙重授權。由 ArtCraft 團隊與社群打造。" +"Duo" = "雙色" +"Duplicate" = "建立副本" +"Duplicate by dragging" = "拖曳以建立副本" +"Duplicate mode" = "建立副本模式" +"Duplicate page" = "建立副本頁面" +"Edit" = "編輯" +"Edit as an expression" = "以運算式編輯" +"Edit colour" = "編輯顏色" +"Edit comment" = "編輯留言" +"Edit dev resource" = "編輯開發資源" +"Edit link" = "編輯連結" +"Edit link copied" = "已複製編輯連結" +"Edit style" = "編輯樣式" +"Edit variable" = "編輯變數" +"Edit with the builder" = "使用建立器編輯" +"Edited {when}" = "已編輯 {when}" +"Effect settings" = "效果設定" +"Effect styles" = "效果樣式" +"Effects" = "效果" +"Elbow" = "肘形" +"Ellipse" = "橢圓" +"Ellipses" = "橢圓" +"Else" = "否則" +"Else if" = "否則若" +"Embedded profile" = "內嵌設定檔" +"Empty" = "空白" +"End" = "結束" +"End point" = "結束點" +"End vote" = "結束投票" +"Ends the session; everyone keeps their copy" = "結束工作階段;每個人都會保留自己的副本" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "輸入 figcraft-server 的位址,然後按「瀏覽」查看它提供的擴充功能。" +"Enter to save · Esc to cancel" = "按 Enter 儲存 · 按 Esc 取消" +"Eraser" = "橡皮擦" +"Esc" = "Esc" +"Essential" = "必備" +"Every conflict has a choice." = "每個衝突都有對應的選擇。" +"Every top-level frame already starts a flow" = "每個最上層框架都已開始一條流程" +"Everyone who reaches this server can open these files while they are shared." = "任何能連上此伺服器的人,都能在檔案分享期間開啟這些檔案。" +"Exact only" = "僅限完全相符" +"Exact opacity (4 then 5: 45%)" = "精確不透明度 (依序按 4、5:45%)" +"Exclude" = "排除" +"Excluded" = "已排除" +"Excluded from layout" = "已從版面配置排除" +"Excludes" = "排除" +"Exclusion" = "排除" +"Exit focus view (Esc)" = "結束聚焦檢視 (Esc)" +"Expand" = "展開" +"Expand the interface" = "展開介面" +"Export" = "匯出" +"Export all" = "全部匯出" +"Export frames and layers as PNG, JPG, SVG or PDF" = "將框架與圖層匯出為 PNG、JPG、SVG 或 PDF" +"Export page as PNG" = "將頁面匯出為 PNG" +"Export settings" = "匯出設定" +"Export variables" = "匯出變數" +"Export {name}" = "匯出 {name}" +"Export {n} layers" = { other = "匯出 {n} 個圖層" } +"Exported {file}" = "已匯出 {file}" +"Exported {name}" = "已匯出 {name}" +"Exported {name}; {n} kinds of content were changed or left out" = { other = "已匯出 {name};有 {n} 種內容遭到變更或省略" } +"Exported {name}; {n} kinds of content were left out" = { other = "已匯出 {name};有 {n} 種內容遭到省略" } +"Exported {n} files" = { other = "已匯出 {n} 個檔案" } +"Exported {n} files; some content was changed or left out" = { other = "已匯出 {n} 個檔案;部分內容遭到變更或省略" } +"Export…" = "匯出…" +"Expose properties to instances" = "將屬性公開給實例" +"Exposure" = "曝光" +"Expression" = "運算式" +"Extend collection" = "擴充集合" +"Extends \"{name}\": overridden values are marked" = "延伸「{name}」:遭覆寫的值會標示出來" +"Eye" = "眼睛" +"F" = "F" +"Fade" = "淡出" +"False" = "False" +"Feb" = "2 月" +"Fetching from the server…" = "正在從伺服器擷取…" +"FigCraft couldn't open its window: {error}" = "FigCraft 無法開啟視窗:{error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft 尚未支援這些檔案的所有功能。附註會列出匯入副本中省略或變更的內容;原始檔案不會變更。" +"FigCraft stopped because of an internal error." = "FigCraft 因內部錯誤而停止。" +"FigCraft tried {apis}, then again with lower requirements on the power-saving GPU. Updating the graphics driver often helps." = "FigCraft 嘗試了 {apis},接著在省電 GPU 上以較低需求重試。更新圖形驅動程式通常有幫助。" +"File" = "檔案" +"File name" = "檔案名稱" +"File not found" = "找不到檔案" +"File published" = "檔案已發佈" +"Files aren't end-to-end encrypted: the server's operator can read them." = "檔案沒有端對端加密:伺服器操作人員可以讀取。" +"Files nobody opens for {n} days are deleted." = { other = "{n} 天內無人開啟的檔案會遭到刪除。" } +"Fill" = "填色" +"Fill (fr)" = "填滿 (fr)" +"Fill container" = "填滿容器" +"Fill window" = "填滿視窗" +"Fills" = "填色" +"Fill|sizing" = "填滿" +"Filter and sort" = "篩選與排序" +"Find" = "尋找" +"Find and replace" = "尋找與取代" +"Find layers" = "尋找圖層" +"First" = "第一個" +"First commit" = "首次提交" +"First on top" = "第一項在最上層" +"Fit" = "符合" +"Fit device to window" = "讓裝置符合視窗" +"Fit to contents" = "符合內容" +"Fit to window" = "符合視窗" +"Fit width" = "符合寬度" +"Fixed" = "固定" +"Fixed gap" = "固定間距" +"Fixed size" = "固定大小" +"Flatten" = "扁平化" +"Flip" = "翻轉" +"Flip horizontal" = "水平翻轉" +"Flip text to the other side" = "將文字翻轉至另一側" +"Flip vertical" = "垂直翻轉" +"Flip width profile" = "翻轉寬度輪廓" +"Flow chart" = "流程圖" +"Flow starting point" = "流程起點" +"Flow starting points" = "流程起點" +"Flows" = "流程" +"Focus view" = "聚焦檢視" +"Fold autosaves" = "收合自動儲存" +"Folder: click to collapse" = "資料夾:點擊以收合" +"Folder: click to expand" = "資料夾:點擊以展開" +"Follow" = "跟隨" +"Follow {name}" = "跟隨 {name}" +"Following" = "跟隨中" +"Following {name} · {key} to stop" = "正在跟隨 {name} · 按 {key} 停止" +"Font" = "字型" +"Font not installed" = "未安裝字型" +"Font size" = "字型大小" +"Font style" = "字型樣式" +"Forget on this computer" = "在此電腦上忘記" +"Format" = "格式" +"Four screens of a trail app with components and a prototype flow" = "登山步道應用程式的四個畫面,包含元件與原型流程" +"Fractions" = "分數" +"Frame" = "框架" +"Frame selection" = "將選取範圍置入框架" +"Frame tools" = "框架工具" +"Frames" = "框架" +"Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it." = "免費字型來自 Google Fonts 與 Noto,透過 jsDelivr 與 GitHub 取得。每個檔案在安裝到 Craft 應用程式共用的字型資料夾前,都會先比對字型索引。在你允許之前,不會下載任何內容。" +"From the library \"{name}\": read-only" = "來自資源庫「{name}」:唯讀" +"From {name}" = "來自 {name}" +"From {server}" = "來自 {server}" +"Frost" = "霜白" +"Gap" = "間距" +"Gap between columns" = "欄間距" +"Gap between items" = "項目間距" +"Gap between rows" = "列間距" +"Getting started" = "開始使用" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "輸入開放給公眾的伺服器位址,或附有金鑰的伺服器位址 (`figcraft-server key` 會顯示金鑰)。" +"Glass" = "玻璃" +"Go to main component" = "前往主要元件" +"Go to {name}" = "前往 {name}" +"Grab bag" = "綜合範例" +"Gradient" = "漸層" +"Graph paper" = "方格紙" +"Gray" = "灰色" +"Green" = "綠色" +"Greyscale components and two example screens to sketch a flow with" = "灰階元件與兩個範例畫面,用來勾勒流程" +"Grid" = "格線" +"Grid ({size})" = "格線 ({size})" +"Grid styles" = "格線樣式" +"Grid {size}" = "格線 {size}" +"Group" = "群組" +"Group selection" = "將選取範圍組成群組" +"Groups" = "群組" +"Gutter" = "溝槽" +"Hand tool" = "手形工具" +"Hanging list" = "懸掛清單" +"Hanging punctuation" = "懸掛標點" +"Hard light" = "實光" +"Heart" = "愛心" +"Height" = "高度" +"Help" = "說明" +"Help and resources" = "說明與資源" +"Help page" = "說明頁面" +"Here: {names}" = "在這裡:{names}" +"Hexagon" = "六邊形" +"Hidden" = "隱藏" +"Hide UI" = "隱藏介面" +"Hide contents" = "隱藏內容" +"Hide from publishing" = "從發佈中隱藏" +"Hide section contents" = "隱藏區段內容" +"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" +"Hold Ctrl" = "按住 Ctrl" +"Hold Z" = "按住 Z" +"Hold ⌃ or ⌘" = "按住 ⌃ 或 ⌘" +"Hold ⌥" = "按住 ⌥" +"Hold ⌥Z" = "按住 ⌥Z" +"Home" = "首頁" +"Horizontal" = "水平" +"Horizontal align" = "水平對齊" +"Horizontal hex" = "水平六角形" +"Horizontal padding" = "水平內距" +"Horizontal spacing" = "水平間距" +"Hosted by {operator}" = "由 {operator} 主機代管" +"Hover a layer to see its properties" = "將游標懸停在圖層上以查看其屬性" +"Hover other layers to measure the distance" = "將游標懸停在其他圖層上以測量距離" +"Hue" = "色相" +"Hug" = "緊貼" +"Hug contents" = "緊貼內容" +"Icon components on a 24 px grid with keylines and a size preview" = "24 px 格線上的圖示元件,附關鍵線與大小預覽" +"Icon set starter" = "圖示集範本" +"If" = "若" +"If it keeps happening, please report it at {link} with this message." = "若問題持續發生,請透過 {link} 回報,並附上此訊息。" +"If {condition}" = "若 {condition}" +"Ignore auto layout (absolute position)" = "忽略 Auto Layout (絕對位置)" +"Ignore overlapping layers" = "忽略重疊圖層" +"Ignore unsupported" = "忽略不支援的內容" +"Image" = "圖片" +"Images" = "圖片" +"Import" = "匯入" +"Import path" = "匯入路徑" +"Import plugin from manifest" = "從資訊清單匯入擴充功能" +"Import plugin from manifest…" = "從資訊清單匯入擴充功能…" +"Import report" = "匯入報告" +"Import variables" = "匯入變數" +"Imported document" = "已匯入文件" +"Imported {name}" = "已匯入 {name}" +"Imported {n} new variables, updated {updated}" = { other = "已匯入 {n} 個新變數,更新了 {updated} 個" } +"Import…" = "匯入…" +"In an instance, this comes from the main component" = "在實例中,此值來自主要元件" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "在瀏覽器中,你可以加入由桌面應用程式主辦的工作階段,以及 FigCraft 伺服器上的檔案。透過 https 開啟的頁面只能經由 wss:// 連結 (位於 TLS 後的伺服器) 連線到其他電腦。" +"Include bounding box" = "包含邊界框" +"Include id attribute" = "包含 id 屬性" +"Included" = "已包含" +"Included in layout" = "已納入版面配置" +"Individual corners" = "個別圓角" +"Individual padding" = "個別內距" +"Individual strokes" = "個別邊框" +"Ink" = "墨水" +"Inner shadow" = "內陰影" +"Inside" = "內側" +"Inspect" = "檢視" +"Inspect the layer under the pointer: size, colours, typography" = "檢視指標下的圖層:大小、顏色、排版" +"Install" = "安裝" +"Install plugin" = "安裝擴充功能" +"Installed" = "已安裝" +"Installed {name}" = "已安裝 {name}" +"Installing…" = "正在安裝…" +"Instance" = "實例" +"Instance properties" = "實例屬性" +"Instance swap" = "實例替換" +"Instance swap (no other component)" = "實例替換 (沒有其他元件)" +"Instances" = "實例" +"Instant" = "立即" +"Intensity" = "強度" +"Interaction" = "互動" +"Interactions" = "互動" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "介面設計與原型製作。以 Rust 從零打造的免費、開放原始碼、clean-room 設計工具,屬於 ArtCraft Crafting Apps 系列。" +"Interface size" = "介面大小" +"Internal storage" = "內部儲存空間" +"Intersect" = "交集" +"Intersects" = "交集" +"Invalid color" = "無效的顏色" +"Invite link" = "邀請連結" +"Invite link copied" = "已複製邀請連結" +"It asks permission to:" = "它要求下列權限:" +"Jan" = "1 月" +"Join" = "加入" +"Join code copied" = "已複製加入代碼" +"Join code or invite link" = "加入代碼或邀請連結" +"Join live session" = "加入即時工作階段" +"Join us on Discord" = "在 Discord 上加入我們" +"Join with a code" = "使用代碼加入" +"Joined" = "已加入" +"Joined {file}" = "已加入 {file}" +"Joining live session…" = "正在加入即時工作階段…" +"Joining…" = "正在加入…" +"Join|stroke" = "接合" +"Jul" = "7 月" +"Jun" = "6 月" +"Just now" = "剛剛" +"Justify" = "兩端對齊" +"Kanban board" = "看板" +"Keep a measurement on the canvas for developers" = "在畫布上為開發人員保留測量標記" +"Keep appearance" = "保留外觀" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "「保留外觀」會將每個顏色值轉換為 {profile},讓顏色看起來相同。「保留數值」則維持原本的數字,顏色會隨之偏移。" +"Keep tool selected after use" = "使用後保持選取工具" +"Keep values" = "保留數值" +"Key/Gamepad" = "按鍵/遊戲手把" +"Key/Gamepad {key}" = "按鍵/遊戲手把 {key}" +"Keyboard shortcuts" = "鍵盤快速鍵" +"Kits" = "套件" +"Landing page" = "登陸頁" +"Language" = "語言" +"Lasso" = "套索" +"Last" = "最後一個" +"Last change: {note}" = "最後變更:{note}" +"Last commit" = "最後提交" +"Last modified" = "最後修改" +"Last on top" = "最後一項在最上層" +"Layer blur" = "圖層模糊" +"Layer name" = "圖層名稱" +"Layers" = "圖層" +"Layers in an instance take their position from the main component" = "實例中的圖層位置來自主要元件" +"Layout" = "版面配置" +"Layout grid" = "版面格線" +"Layout grid settings" = "版面格線設定" +"Layout grids" = "版面格線" +"Leave session" = "離開工作階段" +"Leaves" = "葉子" +"Leaving the prototype" = "離開原型" +"Left" = "左" +"Left & right" = "左與右" +"Left padding" = "左內距" +"Left side" = "左側" +"Left side width" = "左側寬度" +"Letter case" = "字母大小寫" +"Letter spacing" = "字元間距" +"Libraries" = "資源庫" +"Libraries in this file" = "此檔案中的資源庫" +"Library updates available" = "資源庫有可用更新" +"Library: click to collapse" = "資源庫:點擊以收合" +"Library: click to expand" = "資源庫:點擊以展開" +"Library: its variables are read-only here; edit them in the library file and publish it" = "資源庫:此處的變數為唯讀;請在資源庫檔案中編輯並發佈" +"Light" = "淺色" +"Lighten" = "變亮" +"Light|glass" = "光照" +"Line" = "線條" +"Line arrow" = "線條箭頭" +"Line delta" = "行數差異" +"Line height" = "行高" +"Linear" = "線性" +"Lines" = "線條" +"Lines +/−" = "行數 +/−" +"Lines added" = "新增行數" +"Lines deleted" = "刪除行數" +"Link" = "連結" +"Link copied" = "已複製連結" +"Link copied: it opens the file in FigCraft" = "已複製連結:會在 FigCraft 中開啟檔案" +"Link copied: it opens the file in the browser" = "已複製連結:會在瀏覽器中開啟檔案" +"Link docs, code or tickets for developers." = "為開發人員連結文件、程式碼或票據。" +"Link frames on the Prototype tab, then present the flow" = "在「原型」分頁連結框架,然後簡報流程" +"Link this component to its code component: instances then show your real snippet with their values." = "將此元件連結至其程式碼元件:實例便會顯示你的實際程式碼片段及其值。" +"List spacing" = "清單間距" +"List style" = "清單樣式" +"Live session" = "即時工作階段" +"Live · {people}" = "即時 · {people}" +"Local components" = "本機元件" +"Local network" = "本機網路" +"Local styles" = "本機樣式" +"Local variables" = "本機變數" +"Lock/Unlock" = "鎖定/解鎖" +"Locked" = "已鎖定" +"Lowercase" = "小寫" +"Luminance" = "亮度" +"Luminosity" = "明度" +"Main" = "主要" +"Main component deleted" = "主要元件已刪除" +"Main menu" = "主選單" +"Make a component" = "建立元件" +"Make slot" = "建立插槽" +"Manual input" = "手動輸入" +"Mar" = "3 月" +"Margin" = "邊界" +"Mark all as read" = "全部標示為已讀" +"Mark as resolved" = "標示為已解決" +"Mark as unread" = "標示為未讀" +"Mark as unresolved" = "標示為未解決" +"Marker" = "麥克筆" +"Mask" = "遮罩" +"Mass · Stiffness · Damping" = "質量 · 勁度 · 阻尼" +"Match (empty: the whole name)" = "比對 (空白:完整名稱)" +"Max H" = "最大高" +"Max W" = "最大寬" +"Max lines" = "最大行數" +"Maximize" = "最大化" +"May" = "5 月" +"Measure to the selection" = "測量至選取範圍" +"Measurements" = "測量標記" +"Merge into main" = "合併至主線" +"Merge regions" = "合併區域" +"Merge the branch into the main file (one undo step) and archive it" = "將分支合併至主檔案 (單一步驟復原) 並封存" +"Merged" = "已合併" +"Merged PRs" = "已合併的 PR" +"Min H" = "最小高" +"Min W" = "最小寬" +"Minimize" = "最小化" +"Minutes" = "分鐘" +"Mirror angle" = "鏡射角度" +"Mirror angle and length" = "鏡射角度與長度" +"Mirroring" = "鏡射" +"Missing" = "缺少" +"Missing fonts" = "缺少字型" +"Missing layer" = "缺少圖層" +"Missing style" = "缺少樣式" +"Missing variable" = "缺少變數" +"Miter" = "斜接" +"Miter angle" = "斜接角度" +"Mixed" = "混合" +"Mixed fonts" = "混合字型" +"Mixed interactions: click + to replace them" = "混合互動:點擊 + 加以取代" +"Mobile app" = "行動應用程式" +"Mode" = "模式" +"Model" = "模型" +"Models" = "模型" +"Mono" = "單色" +"More" = "更多" +"More instance actions" = "更多實例動作" +"Mouse down" = "滑鼠按下" +"Mouse enter" = "滑鼠進入" +"Mouse leave" = "滑鼠離開" +"Mouse up" = "滑鼠放開" +"Move" = "移動" +"Move along the toolbar or the layer rows" = "沿工具列或圖層列移動" +"Move column left" = "將欄向左移" +"Move column right" = "將欄向右移" +"Move down" = "下移" +"Move in" = "移入" +"Move out" = "移出" +"Move tools" = "移動工具" +"Move up" = "上移" +"Moved to {name}" = "已移至 {name}" +"Multi" = "多重" +"Multiply" = "色彩增殖" +"Muted" = "柔和" +"Name" = "名稱" +"Name (A–Z)" = "名稱 (A–Z)" +"Named instance" = "具名實例" +"Navigate to" = "導覽至" +"Nested instance" = "巢狀實例" +"New board" = "新白板" +"New collection" = "新增集合" +"New design file" = "新設計檔案" +"Newest first" = "最新優先" +"Next frame" = "下一個框架" +"Next or previous control in the focused region" = "聚焦區域中的下一個或上一個控制項" +"Next ready for dev" = "下一個準備交付開發" +"Next size option" = "下一個大小選項" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "沒有資源:沒有匯出設定、圖示或圖片。請在「設計」分頁新增匯出設定。" +"No boolean variables fit here." = "這裡沒有適用的布林變數。" +"No boolean variables yet. Create them in Local variables." = "尚無布林變數。請在本機變數中建立。" +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "沒有分支。分支是檔案的副本,可用來嘗試變更,之後再檢閱並合併。" +"No category" = "沒有類別" +"No changes" = "沒有變更" +"No color styles" = "沒有顏色樣式" +"No colour variables fit here." = "這裡沒有適用的顏色變數。" +"No colour variables yet. Create them in Local variables." = "尚無顏色變數。請在本機變數中建立。" +"No comments to copy" = "沒有可複製的留言" +"No comments yet" = "尚無留言" +"No compatibility warnings reported" = "未回報相容性警告" +"No components yet. Select layers and press {keys}." = "尚無元件。請選取圖層並按 {keys}。" +"No conflicts." = "沒有衝突。" +"No contributor data was built into this copy." = "此版本未內建貢獻者資料。" +"No device" = "沒有裝置" +"No files or templates match your search" = "沒有符合搜尋的檔案或範本" +"No fonts match" = "沒有符合的字型" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "這裡沒有加入代碼 (伺服器版本早於加入代碼,或此位址無法放入代碼):請改為分享連結。" +"No layer changes to show." = "沒有可顯示的圖層變更。" +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "尚無資源庫。請新增已發佈為資源庫的 .figcraft 檔案,或貼上團隊資源庫的連結。" +"No list" = "沒有清單" +"No matching comments" = "沒有符合的留言" +"No matching components" = "沒有符合的元件" +"No matching layers" = "沒有符合的圖層" +"No matching styles" = "沒有符合的樣式" +"No matching variables" = "沒有符合的變數" +"No mirroring" = "不鏡射" +"No model credits were built into this copy." = "此版本未內建模型貢獻資料。" +"No number variables fit here." = "這裡沒有適用的數字變數。" +"No number variables yet. Create them in Local variables." = "尚無數字變數。請在本機變數中建立。" +"No other pages" = "沒有其他頁面" +"No paints" = "沒有塗色" +"No plugins on {server}." = "{server} 上沒有擴充功能。" +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "尚無擴充功能。請從其 figcraft-plugin.json 匯入開發中的擴充功能。" +"No preferred components" = "沒有偏好的元件" +"No preset" = "沒有預設集" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "尚無已儲存的版本。儲存版本即可標記一個你能回頭的工作進度。" +"No scrolling" = "不捲動" +"No shortcuts match" = "沒有符合的快速鍵" +"No status" = "沒有狀態" +"No string variables fit here." = "這裡沒有適用的字串變數。" +"No string variables yet. Create them in Local variables." = "尚無字串變數。請在本機變數中建立。" +"No styles yet." = "尚無樣式。" +"No styles yet. Click + to create one from the selection." = "尚無樣式。點擊 + 從選取範圍建立。" +"No templates match your search" = "沒有符合搜尋的範本" +"No text styles yet. Click + to create one from the selected text." = "尚無文字樣式。點擊 + 從選取的文字建立。" +"No variables fit here." = "這裡沒有適用的變數。" +"No variables yet. Create them in Local variables." = "尚無變數。請在本機變數中建立。" +"No votes yet" = "尚無投票" +"Nobody else is here yet" = "目前還沒有其他人" +"Nobody shares a file there now." = "目前沒有人在該處分享檔案。" +"Noise" = "雜訊" +"None" = "無" +"None." = "無。" +"Normal" = "正常" +"Not bound" = "未綁定" +"Not checked yet" = "尚未檢查" +"Not in a live session" = "不在即時工作階段中" +"Not in this font" = "此字型沒有" +"Not now" = "現在不要" +"Not properties, kept as written: {names}" = "不是屬性,依原樣保留:{names}" +"Not read yet" = "尚未讀取" +"Not shared" = "未分享" +"Note {n}" = "附註 {n}" +"Nothing changed since this version." = "此版本之後沒有任何變更。" +"Nothing changed since this was marked ready for dev." = "標示為準備交付開發後沒有任何變更。" +"Nothing counted yet" = "尚未計算" +"Nothing here yet. Set a frame's status to list it." = "這裡尚無內容。設定框架的狀態即可列出。" +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "尚無線上項目。在檔案上使用「分享」將其放到線上,就會列在這裡。" +"Nothing to export" = "沒有可匯出的內容" +"Nov" = "11 月" +"Now" = "現在" +"Nudge (Shift: big nudge)" = "微調 (Shift:大幅微調)" +"Nudge amount" = "微調距離" +"Number" = "數字" +"Number ↑" = "數字 ↑" +"Number ↓" = "數字 ↓" +"Numbered list" = "編號清單" +"Object" = "物件" +"Oct" = "10 月" +"Octagon" = "八邊形" +"Off" = "關閉" +"Offered first when filling the slot (any content is allowed)" = "填入插槽時優先提供 (允許任何內容)" +"Offered first when swapping the instance (any component is allowed)" = "替換實例時優先提供 (允許任何元件)" +"Offset" = "位移" +"Oldest first" = "最舊優先" +"On click" = "點擊時" +"On drag" = "拖曳時" +"On this page" = "在此頁面" +"One announcement in five common sizes: square, portrait, story, link and banner" = "一則公告的五種常見尺寸:方形、直式、限時動態、連結與橫幅" +"Online" = "線上" +"Online files" = "線上檔案" +"Online sharing is off: files are shared on the local network only." = "線上分享已關閉:檔案僅在本機網路上分享。" +"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "線上分享已關閉:偏好設定中未設定伺服器。你可以在此加入分享的檔案。" +"Only the first changed layers are listed." = "僅列出前幾個變更的圖層。" +"Only the first {n} changed layers are listed." = "僅列出前 {n} 個變更的圖層。" +"Only your threads" = "只有你的討論串" +"Opacity" = "不透明度" +"Opacity & blend" = "不透明度與混合" +"Opacity 10%…90%, 0: 100%" = "不透明度 10%…90%,0:100%" +"Open" = "開啟" +"Open .figcraft, .svg or images" = "開啟 .figcraft、.svg 或圖片" +"Open branch" = "開啟分支" +"Open file" = "開啟檔案" +"Open files" = "開啟檔案" +"Open in focus view" = "在聚焦檢視中開啟" +"Open in new tab" = "在新分頁中開啟" +"Open in new window" = "在新視窗中開啟" +"Open invite links in FigCraft" = "在 FigCraft 中開啟邀請連結" +"Open link" = "開啟連結" +"Open link {url}" = "開啟連結 {url}" +"Open overlay" = "開啟覆蓋層" +"Open presentations" = "開啟簡報" +"Open prototype preview" = "開啟原型預覽" +"Open variables ({count})" = "開啟變數 ({count})" +"Opened as a new document" = "已開啟為新文件" +"Opened “{name}” in a new tab" = "已在新分頁中開啟「{name}」" +"Opening the file…" = "正在開啟檔案…" +"Opening {n} files as new files" = { other = "正在將 {n} 個檔案開啟為新檔案" } +"Open|comments" = "未解決" +"Options" = "選項" +"Orange" = "橘色" +"Order" = "順序" +"Other" = "其他" +"Other apps may not open every .fig file exported here." = "其他應用程式可能無法開啟此處匯出的每個 .fig 檔案。" +"Others lose access and keep their copy; the file stays on the server for you" = "其他人會失去存取權並保留自己的副本;檔案會留在伺服器上供你使用" +"Others type this under Share ▸ Join with a code" = "其他人可在「分享 ▸ 使用代碼加入」中輸入此代碼" +"Outline stroke" = "將邊框轉為外框" +"Outline text" = "將文字轉為外框" +"Outlines" = "外框" +"Outside" = "外側" +"Overflow" = "溢出" +"Overlay" = "覆蓋層" +"Overlay position" = "覆蓋層位置" +"Overridden here (the collection it extends has another value)" = "在此處覆寫 (其延伸的集合有另一個值)" +"Overrides" = "覆寫" +"Owner token" = "擁有者權杖" +"Owner token copied" = "已複製擁有者權杖" +"PDF print options" = "PDF 列印選項" +"PDF print settings" = "PDF 列印設定" +"Padding" = "內距" +"Page" = "頁面" +"Page deleted" = "頁面已刪除" +"Pages" = "頁面" +"Pages added to the document" = "已將頁面新增至文件" +"Pages: {pages}" = "頁面:{pages}" +"Paint bucket" = "油漆桶" +"Pan the canvas" = "平移畫布" +"Pan with nothing selected (Shift: farther)" = "未選取任何項目時平移 (Shift:移動更遠)" +"Panels" = "面板" +"Paper" = "紙張" +"Paragraph indent" = "段落縮排" +"Paragraph indent applies to left-aligned text" = "段落縮排僅適用於靠左對齊的文字" +"Paragraph spacing" = "段落間距" +"Parallelogram left" = "左平行四邊形" +"Parallelogram right" = "右平行四邊形" +"Pass through" = "穿透" +"Past 30 days" = "過去 30 天" +"Past 90 days" = "過去 90 天" +"Past year" = "過去一年" +"Paste a link (https://…)" = "貼上連結 (https://…)" +"Paste a team library link (a view link)" = "貼上團隊資源庫連結 (檢視連結)" +"Paste effect" = "貼上效果" +"Paste fill" = "貼上填色" +"Paste here" = "貼到此處" +"Paste stroke" = "貼上邊框" +"Paste to replace" = "貼上以取代" +"Pasted design layers" = "已貼上設計圖層" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "已貼上設計圖層;剪貼簿未包含以下圖層的圖片:{layers}" +"Pasted design layers; {n} kinds of content were left out" = { other = "已貼上設計圖層;有 {n} 種內容遭到省略" } +"Pattern" = "圖樣" +"Pattern alignment" = "圖樣對齊" +"Pattern scale" = "圖樣縮放" +"Pattern source" = "圖樣來源" +"Pause" = "暫停" +"Pen" = "鋼筆" +"Pencil" = "鉛筆" +"Pentagon" = "五邊形" +"People in this file" = "此檔案中的人員" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "任何人都能使用代碼從各地加入。分享期間,檔案會儲存在 {server} 上。" +"People on your network join with a code. Nothing goes through a server." = "你網路上的人員可使用代碼加入。不會經過任何伺服器。" +"Phone" = "手機" +"Pick color" = "挑選顏色" +"Pick color (I)" = "挑選顏色 (I)" +"Pick one font family first" = "請先選擇一個字型家族" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "選擇「框架」工具,然後在畫布上拖曳或選擇裝置尺寸" +"Pin a property" = "釘選屬性" +"Pinch" = "捏合" +"Pink" = "粉紅色" +"Pixel grid" = "像素格線" +"Pixel preview 1x" = "像素預覽 1x" +"Pixel preview 2x" = "像素預覽 2x" +"Place a shape or frame (its tool chosen)" = "放置圖形或框架 (已選取其工具)" +"Place automatically" = "自動放置" +"Place image" = "置入圖片" +"Placed on the current page" = "已放置於目前頁面" +"Playground" = "遊樂場" +"Plugin" = "外掛" +"Plugins" = "外掛" +"Plugins & widgets" = "外掛與小工具" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "外掛在 FigCraft 的沙箱中執行,無法存取檔案、網路或時鐘。請只安裝你信任的外掛。" +"Plus" = "加號" +"Plus darker" = "加深" +"Plus lighter" = "加亮" +"Plus one" = "加一" +"Point" = "節點" +"Points" = "節點" +"Polygon" = "多邊形" +"Polygons" = "多邊形" +"Port" = "連接埠" +"Position" = "位置" +"Post" = "發佈" +"Predefined process" = "預設程序" +"Preferences" = "偏好設定" +"Preferred" = "偏好" +"Present" = "簡報" +"Present or preview" = "簡報或預覽" +"Present this flow" = "簡報此流程" +"Presentation" = "簡報" +"Presentation slides" = "簡報投影片" +"Presenting: comments" = "簡報中:留言" +"Presenting: cycle the size options" = "簡報中:切換大小選項" +"Presenting: full screen" = "簡報中:全螢幕" +"Presenting: next frame" = "簡報中:下一個框架" +"Presenting: previous frame" = "簡報中:上一個框架" +"Presenting: restart" = "簡報中:重新開始" +"Preserve scroll position" = "保留捲動位置" +"Presets" = "預設集" +"Press a key…" = "請按一個鍵…" +"Pretty" = "美觀" +"Pretty: full lines, no one word alone on the last" = "美觀:填滿整行,最後一行不留單字" +"Preview" = "預覽" +"Preview this flow" = "預覽此流程" +"Preview this frame" = "預覽此框架" +"Previous frame" = "上一個框架" +"Previous ready for dev" = "上一個準備交付開發" +"Progressive" = "漸進式" +"Properties" = "屬性" +"Properties copied" = "已複製屬性" +"Prototype" = "原型" +"Prototype preview" = "原型預覽" +"Prototype settings" = "原型設定" +"Publish" = "發佈" +"Publish changes" = "發佈變更" +"Publish to another server…" = "發佈至其他伺服器…" +"Publish to server" = "發佈至伺服器" +"Published version {v}" = "已發佈版本 {v}" +"Published version {v} to the server" = "已將版本 {v} 發佈至伺服器" +"Published version {v}: save the file so other files can use it" = "已發佈版本 {v}:儲存檔案以便其他檔案使用" +"Published · {people}" = "已發佈 · {people}" +"Publishing with the server's key." = "使用伺服器的金鑰發佈。" +"Publishing…" = "正在發佈…" +"Push" = "推送" +"Push changes to main component" = "將變更推送至主要元件" +"Put text on path" = "將文字置於路徑上" +"Question" = "問題" +"R" = "R" +"Radial" = "放射狀" +"Radius" = "半徑" +"Ratio" = "比例" +"Reading the library…" = "正在讀取資源庫…" +"Ready for dev" = "準備交付開發" +"Real name" = "真實姓名" +"Recent" = "最近" +"Recent files" = "最近的檔案" +"Recents" = "最近項目" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "應用程式意外結束後,已復原 {n} 個未儲存的檔案:儲存即可保留" } +"Rectangle" = "矩形" +"Rectangles" = "矩形" +"Red" = "紅色" +"Reduce motion" = "減少動態效果" +"Refraction" = "折射" +"Regular expression" = "正規表示式" +"Relative to" = "相對於" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "相對於系統的顯示縮放 (100% 為系統本身的大小)。文件縮放另計。" +"Reload the page to get back the unsaved files the browser had room for." = "重新載入頁面,以取回瀏覽器還有空間容納的未儲存檔案。" +"Reload the page to get your unsaved files back." = "重新載入頁面以取回未儲存的檔案。" +"Reload the page to start again." = "重新載入頁面以重新開始。" +"Remove" = "移除" +"Remove all" = "全部移除" +"Remove all auto layout" = "移除所有 Auto Layout" +"Remove annotation" = "移除註解" +"Remove auto layout" = "移除 Auto Layout" +"Remove column" = "移除欄" +"Remove dev resource" = "移除開發資源" +"Remove export setting" = "移除匯出設定" +"Remove from recents" = "從最近項目移除" +"Remove from server" = "從伺服器移除" +"Remove from server…" = "從伺服器移除…" +"Remove guide" = "移除參考線" +"Remove interaction" = "移除互動" +"Remove library from this file" = "從此檔案移除資源庫" +"Remove measurement" = "移除測量標記" +"Remove min and max" = "移除最小與最大值" +"Remove min and max height" = "移除最小與最大高度" +"Remove min and max width" = "移除最小與最大寬度" +"Remove regions" = "移除區域" +"Remove row" = "移除列" +"Remove slot" = "移除插槽" +"Remove starting point" = "移除起點" +"Remove stop" = "移除停駐點" +"Remove text from path" = "從路徑移除文字" +"Remove the else and its actions" = "移除「否則」及其動作" +"Remove these results from the file" = "從檔案移除此結果" +"Remove this condition and its actions" = "移除此條件及其動作" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "要從伺服器移除此檔案嗎?之後將無法從該處分享或開啟。你的副本會保持開啟。" +"Remove {name} from session" = "從工作階段移除 {name}" +"Remove {name} from {server}?" = "要從 {server} 移除 {name} 嗎?" +"Removed" = "已移除" +"Removed from the server" = "已從伺服器移除" +"Removed since it was marked" = "標示後已移除" +"Removed since this version" = "此版本之後已移除" +"Rename" = "重新命名" +"Rename duplicated layers" = "重新命名重複的圖層" +"Rename folder" = "重新命名資料夾" +"Rename group" = "重新命名群組" +"Rename layers" = "重新命名圖層" +"Rename mode" = "重新命名模式" +"Rename on the server" = "在伺服器上重新命名" +"Rename on the server…" = "在伺服器上重新命名…" +"Rename page" = "重新命名頁面" +"Rename to" = "重新命名為" +"Reorder selection" = "重新排序選取範圍" +"Replace all" = "全部取代" +"Replace mixed export settings" = "取代混合的匯出設定" +"Replace with" = "取代為" +"Replace with a new interaction" = "以新互動取代" +"Reply" = "回覆" +"Report" = "回報" +"Report this file…" = "回報此檔案…" +"Reset" = "重設" +"Reset adjustments" = "重設調整" +"Reset all changes" = "重設所有變更" +"Reset all overrides on this layer" = "重設此圖層的所有覆寫" +"Reset grid" = "重設格線" +"Reset overrides" = "重設覆寫" +"Reset slot" = "重設插槽" +"Reset this override" = "重設此覆寫" +"Reset to style" = "重設為樣式" +"Reset to the inherited value" = "重設為繼承的值" +"Resize by the nudge amount" = "依微調距離調整大小" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "將框架調整為裝置畫面大小 (沒有裝置時則為視窗大小):其約束與 Auto Layout 會隨之回應" +"Resize to fit" = "調整大小以符合" +"Resizing" = "正在調整大小" +"Resolve" = "解決" +"Resolve change" = "解決變更" +"Resolved" = "已解決" +"Responsive" = "響應式" +"Restart" = "重新開始" +"Restore" = "還原" +"Restore main component" = "還原主要元件" +"Restore this version" = "還原此版本" +"Restored “{name}”" = "已還原「{name}」" +"Results" = "結果" +"Resume" = "繼續" +"Retrospective" = "回顧" +"Reverse the order" = "反轉順序" +"Reversed triangle" = "倒三角形" +"Review and merge" = "檢閱並合併" +"Ribbon" = "緞帶" +"Right" = "右" +"Right padding" = "右內距" +"Right side" = "右側" +"Right side width" = "右側寬度" +"Rotate 90°" = "旋轉 90°" +"Rotate 90° right" = "向右旋轉 90°" +"Rotate a degree (Shift: 15)" = "旋轉一度 (Shift:15 度)" +"Rotate to landscape" = "旋轉為橫向" +"Rotate to portrait" = "旋轉為直向" +"Rotation" = "旋轉" +"Rotation jitter" = "旋轉抖動" +"Round" = "圓端" +"Rounded" = "圓角" +"Rounded rectangle" = "圓角矩形" +"Row" = "列" +"Row gap" = "列間距" +"Row sizes" = "列大小" +"Row span" = "列合併" +"Rows" = "列" +"Rows {count}" = "列 {count}" +"Rulers" = "尺規" +"Run" = "執行" +"Run {label}" = "執行 {label}" +"SHA-256: {sha}…" = "SHA-256:{sha}…" +"Same as layer" = "與圖層相同" +"Saturation" = "飽和度" +"Save" = "儲存" +"Save as" = "另存新檔" +"Save as preset…" = "儲存為預設集…" +"Save file" = "儲存檔案" +"Save files automatically" = "自動儲存檔案" +"Save version {key}" = "儲存版本 {key}" +"Saved" = "已儲存" +"Saved {name}" = "已儲存 {name}" +"Say something" = "說些什麼" +"Scale" = "縮放" +"Scatter" = "散佈" +"Scoping: where pickers offer it" = "適用範圍:挑選器提供此變數的位置" +"Screen" = "畫面" +"Scroll behavior" = "捲動行為" +"Scroll to" = "捲動至" +"Scroll to layer" = "捲動至圖層" +"Scroll wheel zoom" = "滾輪縮放" +"Scroll with parent" = "隨父層捲動" +"Search" = "搜尋" +"Search actions, layers and tools" = "搜尋動作、圖層與工具" +"Search assets" = "搜尋資源" +"Search comments" = "搜尋留言" +"Search files and templates" = "搜尋檔案與範本" +"Search fonts" = "搜尋字型" +"Search layers" = "搜尋圖層" +"Search pages" = "搜尋頁面" +"Search plugins" = "搜尋外掛" +"Search results" = "搜尋結果" +"Search shortcuts" = "搜尋快速鍵" +"Search templates" = "搜尋範本" +"Section" = "區段" +"Section color" = "區段顏色" +"Sections" = "區段" +"See all templates" = "查看所有範本" +"Select a frame and add a starting point to present it as a flow." = "選取框架並新增起點,即可將其簡報為流程。" +"Select a frame to preview it" = "選取框架以預覽" +"Select a layer to see its properties and code" = "選取圖層以查看其屬性與程式碼" +"Select all" = "全選" +"Select all with same" = "選取所有相同項目" +"Select layer" = "選取圖層" +"Select layers and they space themselves as their content changes" = "選取圖層後,它們會隨著內容變更自動調整間距" +"Select layers to apply the style to" = "選取要套用樣式的圖層" +"Select layers with this color" = "選取使用此顏色的圖層" +"Select overlapping shapes" = "選取重疊的圖形" +"Select points to edit their position, radius and mirroring" = "選取節點以編輯其位置、半徑與鏡射" +"Select slot" = "選取插槽" +"Selection" = "選取範圍" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選取框,{w} × {h},位於 {x}、{y}:方向鍵可移動,Shift+方向鍵或 {cmd}+方向鍵可調整大小,Enter 選取,Escape 取消" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選取框:方向鍵可移動,Shift+方向鍵或 {cmd}+方向鍵可調整大小,Enter 選取,Escape 取消" +"Selection colors" = "選取範圍顏色" +"Send backward" = "下移一層" +"Send report" = "傳送回報" +"Send to back" = "移至最下層" +"Sep" = "9 月" +"Server address" = "伺服器位址" +"Server address (ws://host:47900/)" = "伺服器位址 (ws://host:47900/)" +"Server for online sharing" = "線上分享伺服器" +"Server key" = "伺服器金鑰" +"Set as default" = "設為預設" +"Set corner smoothing to 60%" = "將圓角平滑度設為 60%" +"Set fill container" = "設為填滿容器" +"Set hug contents" = "設為緊貼內容" +"Set variable" = "設定變數" +"Set variable mode" = "設定變數模式" +"Set variable {name}" = "設定變數 {name}" +"Set {name} to {value}" = "將 {name} 設為 {value}" +"Shadows" = "陰影" +"Shape" = "圖形" +"Shape builder" = "圖形建立器" +"Shape tools" = "圖形工具" +"Shapes" = "圖形" +"Share" = "分享" +"Share this file" = "分享此檔案" +"Share with a code" = "使用代碼分享" +"Share: live session, export" = "分享:即時工作階段、匯出" +"Shared" = "已分享" +"Shared by your team on {server}" = "由你的團隊在 {server} 上分享" +"Shared: copy its code from the file's menu" = "已分享:從檔案選單複製其代碼" +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "在本機網路上分享 (線上分享已關閉:偏好設定中未設定伺服器)。" +"Sharing on your network · {people}" = "在你的網路上分享 · {people}" +"Sharing online · {people}" = "線上分享 · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "分享已停止:檔案會留在伺服器上,僅對你開放" +"Show" = "顯示" +"Show all templates" = "顯示所有範本" +"Show as grid" = "以格狀顯示" +"Show as list" = "以清單顯示" +"Show autosaves" = "顯示自動儲存" +"Show behind transparent areas" = "顯示透明區域後方" +"Show comments (Shift+C)" = "顯示留言 (Shift+C)" +"Show device frame" = "顯示裝置外框" +"Show dimensions on objects" = "在物件上顯示尺寸" +"Show hotspot hints" = "顯示熱區提示" +"Show in Explorer" = "在檔案總管中顯示" +"Show in Finder" = "在 Finder 中顯示" +"Show in folder" = "在資料夾中顯示" +"Show only this on the canvas" = "僅在畫布上顯示此項目" +"Show open" = "顯示開啟" +"Show section contents" = "顯示區段內容" +"Show this layer" = "顯示此圖層" +"Show usage" = "顯示使用方式" +"Show/Hide" = "顯示/隱藏" +"Show/Hide UI" = "顯示/隱藏介面" +"Side by side" = "並排" +"Sidebar" = "側邊欄" +"Simplify stroke" = "簡化邊框" +"Size" = "大小" +"Size jitter" = "大小抖動" +"Skip ink" = "略過墨水" +"Slashed zero" = "斜線零" +"Slice" = "切片" +"Slices" = "切片" +"Slide in" = "滑入" +"Slide out" = "滑出" +"Slot" = "插槽" +"Slot content" = "插槽內容" +"Slot name" = "插槽名稱" +"Small capitals" = "小型大寫" +"Small caps" = "小型大寫" +"Smart animate" = "智慧動畫" +"Snap to pixel grid" = "貼齊像素格線" +"Social and slides" = "社群與投影片" +"Social media" = "社群媒體" +"Social post sizes" = "社群貼文尺寸" +"Soft light" = "柔光" +"Solid" = "實線" +"Some variants have the same property values: give each one a unique combination" = "部分變體有相同的屬性值:請為每個變體提供唯一組合" +"Someone who left" = "已離開的人員" +"Sort" = "排序" +"Sort by" = "排序依據" +"Sort by date" = "依日期排序" +"Sort by page" = "依頁面排序" +"Sort by {column}" = "依 {column} 排序" +"Space around" = "環繞留白" +"Space between" = "平均間距" +"Space drag" = "空白鍵拖曳" +"Space evenly" = "平均分配" +"Space+drag" = "空白鍵 + 拖曳" +"Spacing" = "間距" +"Spam, stolen work, abuse…" = "垃圾內容、盜用作品、濫用…" +"Speech bubble" = "對話泡泡" +"Splay" = "展開" +"Spotlight me" = "聚焦我" +"Spread" = "擴散" +"Square" = "方形" +"Stamp" = "印章" +"Stamps" = "印章" +"Standard" = "標準" +"Standard ligatures" = "標準連字" +"Star" = "星形" +"Stars" = "星形" +"Start" = "開始" +"Start ascending from / stop descending at" = "從…開始遞增 / 在…停止遞減" +"Start from a blank canvas" = "從空白畫布開始" +"Start from a template" = "從範本開始" +"Start on path" = "路徑起點" +"Start on the Home screen" = "從首頁畫面開始" +"Start point" = "起點" +"Start sharing" = "開始分享" +"Start sharing with the Share button, then pass on the code it gives" = "使用「分享」按鈕開始分享,然後傳遞它提供的代碼" +"Start vote" = "開始投票" +"Starting to share…" = "正在開始分享…" +"Status" = "狀態" +"Sticky" = "黏性" +"Sticky note" = "便利貼" +"Sticky note colors" = "便利貼顏色" +"Sticky notes" = "便利貼" +"Sticky notes, shapes and connectors" = "便利貼、圖形與連接線" +"Stop" = "停止" +"Stop following {name}" = "停止跟隨 {name}" +"Stop session" = "停止工作階段" +"Stop sharing" = "停止分享" +"Stop spotlighting" = "停止聚焦" +"Stop timer" = "停止計時器" +"Stop {n} {hex}" = "停駐點 {n} {hex}" +"Straight" = "直線" +"Stretch" = "拉伸" +"Strikethrough" = "刪除線" +"String" = "字串" +"Stroke" = "邊框" +"Stroke style" = "邊框樣式" +"Stroke weight" = "邊框粗細" +"Strokes" = "邊框" +"Strong line every" = "每隔…顯示粗線" +"Style" = "樣式" +"Styles" = "樣式" +"Stylistic set 1" = "樣式集 1" +"Stylistic set 2" = "樣式集 2" +"Stylistic set 3" = "樣式集 3" +"Stylistic set 4" = "樣式集 4" +"Stylistic set 5" = "樣式集 5" +"Subtract" = "減去" +"Subtracts" = "減去" +"Suffix" = "後綴" +"Suspend snapping while dragging" = "拖曳時暫停貼齊" +"Swap instance" = "替換實例" +"Swap library: use another library's assets of the same names" = "替換資源庫:使用另一個資源庫中相同名稱的資源" +"Swap orientation" = "交換方向" +"Swap overlay" = "替換覆蓋層" +"Swap to {name}" = "替換為 {name}" +"Swap width and height" = "交換寬度與高度" +"Swapped instance" = "已替換實例" +"Swapped {n} assets" = { other = "已替換 {n} 個資源" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "已替換 {n} 個資源;{missed} 個找不到相符項目,仍使用舊資源庫" } +"Sweep" = "掃掠" +"System" = "系統" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "表格" +"Tablet" = "平板" +"Tabular numbers" = "表格數字" +"Take the results off the board; they stay in the file" = "從白板移除結果;結果仍會保留在檔案中" +"Take the values from the first selected layer" = "採用第一個選取圖層的值" +"Taper" = "錐形" +"Taper both ends" = "兩端漸細" +"Tell the server's operator what is wrong with it" = "向伺服器操作人員說明問題所在" +"Temperature" = "色溫" +"Template" = "範本" +"Templates" = "範本" +"Terms" = "條款" +"Text" = "文字" +"Text align" = "文字對齊" +"Text content" = "文字內容" +"Text on a path" = "路徑上的文字" +"Text size" = "文字大小" +"Text style" = "文字樣式" +"Text styles" = "文字樣式" +"Texts" = "文字" +"Texture" = "材質" +"The bars are hidden. {shortcut} shows them again" = "工具列已隱藏。按 {shortcut} 可再次顯示" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "畫布以 sRGB 預覽顏色;匯出的 PNG、JPG 與 WebP 檔案會標記為 Display P3。" +"The destination document is unavailable. Nothing was imported." = "目的地文件無法使用。未匯入任何內容。" +"The destination document was closed. Nothing was imported." = "目的地文件已關閉。未匯入任何內容。" +"The destination page was removed. Nothing was imported." = "目的地頁面已移除。未匯入任何內容。" +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "檔案已從伺服器刪除:任何人都無法再從該處開啟或加入,且此操作無法復原。已開啟的副本仍會留在持有者手中。" +"The file now holds another library" = "檔案現在包含另一個資源庫" +"The file now holds another library (\"{name}\")" = "檔案現在包含另一個資源庫 (「{name}」)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "你從此電腦在線上分享或發佈的檔案。它們會留在其伺服器上,直到你將其移除。" +"The link carries the session's secret: share it only with people you invite." = "此連結含有工作階段的密鑰:請只分享給你邀請的人。" +"The name in your online files and on the server. The document inside keeps its own name." = "你的線上檔案與伺服器上顯示的名稱。內部的文件會保留自己的名稱。" +"The selection uses several text styles: click to apply one" = "選取範圍使用多種文字樣式:點擊以套用其中一種" +"The server deletes this file if nobody opens it before {date}." = "若在 {date} 前無人開啟,伺服器會刪除此檔案。" +"The whole file" = "整個檔案" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "視窗太小,無法以 {size}% 顯示,因此介面以 {drawn}% 繪製。請將視窗放大以使用 {size}%。" +"Theme" = "主題" +"There's no destination for that action" = "該動作沒有目的地" +"Thickness" = "粗細" +"This branch can't be reviewed: {e}" = "無法檢閱此分支:{e}" +"This branch was merged or archived." = "此分支已合併或封存。" +"This computer only" = "僅限此電腦" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "此電腦會記住擁有者權杖,因此可從首頁的線上清單管理檔案。複製權杖即可從其他位置管理檔案:它是此檔案的密碼,不會再次顯示。" +"This condition is edited as an expression" = "此條件以運算式編輯" +"This file" = "此檔案" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "此檔案位於 {server},僅對你開放。分享後,其他人即可使用新代碼加入。" +"This file is too large to join here (up to {mb} MB)" = "此檔案太大,無法在此加入 (上限 {mb} MB)" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "此檔案使用的字型未安裝在此電腦上。在你安裝前,其文字會以備用字型繪製。" +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "此檔案使用你尚未在此使用的伺服器上的團隊資源庫。在你檢查前,不會聯絡這些伺服器。" +"This frame is new on the branch." = "此框架是分支上的新項目。" +"This frame isn't on the branch." = "此框架不在分支上。" +"This link opens a website outside the prototype:" = "此連結會在原型外部開啟網站:" +"This link opens your mail app to write a message:" = "此連結會開啟你的郵件應用程式以撰寫訊息:" +"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "此頁面僅在開啟期間保留擁有者權杖。請複製權杖以便日後管理檔案:它是此檔案的密碼,不會再次顯示。" +"This server doesn't count detached instances" = "此伺服器不計入已分離的實例" +"This server doesn't count insertions" = "此伺服器不計入插入項目" +"This server doesn't count styles and variables" = "此伺服器不計入樣式與變數" +"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." = "此伺服器僅接受附有金鑰的檔案:請參閱「進階」。" +"This version can't be read." = "無法讀取此版本。" +"This version doesn't have it: all of it is new." = "此版本沒有該項目:全部都是新的。" +"Thumbs up" = "讚" +"Tidy up" = "整理" +"Tile" = "並排" +"Tile type" = "並排類型" +"Timer" = "計時器" +"Tint" = "色調" +"Title" = "標題" +"Title case" = "標題大小寫" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "若要與團隊分享此資源庫,請將檔案發佈至伺服器 (分享 ▸ 發佈至伺服器…),然後在該處發佈資源庫。" +"To the layer around it" = "至周圍的圖層" +"Toggle" = "切換" +"Toggle light/dark theme" = "切換淺色/深色主題" +"Toggle visibility" = "切換可見度" +"Tools" = "工具" +"Top" = "上" +"Top & bottom" = "上與下" +"Top left radius" = "左上圓角半徑" +"Top padding" = "上內距" +"Top right radius" = "右上圓角半徑" +"Transform" = "變形" +"Trapezoid" = "梯形" +"Triangle" = "三角形" +"Triangle arrow" = "三角形箭頭" +"Triangle down" = "向下三角形" +"Trigger" = "觸發條件" +"Trigger layer" = "觸發圖層" +"True" = "True" +"Truncate text" = "截斷文字" +"Truncate with an ellipsis" = "以省略號截斷" +"Try again" = "再試一次" +"Try another search or filter." = "請嘗試其他搜尋或篩選條件。" +"Try another search, or browse templates." = "請嘗試其他搜尋,或瀏覽範本。" +"Try another word, or look at every kind." = "請嘗試其他關鍵字,或查看所有類型。" +"Turn a layer into a component, reuse it, and change every copy at once" = "將圖層變成元件、重複使用,並一次變更所有副本" +"Two digits" = "兩位數" +"Type" = "類型" +"Type settings" = "文字設定" +"Type the join code you were given, or paste an invite link." = "輸入你收到的加入代碼,或貼上邀請連結。" +"Typography" = "排版" +"Underline" = "底線" +"Undo takes back only your own changes; what others changed since stays." = "復原只會撤回你自己的變更;其他人之後的變更仍會保留。" +"Ungroup" = "取消群組" +"Uniform" = "均勻" +"Union" = "聯集" +"Unions" = "聯集" +"Unknown — treated as sRGB" = "未知 — 以 sRGB 處理" +"Unread" = "未讀" +"Unresolve" = "取消解決" +"Unsaved changes" = "未儲存的變更" +"Unsaved changes in {names}. Save them before closing?" = "{names} 有未儲存的變更。要在關閉前儲存嗎?" +"Up" = "上" +"Update" = "更新" +"Update check is not supported in the web browser." = "網頁瀏覽器不支援檢查更新。" +"Update from main" = "從主線更新" +"Update from selection" = "從選取範圍更新" +"Update plugin" = "更新外掛" +"Updated {components} and {styles}" = "已更新 {components} 與 {styles}" +"Updated {name}" = "已更新 {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "更新圖形驅動程式通常有幫助。若只想使用 OpenGL,請從命令提示字元以下列方式啟動 FigCraft:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "更新圖形驅動程式通常有幫助。WGPU_BACKEND (vulkan、gl、metal) 可指定要嘗試的其他圖形 API。" +"Upload from computer" = "從電腦上傳" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "將此檔案上傳至 FigCraft 伺服器,並讓此分頁與其保持同步。你會取得編輯連結與檢視連結。" +"Uppercase" = "大寫" +"Use a fixed value" = "使用固定值" +"Use an expression, e.g. count + 1" = "使用運算式,例如 count + 1" +"Use as mask" = "用作遮罩" +"Use auto layout" = "使用 Auto Layout" +"Use fonts installed on this computer…" = "使用此電腦安裝的字型…" +"Use number keys for opacity" = "使用數字鍵設定不透明度" +"Use the .figcraft or .fig extension." = "請使用 .figcraft 或 .fig 副檔名。" +"Use the layer's fill" = "使用圖層的填色" +"Use your browser's zoom to change the interface size." = "使用瀏覽器的縮放功能變更介面大小。" +"Used in {files} · {instances}" = "用於 {files} · {instances}" +"Username" = "使用者名稱" +"Uses variables: each layer shows them in its own mode" = "使用變數:每個圖層會以自己的模式顯示" +"Value" = "值" +"Variable" = "變數" +"Variable collections" = "變數集合" +"Variable modes" = "變數模式" +"Variable width" = "可變寬度" +"Variables" = "變數" +"Variables JSON…" = "變數 JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "變數儲存可重複使用的值 (顏色、數字、字串、布林),並可搭配淺色、深色等模式。" +"Variant" = "變體" +"Vector" = "向量" +"Vectors" = "向量" +"Version" = "版本" +"Version history" = "版本記錄" +"Version {version}" = "版本 {version}" +"Version {v} · {components} · {styles} · {variables}" = "版本 {v} · {components} · {styles} · {variables}" +"Vertical" = "垂直" +"Vertical align" = "垂直對齊" +"Vertical hex" = "垂直六角形" +"Vertical padding" = "垂直內距" +"Vertical spacing" = "垂直間距" +"Vertical trim" = "垂直修剪" +"View" = "檢視" +"View link" = "檢視連結" +"View link copied" = "已複製檢視連結" +"View only" = "僅限檢視" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "僅限檢視:你能即時看到每項變更,但不能編輯。離開即可保留一份可編輯的副本。" +"Viewing “{name}”" = "正在檢視「{name}」" +"Violet" = "紫羅蘭色" +"Visibility" = "可見度" +"Visible" = "可見" +"Vivid" = "鮮豔" +"Votes per person" = "每人票數" +"Voting session" = "投票工作階段" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已設定 WGPU_BACKEND,並指定要使用的圖形 API;取消設定即可讓 FigCraft 自行選擇。" +"Warm" = "暖色" +"Watch" = "手錶" +"Wavy" = "波浪" +"Web" = "網頁" +"WebSocket URL" = "WebSocket URL" +"WebSocket URL copied" = "已複製 WebSocket URL" +"Websites" = "網站" +"Wedge" = "楔形" +"Weekly planner" = "每週計畫表" +"Weight" = "字重" +"What changed since a saved version" = "與已儲存版本相比的變更" +"What changed since it was marked ready for dev" = "與標示為準備交付開發時相比的變更" +"What is wrong with this file? The server's operator gets your report." = "此檔案有什麼問題?伺服器操作人員會收到你的回報。" +"What it's for" = "用途" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "無法得知此變更的原始狀態:它在 FigCraft 開始保留準備交付開發框架副本之前就已變更。請解決此變更 (或再次標示為準備交付開發),以便日後進行比較。" +"What you try here changes this preview and the code below, not the file" = "在此嘗試的內容會變更此預覽與下方的程式碼,不會變更檔案" +"When marked ready" = "標示為可交付時" +"While hovering" = "懸停時" +"While pressing" = "按下時" +"White" = "白色" +"Who runs it, its terms and how long it keeps files" = "由誰營運、其條款以及檔案保留多久" +"Whole screen" = "整個畫面" +"Why it changed" = "變更原因" +"Widget" = "小工具" +"Widgets" = "小工具" +"Width" = "寬度" +"Width point" = "寬度節點" +"Width profile" = "寬度輪廓" +"Wireframe kit" = "線框套件" +"With the layers' export settings" = "使用圖層的匯出設定" +"Wrap" = "換行" +"Wrap in new section" = "包裝至新區段" +"Wrap style" = "換行樣式" +"Yellow" = "黃色" +"Yesterday" = "昨天" +"You" = "你" +"You'll get the shared file as a new tab and edit it together." = "你會以新分頁取得分享的檔案,並可一起編輯。" +"You're spotlighting: everyone follows your view" = "你正在聚焦:所有人都會跟隨你的檢視" +"Your copy of the file stays open" = "你的檔案副本會保持開啟" +"Your copy stays open; the file stays on the server" = "你的副本會保持開啟;檔案會留在伺服器上" +"Your name" = "你的名字" +"Your system asks for reduced motion: animations stay off." = "你的系統要求減少動態效果:動畫會保持關閉。" +"Your unsaved files were too large for the browser to keep." = "你的未儲存檔案太大,瀏覽器無法保留。" +"Z" = "Z" +"Z to A" = "Z 到 A" +"Zoom" = "縮放" +"Zoom around the pointer" = "以指標為中心縮放" +"Zoom device to fill window" = "縮放裝置以填滿視窗" +"Zoom in" = "放大" +"Zoom out" = "縮小" +"Zoom to 100%" = "縮放至 100%" +"Zoom to 200%" = "縮放至 200%" +"Zoom to 50%" = "縮放至 50%" +"Zoom to fit" = "縮放以符合" +"Zoom to selection" = "縮放至選取範圍" +"Zoom tool: click to zoom in, drag an area" = "縮放工具:點擊以放大,拖曳出區域" +"Zoom tool: zoom out" = "縮放工具:縮小" +"and {n} more" = { other = "還有 {n} 個" } +"bleed marks" = "出血標記" +"by {author}" = "由 {author}" +"call any command its access allows" = "可呼叫其存取權限允許的任何指令" +"call only: {commands}" = "僅可呼叫:{commands}" +"can edit the document" = "可以編輯文件" +"can save files" = "可以儲存檔案" +"conditional prototype actions nested too deeply were skipped" = "巢狀層級過深的條件式原型動作已略過" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "條件無法評估的條件式原型動作已略過" +"connector label background was skipped: style data is missing" = "已略過連接線標籤背景:缺少樣式資料" +"connector label was skipped: background bounds are invalid" = "已略過連接線標籤:背景邊界無效" +"connector label was skipped: background transform is invalid" = "已略過連接線標籤:背景變形無效" +"connector label was skipped: composed text transform is invalid" = "已略過連接線標籤:組合文字變形無效" +"connector label was skipped: derived background bounds are missing" = "已略過連接線標籤:缺少衍生的背景邊界" +"connector label was skipped: derived text bounds are missing" = "已略過連接線標籤:缺少衍生的文字邊界" +"connector label was skipped: text bounds are invalid" = "已略過連接線標籤:文字邊界無效" +"connector label was skipped: text style data is missing" = "已略過連接線標籤:缺少文字樣式資料" +"connector label was skipped: text transform is invalid" = "已略過連接線標籤:文字變形無效" +"connector labels were skipped" = "已略過連接線標籤" +"connector labels without a saved snapshot are centred on the line, without their background" = "沒有已儲存快照的連接線標籤會置中於線上,且不顯示背景" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "連接線圖層會匯入為靜態框架/向量快照 (若有標籤則一併匯入);不會保留即時端點綁定" +"connector snapshot vector data is missing; connector was left empty" = "缺少連接線快照向量資料;連接線保留為空白" +"connectors whose ends can't be placed are empty" = "端點無法放置的連接線會是空白" +"crop marks" = "裁切標記" +"damaged vector geometry was skipped" = "已略過損毀的向量幾何" +"damaged vector networks import as outlines" = "損毀的向量網路會匯入為外框" +"development: {path}" = "開發:{path}" +"document children other than pages were skipped" = "已略過文件中頁面以外的子項目" +"duplicate grid track sizing entries use equal tracks" = "重複的格線軌道大小項目會使用相等軌道" +"e.g. count + 1" = "例如 count + 1" +"e.g. count >= 3 and not done" = "例如 count >= 3 and not done" +"elbowed and curved connectors without a saved path follow FigCraft's own routing" = "沒有儲存路徑的肘形與曲線連接線會採用 FigCraft 自己的路徑規劃" +"entries without a node type were skipped" = "已略過沒有節點類型的項目" +"figcraft:// links from a browser, chat or email open this app and join the session." = "來自瀏覽器、聊天或電子郵件的 figcraft:// 連結會開啟此應用程式並加入工作階段。" +"from {server}" = "來自 {server}" +"gamepad triggers without exactly one known controller button were skipped" = "已略過未恰好對應一個已知控制器按鈕的遊戲手把觸發條件" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "格線軌道清單超過 1000 個項目;軌道會使用預設的相等大小" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "不支援或格式錯誤的格線軌道大小;受影響的軌道會使用相等大小" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "格線軌道大小清單超過 1000 個項目;軌道會使用預設的相等大小" +"hidden" = "隱藏" +"iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "圖片填色若其圖片不在檔案中,會顯示缺少圖片的預留位置並保留其名稱" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "因為兩個篩選容器同時存在,圖片篩選不明確;已略過篩選轉換" +"image filters use FigCraft's approximate renderer" = "圖片篩選使用 FigCraft 的近似轉譯器" +"instance swap properties whose default component isn't in the file were skipped" = "已略過預設元件不在檔案中的實例替換屬性" +"instance swap properties whose default component was past the layer limit were skipped" = "已略過預設元件超出圖層上限的實例替換屬性" +"instances inside their own main component were imported as frames" = "位於自身主要元件內的實例已匯入為框架" +"instances of components that aren't in the file show no layers" = "檔案中不存在之元件的實例不會顯示圖層" +"instances of components that aren't in the file were imported as frames" = "檔案中不存在之元件的實例已匯入為框架" +"instances that would nest a component inside itself were imported as frames" = "會將元件巢狀於自身內的實例已匯入為框架" +"instances whose component was past the layer limit were imported as frames" = "元件超出圖層上限的實例已匯入為框架" +"instances without a main component were imported as empty frames" = "沒有主要元件的實例已匯入為空白框架" +"invalid internal .fig text path data was ignored" = "已忽略無效的內部 .fig 文字路徑資料" +"invalid or oversized vector networks import as outlines" = "無效或過大的向量網路會匯入為外框" +"invalid or oversized vector path data was skipped" = "已略過無效或過大的向量路徑資料" +"invalid vector region fill paint list; layer fill used" = "無效的向量區域填色塗色清單;已使用圖層填色" +"invalid vector region paints were skipped" = "已略過無效的向量區域塗色" +"just now" = "剛剛" +"key triggers without a supported key were skipped" = "已略過沒有支援按鍵的按鍵觸發條件" +"key up triggers import as key presses" = "按鍵放開觸發條件會匯入為按鍵按下" +"layers without a position were placed at their parent's origin" = "沒有位置的圖層已放置在父層原點" +"layers without a type were skipped" = "已略過沒有類型的圖層" +"legacy image filter container is unsupported; filter conversion was skipped" = "不支援舊版圖片篩選容器;已略過篩選轉換" +"linked vector region fill styles keep resolved appearance; live style links were left out" = "連結的向量區域填色樣式會保留解析後外觀;即時樣式連結已省略" +"links to shared styles that aren't in the file kept the layers' own values" = "指向檔案中不存在之共用樣式的連結會保留圖層本身的值" +"locked" = "鎖定" +"malformed image filters container was skipped" = "已略過格式錯誤的圖片篩選容器" +"malformed image paintFilter container was skipped" = "已略過格式錯誤的圖片 paintFilter 容器" +"malformed slot content assignment was skipped" = "已略過格式錯誤的插槽內容指派" +"no bleed" = "無出血" +"nobody else here yet" = "目前還沒有其他人" +"non-default slot settings are not represented and were skipped" = "未呈現非預設的插槽設定,並已略過" +"note or shape generated child has invalid bounds; child skipped" = "附註或圖形產生的子項目邊界無效;已略過子項目" +"note or shape generated child is missing its style or bounds; child skipped" = "附註或圖形產生的子項目缺少樣式或邊界;已略過子項目" +"note or shape generated children exceed the layer or nesting limit; remaining children skipped" = "附註或圖形產生的子項目超過圖層或巢狀上限;已略過其餘子項目" +"note or shape generated layout is missing or unsupported; using its outer frame" = "附註或圖形產生的版面配置缺少或不支援;改用其外層框架" +"now" = "現在" +"offer files to save if it asks to (you choose where each one goes)" = "若它要求,提供要儲存的檔案 (由你選擇每個檔案的位置)" +"only in a text style or variable" = "僅在文字樣式或變數中" +"or" = "或" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "已保留節點邊框接合;虛線或可變寬度邊框會以圖層接合方式轉譯" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "無法將偏好的插槽元件索引鍵解析為檔案節點,並已略過" +"prototype actions of unknown types were skipped" = "已略過未知類型的原型動作" +"prototype actions without a destination in the file were skipped" = "已略過檔案中沒有目的地的原型動作" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "已略過變數不在變數 JSON 中的原型運算式" +"prototype expressions with unsupported functions were skipped" = "已略過含有不支援函式的原型運算式" +"prototype interactions without supported actions were skipped" = "已略過沒有支援動作的原型互動" +"prototype navigations of unknown types were skipped" = "已略過未知類型的原型導覽" +"prototype transitions of unknown types play instantly" = "未知類型的原型轉場會立即播放" +"prototype triggers of unknown types were skipped" = "已略過未知類型的原型觸發條件" +"put a widget on the canvas" = "將小工具放到畫布上" +"read and edit the document" = "讀取並編輯文件" +"read the document" = "讀取文件" +"reads the document" = "讀取文件" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "已略過變數不在變數 JSON 中的設定變數動作" +"set-variable actions with values that can't be evaluated were skipped" = "已略過值無法評估的設定變數動作" +"set-variable actions with values that don't fit the variable were skipped" = "已略過值不符合變數的設定變數動作" +"set-variable actions without a value were skipped" = "已略過沒有值的設定變數動作" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "已略過集合或模式不在變數 JSON 中的設定變數模式動作" +"shape generated outline is missing or damaged; using its rectangle" = "圖形產生的外框缺少或損毀;改用其矩形" +"slot content assignment target is missing or is not slot content" = "插槽內容指派的目標缺少或不是插槽內容" +"slot content assignment uses the empty GUID sentinel and was skipped" = "插槽內容指派使用空白 GUID 哨兵值,並已略過" +"slot layers without a matching slot property were imported as frames" = "沒有相符插槽屬性的插槽圖層已匯入為框架" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "部分向量節點指向檔案中缺少的節點設定;它們會使用圖層的接合與一般圓角" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "部分向量線段指向檔案中缺少的樣式設定;它們會使用圖層的邊框" +"stale or invalid grid track sizing entries were ignored" = "已忽略過期或無效的格線軌道大小項目" +"stars and polygons without point counts or geometry use the default point count" = "沒有節點數或幾何的星形與多邊形會使用預設節點數" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "不支援部分向量線段的樣式設定;這些線段會使用圖層的邊框" +"text path underline or strikethrough decoration is not rendered" = "不會轉譯文字路徑的底線或刪除線裝飾" +"text style overrides refer to missing table entries" = "文字樣式覆寫指向缺少的表格項目" +"the file has no pages" = "檔案沒有頁面" +"the plugin" = "此外掛" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "變數 JSON 沒有 `variableCollections` (應為本機變數回應),已忽略" +"this session only" = "僅限此工作階段" +"unknown component property types were skipped" = "已略過未知的元件屬性類型" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "已略過指向變數 JSON 中不存在 (或類型不同) 之變數的變數別名" +"variable bindings to properties the layer doesn't have were skipped" = "已略過指向圖層沒有之屬性的變數綁定" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "已略過指向變數 JSON 中不存在之變數 (資源庫變數) 的變數綁定" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "已略過變數綁定:檔案 JSON 沒有變數定義 (請將本機變數 JSON 作為 `variables` 傳入,或將其 `meta` 嵌入檔案 JSON 旁)" +"variable bindings whose variable type doesn't fit the property were skipped" = "已略過變數類型不符合屬性的變數綁定" +"variable collections without modes were skipped" = "已略過沒有模式的變數集合" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "已略過原型運算式中不是變數及其模式之一的變數模式查詢" +"variable modes of collections that aren't in the file were skipped" = "已略過集合不在檔案中的變數模式" +"variable modes of collections that aren't in the variables JSON were skipped" = "已略過集合不在變數 JSON 中的變數模式" +"variable values that don't match their type (or composed colours) were skipped" = "已略過類型不符 (或組合顏色) 的變數值" +"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "綁定變數的向量區域塗色會保留解析後外觀;即時綁定已省略" +"variables of unknown types were skipped" = "已略過未知類型的變數" +"variables whose collection isn't in the variables JSON were skipped" = "已略過集合不在變數 JSON 中的變數" +"variables without readable values were skipped" = "已略過沒有可讀值的變數" +"vector points with unknown stroke joins use the layer join" = "具有未知邊框接合的向量節點會使用圖層接合" +"vector region paint data exceeded import limits and was skipped" = "向量區域塗色資料超過匯入限制,已略過" +"vector region references a missing fill style; layer fill used" = "向量區域參照缺少的填色樣式;已使用圖層填色" +"vectors without geometry are empty" = "沒有幾何的向量會是空白" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "沒有幾何的向量會是空白 (請以 geometry=paths 匯出檔案 JSON)" +"very long text was cut to 1,048,576 characters" = "過長的文字已裁切至 1,048,576 個字元" +"very long vector paths were skipped" = "已略過過長的向量路徑" +"video actions are not supported and were skipped" = "不支援影片動作,已略過" +"video triggers are not supported and were skipped" = "不支援影片觸發條件,已略過" +"yesterday" = "昨天" +"{count} in it now · Changed {date}" = "目前有 {count} 個 · 已變更 {date}" +"{insertions} · {detachments}" = "{insertions} · {detachments}" +"{instances} in {files}" = "{instances},位於 {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}:草稿翻譯,等待母語者審閱。" +"{length} bleed" = "{length} 出血" +"{month} {day}, {year}" = "{year} 年 {month} {day} 日" +"{name}\nClick to apply to the selection" = "{name}\n點擊以套用至選取範圍" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖曳到畫布上,或點擊以放置在檢視中央。\n拖放至實例上時按住 Alt (Option) 即可替換。" +"{name} (from {library})" = "{name} (來自 {library})" +"{name} (you)" = "{name} (你)" +"{name} ({layers} layers)" = "{name} ({layers} 個圖層)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}):{min} – {max},預設 {default}。按兩下滑桿可回到樣式的值;拖曳時按 Escape 可取消。" +"{name} cleared from the list" = "已從清單清除 {name}" +"{name} from {path}" = "{name},來自 {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} 已在此電腦上忘記;檔案會留在 {server}" +"{name} is on the server." = "{name} 在伺服器上。" +"{name} is spotlighting" = "{name} 正在聚焦" +"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,無法在瀏覽器中開啟 (超過 512 MB)。" +"{name} was removed from the server" = "{name} 已從伺服器移除" +"{name} · deleted on main" = "{name} · 已在主線刪除" +"{name} · deleted on the branch" = "{name} · 已在分支刪除" +"{name} · not published" = "{name} · 未發佈" +"{name} · on a server" = "{name} · 在伺服器上" +"{name} · published version {v}" = "{name} · 已發佈版本 {v}" +"{name} · version {v} on the server" = "{name} · 伺服器上的版本 {v}" +"{name} ×{n}" = "{name} ×{n}" +"{name}, in a live session" = "{name},在即時工作階段中" +"{name}, online, open only to you" = "{name},線上,僅對你開放" +"{name}, {kind}, {w} by {h}, selected" = "{name},{kind},{w} × {h},已選取" +"{n} added" = { other = "已新增 {n} 個" } +"{n} autosave versions" = { other = "{n} 個自動儲存版本" } +"{n} changed" = { other = "已變更 {n} 個" } +"{n} columns" = { other = "{n} 欄" } +"{n} components" = { other = "{n} 個元件" } +"{n} components and styles have newer versions" = { other = "{n} 個元件與樣式有較新版本" } +"{n} conflicts to resolve" = { other = "{n} 個衝突待解決" } +"{n} days ago" = { other = "{n} 天前" } +"{n} detached" = { other = "已分離 {n} 個" } +"{n} files" = { other = "{n} 個檔案" } +"{n} hours ago" = { other = "{n} 小時前" } +"{n} inserted" = { other = "已插入 {n} 個" } +"{n} instances" = { other = "{n} 個實例" } +"{n} layers" = { other = "{n} 個圖層" } +"{n} layers as one file" = { other = "{n} 個圖層作為一個檔案" } +"{n} layers selected" = { other = "已選取 {n} 個圖層" } +"{n} layers · {parts}" = { other = "{n} 個圖層 · {parts}" } +"{n} matches" = { other = "{n} 個相符項目" } +"{n} min" = "{n} 分鐘" +"{n} minutes ago" = { other = "{n} 分鐘前" } +"{n} months ago" = { other = "{n} 個月前" } +"{n} more notes omitted from this report" = { other = "此報告省略了另外 {n} 則附註" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { other = "{n} 人加入" } +"{n} preferred components" = { other = "{n} 個偏好元件" } +"{n} ready" = { other = "{n} 個就緒" } +"{n} removed" = { other = "已移除 {n} 個" } +"{n} replies" = { other = "{n} 則回覆" } +"{n} rows" = { other = "{n} 列" } +"{n} styles" = { other = "{n} 個樣式" } +"{n} text layers" = { other = "{n} 個文字圖層" } +"{n} variables" = { other = "{n} 個變數" } +"{n} votes" = { other = "{n} 票" } +"{n} votes left" = { other = "剩餘 {n} 票" } +"{n} weeks ago" = { other = "{n} 週前" } +"{n} years ago" = { other = "{n} 年前" } +"{n}d" = "{n}d" +"{n}h" = "{n}h" +"{n}m" = "{n}m" +"{n}w" = "{n}w" +"{page}, nothing selected" = "{page},未選取任何項目" +"{percent}%" = "{percent}%" +"{period}: {counts}" = "{period}:{counts}" +"{profile} (current)" = "{profile} (目前)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} 是其值;{property ? \"a\" : \"b\"} 會挑選字串。其他 {…} 依原樣保留。" +"{server} can't say what became of your files (it is older than this app)." = "{server} 無法說明你的檔案發生什麼事 (其版本早於此應用程式)。" +"{server} · No longer on the server" = "{server} · 已不在伺服器上" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · 已於 {date} 開啟" +"{tool} hold {key}" = "{tool} 按住 {key}" +"{used} · {detached}" = "{used} · {detached}" +"{used} · {inserted}" = "{used} · {inserted}" +"{value} isn't a colour" = "{value} 不是顏色" +"{when} · edited" = "{when} · 已編輯" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→、Space 或 N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ + 方向鍵" +"⌘ click" = "⌘ + 點擊" +"⌘ scroll" = "⌘ + 捲動" +"⌥ double-click an edge of an auto layout child" = "⌥ + 按兩下 Auto Layout 子物件的邊緣" +"⌥ drag" = "⌥ + 拖曳" +"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓" + +[commands] +"align.bottom" = "對齊底部" +"align.hcenter" = "水平置中對齊" +"align.left" = "靠左對齊" +"align.right" = "靠右對齊" +"align.top" = "靠上對齊" +"align.vcenter" = "垂直置中對齊" +"annotation.add" = "新增註解" +"annotation.addCategory" = "新增註解類別" +"annotation.categories" = "註解類別" +"annotation.edit" = "編輯註解" +"annotation.editCategory" = "編輯註解類別" +"annotation.list" = "列出註解" +"annotation.remove" = "移除註解" +"annotation.removeCategory" = "刪除註解類別" +"app.actions" = "動作" +"app.preferences" = "偏好設定" +"arrange.back" = "移至最下層" +"arrange.backward" = "下移一層" +"arrange.forward" = "上移一層" +"arrange.front" = "移至最上層" +"arrange.packH" = "水平緊密排列" +"arrange.packV" = "垂直緊密排列" +"arrange.reorder" = "重新排序選取範圍" +"arrange.roundToPixels" = "對齊像素" +"arrange.spacing" = "間距" +"arrange.tidy" = "整理" +"board.addConnected" = "新增連接圖形" +"board.connector" = "連接線" +"board.grid" = "白板格線" +"board.ink" = "麥克筆邊框" +"board.label" = "連接線標籤" +"board.nextSticky" = "下一張便利貼" +"board.reattach" = "重新連接連接線" +"board.reshape" = "重塑連接線" +"board.setColor" = "白板顏色" +"board.setStyle" = "白板樣式" +"board.shape" = "含文字的圖形" +"board.stamp" = "印章" +"board.sticky" = "便利貼" +"board.table" = "表格" +"board.tableEdit" = "編輯表格" +"board.template" = "白板範本" +"board.templates" = "列出白板範本" +"board.timer" = "計時器" +"board.timerState" = "計時器狀態" +"board.vote" = "投票工作階段" +"board.voteResults" = "投票結果" +"bool.exclude" = "從選取範圍排除" +"bool.flatten" = "扁平化布林" +"bool.intersect" = "選取範圍交集" +"bool.subtract" = "從選取範圍減去" +"bool.union" = "選取範圍聯集" +"branch.archive" = "封存分支" +"branch.create" = "建立分支" +"branch.list" = "列出分支" +"branch.merge" = "合併分支" +"branch.open" = "開啟分支" +"branch.review" = "檢閱分支" +"branch.updateFromMain" = "從主線更新" +"codeConnect.export" = "匯出程式碼連接" +"codeConnect.import" = "匯入程式碼連接" +"codeConnect.remove" = "中斷程式碼連接" +"codeConnect.set" = "連接至程式碼" +"collab.cursor" = "在…顯示游標" +"collab.cursorChat" = "游標聊天" +"collab.diff" = "顯示同步變更" +"collab.files" = "你的線上檔案" +"collab.follow" = "跟隨" +"collab.forgetFile" = "忘記線上檔案" +"collab.host" = "開始即時工作階段" +"collab.join" = "加入即時工作階段" +"collab.leave" = "離開即時工作階段" +"collab.manageFile" = "管理已發佈檔案" +"collab.openFile" = "開啟線上檔案" +"collab.peers" = "此檔案中的人員" +"collab.publish" = "發佈至伺服器" +"collab.recoverFile" = "復原線上檔案" +"collab.registerLinks" = "在 FigCraft 中開啟邀請連結" +"collab.removePerson" = "從工作階段移除" +"collab.report" = "向伺服器操作人員回報檔案" +"collab.serverInfo" = "伺服器資訊" +"collab.share" = "使用加入代碼分享" +"collab.spotlight" = "聚焦我" +"collab.stop" = "停止即時工作階段" +"command.batch" = "執行多個指令" +"comment.add" = "新增留言" +"comment.delete" = "刪除留言" +"comment.edit" = "編輯留言" +"comment.link" = "複製留言連結" +"comment.list" = "列出留言" +"comment.markRead" = "將留言標示為已讀" +"comment.move" = "移動留言" +"comment.next" = "下一則留言" +"comment.open" = "開啟留言" +"comment.people" = "留言中的人員" +"comment.prompt" = "複製供 AI 使用的留言" +"comment.react" = "對留言新增反應" +"comment.reply" = "回覆留言" +"comment.resolve" = "解決留言" +"comment.zoom" = "縮放至留言" +"component.addProperty" = "新增元件屬性" +"component.addVariant" = "新增變體" +"component.bindProperty" = "將圖層綁定至屬性" +"component.combineVariants" = "合併為變體" +"component.create" = "建立元件" +"component.createMultiple" = "建立多個元件" +"component.deleteProperty" = "刪除元件屬性" +"component.detach" = "分離實例" +"component.editProperty" = "編輯元件屬性" +"component.exposeInstance" = "公開巢狀實例" +"component.goToMain" = "前往主要元件" +"component.hideWhenPublishing" = "發佈時隱藏" +"component.list" = "列出元件" +"component.makeSlot" = "建立插槽" +"component.playground" = "元件遊樂場" +"component.properties" = "元件屬性" +"component.pushChanges" = "將變更推送至主要元件" +"component.removeSlot" = "移除插槽" +"component.resetInstance" = "重設所有變更" +"component.resetOverride" = "重設覆寫" +"component.restoreMain" = "還原主要元件" +"data.fill" = "以資料填入副本" +"dev.compare" = "比較變更" +"dev.focus" = "聚焦檢視" +"dev.resolveChange" = "解決變更" +"dev.unfocus" = "結束聚焦檢視" +"devMode.addMeasurement" = "新增測量" +"devMode.measurements" = "列出測量標記" +"devMode.removeMeasurement" = "移除測量標記" +"devResource.add" = "新增開發資源" +"devResource.edit" = "編輯開發資源" +"devResource.list" = "列出開發資源" +"devResource.remove" = "移除開發資源" +"distribute.horizontal" = "平均分佈水平間距" +"distribute.vertical" = "平均分佈垂直間距" +"doc.home" = "首頁" +"doc.list" = "列出文件" +"doc.nextTab" = "下一個分頁" +"doc.previousTab" = "上一個分頁" +"doc.switch" = "切換文件" +"edit.copy" = "複製" +"edit.copyAsCss" = "複製為 CSS" +"edit.copyAsPng" = "複製為 PNG" +"edit.copyAsSvg" = "複製為 SVG" +"edit.copyAsText" = "複製為文字" +"edit.copyLink" = "複製選取範圍連結" +"edit.copyProperties" = "複製屬性" +"edit.cut" = "剪下" +"edit.delete" = "刪除" +"edit.duplicate" = "建立副本" +"edit.findReplace" = "尋找與取代" +"edit.paste" = "貼上" +"edit.pasteDesignLayers" = "貼上設計圖層" +"edit.pasteOver" = "貼上至選取範圍上" +"edit.pasteProperties" = "貼上屬性" +"edit.pasteReplace" = "貼上以取代" +"edit.pickColor" = "挑選顏色" +"edit.redo" = "重做" +"edit.repeat" = "重複上一個指令" +"edit.setDefaultProperties" = "設定預設屬性" +"edit.undo" = "復原" +"effect.add" = "新增效果" +"effect.copy" = "複製效果" +"effect.move" = "移動效果" +"effect.paste" = "貼上效果" +"effect.remove" = "移除效果" +"effect.set" = "設定效果" +"export.add" = "新增匯出設定" +"export.remove" = "移除匯出設定" +"export.update" = "編輯匯出設定" +"file.close" = "關閉分頁" +"file.colorProfile" = "色彩設定檔" +"file.duplicate" = "建立檔案副本" +"file.export" = "匯出" +"file.exportFramesPdf" = "將框架匯出為 PDF" +"file.exportLayers" = "匯出圖層" +"file.exportSvg" = "將選取範圍匯出為 SVG" +"file.fontReport" = "檢視文件字型" +"file.import" = "匯入" +"file.markSaved" = "標示為已儲存" +"file.moveTab" = "移動分頁" +"file.new" = "新設計檔案" +"file.newBoard" = "新白板" +"file.newFromTemplate" = "從範本新增" +"file.open" = "開啟" +"file.openRecent" = "開啟最近項目" +"file.placeImage" = "置入圖片" +"file.recover" = "復原檔案" +"file.rename" = "重新命名檔案" +"file.revert" = "還原至已儲存版本" +"file.save" = "儲存" +"file.saveAs" = "另存新檔" +"file.saveVersion" = "儲存至版本記錄" +"file.versionHistory" = "顯示版本記錄" +"fill.add" = "新增填色" +"fill.copy" = "複製填色" +"fill.move" = "移動填色" +"fill.paste" = "貼上填色" +"fill.pattern" = "圖樣填色" +"fill.remove" = "移除填色" +"fill.set" = "設定填色" +"font.download" = "下載字型" +"font.downloads" = "字型下載" +"frame.create" = "建立框架" +"frame.presets" = "列出框架預設集" +"frame.swapOrientation" = "交換方向" +"grid.add" = "新增版面格線" +"grid.move" = "移動版面格線" +"grid.remove" = "移除版面格線" +"guide.add" = "新增參考線" +"guide.clear" = "移除所有參考線" +"guide.move" = "移動參考線" +"guide.remove" = "移除參考線" +"guide.select" = "選取參考線" +"help.about" = "關於 FigCraft" +"help.gettingStarted" = "開始使用" +"help.page" = "說明頁面" +"help.shortcuts" = "鍵盤快速鍵" +"image.adjust" = "調整圖片" +"image.crop" = "裁切圖片" +"image.deletePreset" = "刪除圖片預設集" +"image.place" = "置入圖片" +"image.presets" = "圖片預設集" +"image.replace" = "取代圖片" +"image.savePreset" = "儲存圖片預設集" +"inspect.assets" = "檢視資源" +"inspect.code" = "圖層程式碼" +"inspect.componentCode" = "元件程式碼" +"inspect.properties" = "檢視屬性" +"inspect.readyForDev" = "準備交付開發" +"instance.create" = "建立實例" +"instance.exposed" = "公開的巢狀實例" +"instance.place" = "置入實例" +"instance.resetSlot" = "重設插槽" +"instance.setProperties" = "設定實例屬性" +"instance.slotInsert" = "插入插槽" +"instance.swap" = "替換實例" +"instance.swapAt" = "在指定位置替換實例" +"layers.collapseAll" = "收合圖層" +"layout.add" = "新增 Auto Layout" +"layout.remove" = "移除 Auto Layout" +"layout.removeAll" = "移除所有 Auto Layout" +"layout.setTrack" = "設定格線軌道大小" +"layout.suggest" = "建議 Auto Layout" +"libraries.open" = "資源庫" +"libraries.publish" = "發佈資源庫…" +"library.acceptUpdates" = "接受資源庫更新" +"library.add" = "新增資源庫" +"library.aliasVariable" = "建立資源庫變數別名" +"library.applyStyle" = "套用資源庫樣式" +"library.bindVariable" = "綁定資源庫變數" +"library.check" = "檢查團隊資源庫更新" +"library.extendCollection" = "擴充資源庫集合" +"library.import" = "匯入資源庫元件" +"library.importVariable" = "匯入資源庫變數" +"library.list" = "列出資源庫" +"library.place" = "置入資源庫元件" +"library.publish" = "發佈資源庫" +"library.publishToServer" = "將資源庫發佈至伺服器" +"library.remove" = "移除資源庫" +"library.setVariableMode" = "設定資源庫變數模式" +"library.swap" = "替換資源庫" +"library.swapAt" = "將實例替換為資源庫元件" +"library.updates" = "資源庫更新" +"library.usage" = "資源庫使用方式" +"measure.distances" = "測量距離" +"measure.padding" = "測量 Auto Layout 內距" +"node.create" = "建立圖層" +"node.find" = "尋找圖層" +"node.get" = "取得圖層" +"node.reorder" = "重新排序圖層" +"node.reparent" = "將圖層移入" +"node.set" = "設定屬性" +"object.batchRename" = "批次重新命名" +"object.convertToFrame" = "轉換為框架" +"object.convertToGroup" = "轉換為群組" +"object.convertToSection" = "轉換為區段" +"object.flipH" = "水平翻轉" +"object.flipV" = "垂直翻轉" +"object.frameSelection" = "將選取範圍置入框架" +"object.group" = "將選取範圍組成群組" +"object.hideOthers" = "隱藏其他圖層" +"object.mask" = "用作遮罩" +"object.moveToPage" = "移至頁面" +"object.opacityKeys" = "以數字鍵設定不透明度" +"object.rasterize" = "點陣化選取範圍" +"object.releaseMask" = "釋放遮罩" +"object.removeFill" = "移除填色" +"object.removeFrame" = "移除框架" +"object.removeStroke" = "移除邊框" +"object.rename" = "重新命名" +"object.resizeToFit" = "調整大小以符合" +"object.rotate180" = "旋轉 180°" +"object.rotateLeft" = "向左旋轉 90°" +"object.rotateRight" = "向右旋轉 90°" +"object.setThumbnail" = "設為縮圖" +"object.swapFillStroke" = "交換填色與邊框" +"object.toggleLock" = "鎖定/解鎖選取範圍" +"object.toggleVisible" = "顯示/隱藏選取範圍" +"object.ungroup" = "解散選取範圍群組" +"object.wrapInSection" = "包裝至新區段" +"page.add" = "新增頁面" +"page.delete" = "刪除頁面" +"page.duplicate" = "建立副本頁面" +"page.list" = "列出頁面" +"page.move" = "移動頁面" +"page.rename" = "重新命名頁面" +"page.select" = "前往頁面" +"page.setBackground" = "頁面背景" +"paint.documentColors" = "文件顏色" +"plugin.close" = "關閉外掛面板" +"plugin.directory.install" = "從目錄安裝外掛" +"plugin.directory.list" = "瀏覽外掛目錄" +"plugin.directory.publish" = "將外掛發佈至目錄" +"plugin.directory.unpublish" = "從目錄取消發佈外掛" +"plugin.event" = "使用外掛面板控制項" +"plugin.import" = "從資訊清單匯入外掛…" +"plugin.list" = "列出外掛" +"plugin.remove" = "移除外掛" +"plugin.run" = "執行外掛" +"plugin.ui" = "外掛面板" +"plugins.manage" = "管理外掛…" +"plugins.runLast" = "執行上一個外掛" +"pref.autoSave" = "自動儲存檔案" +"pref.highlightHover" = "懸停時醒目標示圖層" +"pref.keepTool" = "使用後保持選取工具" +"pref.language" = "介面語言" +"pref.nudge" = "微調距離" +"pref.numberKeysOpacity" = "使用數字鍵設定不透明度" +"pref.publishServer" = "線上分享伺服器" +"pref.reduceMotion" = "減少動態效果" +"pref.renameDuplicates" = "重新命名重複的圖層" +"pref.scrollWheelZoom" = "滾輪縮放" +"pref.showDimensions" = "在物件上顯示尺寸" +"pref.startOnHome" = "從首頁畫面開始" +"pref.uiScale" = "介面大小" +"pref.uiScaleDown" = "較小介面" +"pref.uiScaleReset" = "預設介面大小" +"pref.uiScaleUp" = "較大介面" +"pref.userName" = "你的名字" +"proto.action.add" = "新增原型動作" +"proto.action.move" = "重新排序原型動作" +"proto.action.remove" = "移除原型動作" +"proto.action.set" = "編輯原型動作" +"proto.closePreview" = "關閉原型預覽" +"proto.conditional.addBlock" = "新增否則若 / 否則" +"proto.conditional.removeBlock" = "移除若 / 否則若 / 否則" +"proto.conditional.setCondition" = "編輯條件" +"proto.evaluate" = "評估原型運算式" +"proto.flow.add" = "新增流程起點" +"proto.flow.remove" = "移除流程" +"proto.flow.rename" = "重新命名流程" +"proto.interaction.add" = "新增互動" +"proto.interaction.clear" = "移除所有互動" +"proto.interaction.remove" = "移除互動" +"proto.interaction.set" = "編輯互動" +"proto.linkNotice" = "離開前往連結前的提示" +"proto.present" = "簡報" +"proto.presentOptions" = "簡報選項" +"proto.preview" = "開啟原型預覽" +"proto.settings" = "原型設定" +"section.toggleContents" = "隱藏/顯示區段內容" +"select.add" = "加入選取範圍" +"select.all" = "全選" +"select.area" = "選取區域" +"select.byName" = "依名稱選取圖層" +"select.children" = "選取子項目" +"select.inverse" = "反向選取" +"select.layersAt" = "指定位置的圖層" +"select.matching" = "選取相符的圖層" +"select.next" = "選取下一個同層項目" +"select.none" = "取消選取" +"select.parent" = "選取父層" +"select.previous" = "選取上一個同層項目" +"select.remove" = "從選取範圍移除" +"select.sameEffect" = "選取相同效果" +"select.sameFill" = "選取相同填色" +"select.sameFont" = "選取相同字型" +"select.sameInstance" = "選取相同實例" +"select.sameProperties" = "選取相同屬性" +"select.sameStroke" = "選取相同邊框" +"select.sameText" = "選取相同文字屬性" +"select.set" = "選取" +"selection.applyColorStyle" = "套用選取範圍顏色樣式" +"selection.bindColorVariable" = "套用選取範圍顏色變數" +"selection.colors" = "選取範圍顏色" +"selection.detachColor" = "分離選取範圍顏色" +"selection.selectColor" = "選取使用此顏色的圖層" +"selection.setPaint" = "變更選取範圍顏色" +"shape.arrow" = "箭頭" +"shape.ellipse" = "橢圓" +"shape.line" = "線條" +"shape.polygon" = "多邊形" +"shape.rectangle" = "矩形" +"shape.star" = "星形" +"shapeBuilder.merge" = "合併區域" +"shapeBuilder.mode" = "圖形建立器模式" +"shapeBuilder.regions" = "圖形建立器區域" +"shapeBuilder.remove" = "移除區域" +"stroke.add" = "新增邊框" +"stroke.brush" = "筆刷" +"stroke.copy" = "複製邊框" +"stroke.move" = "移動邊框" +"stroke.paste" = "貼上邊框" +"stroke.remove" = "移除邊框" +"stroke.set" = "設定邊框" +"stroke.widthProfile" = "寬度輪廓" +"style.apply" = "套用樣式" +"style.bind" = "在樣式中綁定變數" +"style.create" = "建立樣式" +"style.delete" = "刪除樣式" +"style.detach" = "分離樣式" +"style.duplicate" = "建立樣式副本" +"style.list" = "列出樣式" +"style.move" = "移動樣式" +"style.renameFolder" = "重新命名樣式資料夾" +"style.update" = "編輯樣式" +"template.list" = "列出範本" +"text.alignCenter" = "置中對齊" +"text.alignJustify" = "兩端對齊" +"text.alignLeft" = "靠左對齊" +"text.alignRight" = "靠右對齊" +"text.bold" = "粗體" +"text.bulletList" = "項目符號清單" +"text.case" = "字母大小寫" +"text.caseLower" = "小寫" +"text.caseOriginal" = "原始大小寫" +"text.caseSmallCaps" = "小型大寫" +"text.caseTitle" = "標題大小寫" +"text.caseUpper" = "大寫" +"text.create" = "文字" +"text.decoration" = "裝飾" +"text.edit.begin" = "編輯文字" +"text.edit.caret" = "設定游標位置" +"text.edit.end" = "停止編輯文字" +"text.edit.insert" = "輸入文字" +"text.edit.key" = "文字按鍵" +"text.edit.preedit" = "組合文字 (IME)" +"text.edit.selectLine" = "選取行" +"text.edit.selectWord" = "選取字詞" +"text.edit.selection" = "文字選取範圍" +"text.endPreview" = "結束文字預覽" +"text.fit" = "讓文字符合其方塊" +"text.fonts" = "列出字型" +"text.italic" = "斜體" +"text.lineHeightDown" = "減少行高" +"text.lineHeightUp" = "增加行高" +"text.link" = "建立連結" +"text.listIndent" = "增加清單縮排" +"text.listOutdent" = "減少清單縮排" +"text.listStyle" = "清單樣式" +"text.loadFont" = "載入字型檔案" +"text.maxLines" = "最大行數" +"text.missingFonts" = "缺少字型" +"text.numberList" = "編號清單" +"text.onPath" = "路徑上的文字" +"text.openLink" = "開啟連結" +"text.paragraphIndent" = "段落縮排" +"text.paragraphSpacing" = "段落間距" +"text.pathOptions" = "文字路徑選項" +"text.preview" = "預覽變更" +"text.refit" = "讓文字符合字型" +"text.removeFromPath" = "從路徑移除文字" +"text.rescanFonts" = "重新掃描已安裝字型" +"text.set" = "設定文字" +"text.setFeatures" = "OpenType 功能" +"text.setVariations" = "可變字型軸" +"text.sizeDown" = "縮小字型大小" +"text.sizeUp" = "放大字型大小" +"text.strikethrough" = "刪除線" +"text.style" = "設定文字範圍樣式" +"text.trackingDown" = "減少字元間距" +"text.trackingUp" = "增加字元間距" +"text.underline" = "底線" +"text.weightDown" = "減輕字重" +"text.weightUp" = "加重字重" +"text.wrapStyle" = "換行樣式" +"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" = "鉛筆" +"tool.pencilBrush" = "鉛筆筆刷" +"tool.place" = "使用工具置入" +"tool.polygon" = "多邊形" +"tool.rectangle" = "矩形" +"tool.scale" = "縮放" +"tool.scaleAnchor" = "縮放錨點" +"tool.section" = "區段" +"tool.set" = "選擇工具" +"tool.shape" = "圖形" +"tool.shapeBuilder" = "圖形建立器" +"tool.slice" = "切片" +"tool.stamp" = "印章" +"tool.star" = "星形" +"tool.sticky" = "便利貼" +"tool.text" = "文字" +"tool.textOnPath" = "路徑上的文字" +"transform.move" = "移動" +"transform.moveTo" = "移至" +"transform.nudge" = "微調" +"transform.nudgeSize" = "依微調距離調整大小" +"transform.resize" = "調整大小" +"transform.rotate" = "旋轉" +"transform.rotationOrigin" = "移動旋轉原點" +"transform.scale" = "縮放" +"transform.setRotationOrigin" = "設定旋轉原點" +"variable.addMode" = "新增模式" +"variable.bind" = "綁定變數" +"variable.create" = "建立變數" +"variable.createCollection" = "建立變數集合" +"variable.delete" = "刪除變數" +"variable.deleteCollection" = "刪除集合" +"variable.duplicate" = "建立變數副本" +"variable.export" = "匯出變數" +"variable.extendCollection" = "擴充集合" +"variable.import" = "匯入變數" +"variable.list" = "列出變數" +"variable.moveMode" = "移動模式" +"variable.removeMode" = "刪除模式" +"variable.renameCollection" = "重新命名集合" +"variable.renameMode" = "重新命名模式" +"variable.resetOverride" = "重設變數覆寫" +"variable.set" = "設定變數值" +"variable.setMode" = "設定變數模式" +"variable.update" = "編輯變數" +"vector.create" = "向量" +"vector.deleteHeal" = "刪除並修補選取範圍" +"vector.edit.addVertex" = "新增節點" +"vector.edit.begin" = "編輯向量" +"vector.edit.bend" = "彎曲線段" +"vector.edit.bounds" = "選取節點的邊界" +"vector.edit.connect" = "連接節點" +"vector.edit.cornerRadius" = "節點圓角半徑" +"vector.edit.cut" = "剪斷路徑" +"vector.edit.delete" = "刪除節點" +"vector.edit.end" = "完成編輯" +"vector.edit.erase" = "擦除" +"vector.edit.lasso" = "套索選取" +"vector.edit.mirroring" = "控點鏡射" +"vector.edit.moveHandles" = "移動控點" +"vector.edit.moveVertices" = "移動節點" +"vector.edit.nudge" = "微調節點" +"vector.edit.paint" = "油漆桶" +"vector.edit.pen" = "鋼筆節點" +"vector.edit.penEnd" = "結束鋼筆路徑" +"vector.edit.position" = "節點位置" +"vector.edit.resize" = "調整節點大小" +"vector.edit.rotate" = "旋轉節點" +"vector.edit.select" = "選取節點" +"vector.edit.selectAll" = "選取所有節點" +"vector.edit.setHandle" = "設定控點" +"vector.edit.smooth" = "平滑或銳利化節點" +"vector.edit.state" = "向量編輯狀態" +"vector.edit.strokeCap" = "節點邊框端點" +"vector.edit.strokeJoin" = "節點邊框接合" +"vector.edit.tool" = "向量編輯工具" +"vector.edit.transform" = "變形節點" +"vector.edit.widthPoint" = "寬度節點" +"vector.fillRule" = "設定填色規則" +"vector.flatten" = "扁平化" +"vector.join" = "聯集選取範圍" +"vector.offset" = "位移路徑" +"vector.outlineStroke" = "將邊框轉為外框" +"vector.reverse" = "反轉路徑方向" +"vector.reverseLoop" = "反轉迴圈" +"vector.simplify" = "簡化向量" +"vector.smoothJoin" = "平滑接合選取範圍" +"version.autosave" = "自動儲存版本" +"version.delete" = "刪除版本" +"version.duplicate" = "建立版本副本" +"version.exitPreview" = "返回目前版本" +"version.list" = "列出批次" +"version.preview" = "檢視版本" +"version.rename" = "重新命名版本" +"version.restore" = "還原版本" +"view.assets" = "資源面板" +"view.comments" = "留言" +"view.contextMenu" = "顯示內容選單" +"view.design" = "設計面板" +"view.designPrototype" = "在設計與原型之間切換" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "框架外框" +"view.fullscreen" = "全螢幕" +"view.get" = "取得檢視" +"view.layers" = "圖層面板" +"view.layoutGrids" = "版面格線" +"view.leftSidebar" = "顯示/隱藏左側邊欄" +"view.maskOutlines" = "遮罩外框" +"view.minimize" = "最小化介面" +"view.multiplayerCursors" = "多人游標" +"view.nextFrame" = "縮放至下一個框架" +"view.nextPage" = "下一個頁面" +"view.nextRegion" = "聚焦下一個區域" +"view.outlines" = "外框" +"view.outlinesHiddenLayers" = "在外框中包含隱藏圖層" +"view.outlinesObjectBounds" = "在外框中包含物件邊界" +"view.outlinesSelection" = "選取範圍外框" +"view.pan" = "平移檢視" +"view.panelWidths" = "重設面板寬度" +"view.pixelGrid" = "像素格線" +"view.pixelPreview" = "像素預覽" +"view.prevFrame" = "縮放至上一個框架" +"view.prevPage" = "上一個頁面" +"view.previousRegion" = "聚焦上一個區域" +"view.proofSrgb" = "sRGB 軟式打樣" +"view.prototype" = "原型面板" +"view.rulers" = "尺規" +"view.set" = "設定檢視" +"view.slices" = "切片" +"view.snapGeometry" = "貼齊幾何" +"view.snapObjects" = "貼齊物件" +"view.snapPixel" = "貼齊像素格線" +"view.themeDark" = "深色主題" +"view.themeLight" = "淺色主題" +"view.themeSystem" = "跟隨系統主題" +"view.toggleUi" = "顯示/隱藏介面" +"view.zoom100" = "縮放至 100%" +"view.zoom200" = "縮放至 200%" +"view.zoom50" = "縮放至 50%" +"view.zoomAt" = "縮放工具" +"view.zoomFit" = "縮放以符合" +"view.zoomIn" = "放大" +"view.zoomOut" = "縮小" +"view.zoomSelection" = "縮放至選取範圍" +"widget.click" = "點擊小工具" +"widget.hover" = "懸停小工具" +"widget.insert" = "插入小工具" +"widget.list" = "列出小工具" +"widget.menu" = "小工具選單" +"widget.setText" = "編輯小工具欄位" +"window.focus" = "將視窗移至最前" +"window.new" = "新視窗" + +[menu] +"bool.exclude" = "排除" +"bool.flatten" = "扁平化" +"bool.intersect" = "交集" +"bool.subtract" = "減去" +"bool.union" = "聯集" +"file.duplicate" = "建立副本" +"file.import" = "從檔案匯入…" +"file.rename" = "重新命名" +"libraries.open" = "開啟資源庫" +"select.sameEffect" = "效果" +"select.sameFill" = "填色" +"select.sameFont" = "字型" +"select.sameInstance" = "實例" +"select.sameProperties" = "屬性" +"select.sameStroke" = "邊框" +"select.sameText" = "文字屬性" +"text.alignCenter" = "文字置中對齊" +"text.alignJustify" = "文字兩端對齊" +"text.alignLeft" = "文字靠左對齊" +"text.alignRight" = "文字靠右對齊" +"view.assets" = "資源" +"view.design" = "設計" +"view.layers" = "圖層" +"view.outlines" = "顯示外框" +"view.outlinesHiddenLayers" = "包含隱藏圖層" +"view.outlinesObjectBounds" = "包含物件邊界" +"view.outlinesSelection" = "顯示選取範圍外框" +"view.prototype" = "原型" +"view.themeDark" = "深色" +"view.themeLight" = "淺色" +"view.themeSystem" = "跟隨系統" diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index fa326d389..18616671a 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -59,6 +59,21 @@ const CHINESE: &[&str] = &[ "WenQuanYi Zen Hei", "Droid Sans Fallback", ]; +/// Traditional Chinese (Taiwan) faces, an installed Noto Sans CJK TC first, then Windows, macOS +/// and Linux names. Nothing is bundled: the desktop finds one of these among the installed fonts. +const TRADITIONAL_CHINESE: &[&str] = &[ + "Noto Sans CJK TC", + "Microsoft JhengHei UI", + "Microsoft JhengHei", + "PingFang TC", + "Heiti TC", + "Noto Sans TC", + "Source Han Sans TC", + "LiHei Pro", + "Apple LiGothic", + "MingLiU", + "PMingLiU", +]; /// The CJK, Bengali and Thai scripts in `s`. pub fn scripts(s: &str) -> u8 { @@ -86,6 +101,16 @@ pub fn language(code: &str) -> u8 { } } +/// Whether `code` is Traditional Chinese: `zh-Hant`, or `zh` with a Taiwan, Hong Kong or Macau +/// region and no explicit `Hans` (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant-TW`). `zh-Hans-TW` is +/// Simplified. +pub fn traditional(code: &str) -> bool { + let tag = code.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let base = tag.split('-').next().unwrap_or(""); + let has = |subtags: &[&str]| tag.split('-').skip(1).any(|t| subtags.iter().any(|s| s.eq_ignore_ascii_case(t))); + base.eq_ignore_ascii_case("zh") && (has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"]))) +} + /// Whether the UI can draw language `code`: always on desktop (a system face is found when it's /// needed; tofu at worst), on the web only with built-in faces for its scripts (Georgian: craft-fonts' /// Noto Sans Georgian). The language picker and the system language skip a language it can't. @@ -107,19 +132,20 @@ fn shows(code: &str, web: bool) -> bool { return figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]).is_some(); } let need = language(code); - need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) + need == 0 || samples(need, 0, traditional(code)).iter().all(|(c, families)| figcraft_text::face_with_char(*c, families).is_some()) } /// A character standing for each script in `need`, with the families to look for it in, the UI /// language's script (`ui`, see [`language`]) first. Han reads as Chinese (a simplified character, -/// Chinese faces first) in a Chinese UI, and without kana in a UI that isn't Japanese or Korean; -/// else a Japanese or Korean face draws it. -fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> { +/// Simplified or Traditional Chinese faces first as the UI language says) in a Chinese UI, and +/// without kana in a UI that isn't Japanese or Korean; else a Japanese or Korean face draws it. +fn samples(need: u8, ui: u8, hant: bool) -> Vec<(char, Vec<&'static str>)> { let chinese = need & HAN != 0 && (ui == HAN || (ui == 0 && need & KANA == 0)); - let han: (char, Vec<&str>) = match (chinese, ui) { - (true, _) => ('们', [CHINESE, JAPANESE, KOREAN].concat()), - (false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()), - (false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()), + let han: (char, Vec<&str>) = match (chinese, hant, ui) { + (true, true, _) => ('們', [TRADITIONAL_CHINESE, JAPANESE, KOREAN].concat()), + (true, false, _) => ('们', [CHINESE, JAPANESE, KOREAN].concat()), + (false, _, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()), + (false, _, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()), }; let mut v = vec![ (KANA, ('あ', JAPANESE.to_vec())), @@ -147,7 +173,7 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> { /// tried are read from disk. pub fn faces(need: u8, lang: &str, have: &dyn Fn(char) -> bool) -> Vec> { let mut out: Vec> = Vec::new(); - for (c, families) in samples(need, language(lang)) { + for (c, families) in samples(need, language(lang), traditional(lang)) { if have(c) || out.iter().any(|f| f.has_char(c)) { continue; } @@ -177,6 +203,9 @@ mod tests { assert_eq!(language("ko"), HANGUL); assert_eq!(language("zh-Hant"), HAN); assert_eq!(language("zh-Hans"), HAN); + assert!(traditional("zh-Hant") && traditional("zh-TW") && traditional("zh-HK") && traditional("zh-MO")); + assert!(traditional("zh-Hant-HK") && traditional("zh_TW.UTF-8")); + assert!(!traditional("zh-Hans") && !traditional("zh-CN") && !traditional("zh-Hans-TW") && !traditional("zh") && !traditional("ja")); assert_eq!(language("bn"), BENGALI); assert_eq!(language("fr"), 0); assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id", "vi", "bs"].iter().all(|c| can_show(c))); @@ -202,8 +231,8 @@ mod tests { /// The UI language's script leads the fallback order; the others follow. #[test] fn the_ui_language_script_comes_first() { - let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::(); - let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied(); + let order = |need, ui| samples(need, ui, false).into_iter().map(|(c, _)| c).collect::(); + let first_face = |need, ui| samples(need, ui, false).first().and_then(|(_, f)| f.first()).copied(); assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한"); assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한"); assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한"); @@ -220,7 +249,15 @@ mod tests { // A Chinese UI draws Han in a Chinese face first, Japanese names in it too. assert_eq!(order(KANA | HAN | HANGUL, HAN), "们あ한"); assert_eq!(order(KANA | HAN, language("zh-Hans")), "们あ"); - assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN)[0].1[..4].contains(f))); + assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN, false)[0].1[..4].contains(f))); + // Traditional Chinese puts an installed Noto Sans CJK TC, then Taiwan's faces, first. + let order_hant = |need, ui| samples(need, ui, true).into_iter().map(|(c, _)| c).collect::(); + assert_eq!(order_hant(KANA | HAN, language("zh-Hant")), "們あ"); + assert_eq!(first_face(HAN, HAN), Some("Noto Sans CJK SC")); + let hant = samples(HAN, language("zh-Hant"), true); + assert_eq!(hant[0].0, '們'); + assert_eq!(hant[0].1[0], "Noto Sans CJK TC"); + assert!(["Microsoft JhengHei UI", "PingFang TC", "Noto Sans CJK TC"].iter().all(|f| hant[0].1.contains(f))); assert!(faces(0, "en", &|_| false).is_empty()); assert!(faces(KANA | HANGUL | HAN | BENGALI, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws"); } diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index ca1234cfe..a3f4f88e6 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -181,3 +181,35 @@ fn advanced_options_offer_the_bounding_box_for_text_layers() { assert!(shown.iter().any(|s| s == "Include bounding box"), "a rectangle that has it on shows it: {shown:?}"); assert!(u.app.session.doc().unwrap().node(r).unwrap().exports[0].include_bounding_box, "the setting is kept"); } + +/// The advanced options of a PNG, JPG or WebP setting list its color profile: same as the file +/// (naming the document's profile), sRGB or Display P3. SVG, PDF and XML settings list none. +#[test] +fn advanced_options_offer_a_color_profile_for_images() { + let mut s = Session::new(); + let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + let options = |u: &mut crate::layers_drag_tests::Ui| { + u.frame(vec![]); + egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0))); + u.frame(vec![]); + crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes) + }; + for f in ["png", "jpg", "webp"] { + u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f})); + let shown = options(&mut u); + for label in ["Color profile", "Same as file (sRGB)", "sRGB", "Display P3"] { + assert!(shown.iter().any(|s| s == label), "{f}: no {label:?} in {shown:?}"); + } + } + u.app.run("file.colorProfile", json!({"profile": "displayP3"})); + let shown = options(&mut u); + assert!(shown.iter().any(|s| s == "Same as file (Display P3)"), "{shown:?}"); + for f in ["svg", "pdf", "xml"] { + u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f})); + let shown = options(&mut u); + assert!(!shown.iter().any(|s| s == "Color profile" || s.starts_with("Same as file")), "{f}: {shown:?}"); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 380a78076..c1575e6bf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -90,15 +90,19 @@ const LATIN_AMERICA: &[&str] = /// The [`LANGS`] index for one tag: the exact tag ("pt-BR"), else its base language ("es" for /// "es_ES.UTF-8"), else a regional catalog of that language ("pt-BR" for "pt-PT"); Latin American -/// Spanish ("es-MX") reads es-419, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) none: it -/// has no catalog and Simplified isn't a stand-in. A language the UI can't draw (CJK on the web -/// without CJK faces built in) doesn't count. +/// Spanish ("es-MX") reads es-419. A Chinese tag reads Traditional (zh-Hant) for `Hant`, `TW`, `HK` +/// or `MO` and Simplified (zh-Hans) otherwise; an explicit `Hans` always reads Simplified. A +/// language the UI can't draw (CJK on the web without CJK faces built in) doesn't count. fn resolve_one(want: &str) -> Option { let tag = want.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-"); let base = tag.split('-').next().unwrap_or(""); let has = |subtags: &[&str]| tag.split('-').skip(1).any(|t| subtags.iter().any(|s| s.eq_ignore_ascii_case(t))); - if base.eq_ignore_ascii_case("zh") && (has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"]))) { - return None; + let zh = |code: &str| { + LANGS.iter().position(|l| l.code.eq_ignore_ascii_case(code)).filter(|i| LANGS.get(*i).is_some_and(|l| crate::cjk::can_show(l.code))) + }; + if base.eq_ignore_ascii_case("zh") { + let traditional = has(&["Hant"]) || (!has(&["Hans"]) && has(&["TW", "HK", "MO"])); + return zh(if traditional { "zh-Hant" } else { "zh-Hans" }); } let find = |t: &str| LANGS.iter().position(|l| l.code.eq_ignore_ascii_case(t)); let alias = (base.eq_ignore_ascii_case("es") && has(LATIN_AMERICA)).then(|| find("es-419")).flatten(); @@ -106,6 +110,7 @@ fn resolve_one(want: &str) -> Option { alias.or_else(|| find(&tag)).or_else(|| find(base)).or_else(regional).filter(|i| LANGS.get(*i).is_some_and(|l| crate::cjk::can_show(l.code))) } +/// Look `key` up in one of the sorted catalog tables. fn find(table: &'static [(&'static str, Text)], key: &str) -> Option<&'static Text> { table.binary_search_by(|(k, _)| (*k).cmp(key)).ok().and_then(|i| table.get(i)).map(|(_, t)| t) } @@ -194,7 +199,7 @@ fn plural(code: &str, n: usize) -> Plural { "bs" if n % 10 == 1 && n % 100 != 11 => Plural::One, "bs" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, "bs" => Plural::Other, - "ja" | "ko" | "th" | "zh-Hans" | "vi" => Plural::Other, + "ja" | "ko" | "th" | "zh-Hans" | "zh-Hant" | "vi" => Plural::Other, "fr" | "pt-BR" if n <= 1 => Plural::One, _ if n == 1 => Plural::One, _ => Plural::Other, @@ -250,6 +255,8 @@ pub fn menu_item<'a>(id: &str, english: &'a str) -> Cow<'a, str> { label(true, id, english) } +/// A command's or menu item's label: the catalog wording from `chain`, keeping a dialog's trailing +/// ellipsis when the English label has one. fn label<'a>(menu: bool, id: &str, english: &'a str) -> Cow<'a, str> { let text = chain(CURRENT.load(Ordering::Relaxed)).find_map(|l| menu.then(|| find(l.menu, id)).flatten().or_else(|| find(l.commands, id))); match text { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 489672f0f..00b36e37c 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -514,7 +514,7 @@ fn japanese_korean_chinese_and_vietnamese_have_no_plural_forms() { assert_eq!(code("system", Some("ja,en-US")), "ja"); assert_eq!(LANGS[resolve("vi", None)].name, "Tiếng Việt"); assert!(LANGS[resolve("vi", None)].draft); - for code in ["ja", "ko", "th", "zh-Hans", "vi"] { + for code in ["ja", "ko", "th", "zh-Hans", "zh-Hant", "vi"] { let lang = LANGS.iter().position(|l| l.code == code).unwrap(); assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}"); let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural(Forms { one: Some(_), .. }))).map(|(k, _)| k).collect(); @@ -607,17 +607,56 @@ fn the_built_in_georgian_face_draws_the_georgian_catalog() { } #[test] -fn chinese_reads_simplified_and_traditional_has_no_catalog() { +fn chinese_tags_pick_simplified_or_traditional() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; for tag in ["zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "zh"] { assert_eq!(code("system", Some(tag)), "zh-Hans", "{tag}"); } - // No Traditional Chinese catalog: English, never Simplified; else the next OS language. - for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8"] { - assert_eq!(code("system", Some(tag)), "en", "{tag}"); - assert_eq!(code(tag, None), "en", "{tag}"); + // Taiwan, Hong Kong, Macau and Hant read Traditional Chinese, never Simplified. + for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8", "zh-Hant-HK"] { + assert_eq!(code("system", Some(tag)), "zh-Hant", "{tag}"); + assert_eq!(code(tag, None), "zh-Hant", "{tag}"); } - assert_eq!(code("system", Some("zh-TW,ja-JP")), "ja"); + // An explicit Hans still reads Simplified, whatever the region. + assert_eq!(code("system", Some("zh-Hans-TW")), "zh-Hans"); + assert_eq!(code("system", Some("zh-TW,ja-JP")), "zh-Hant"); + assert_eq!(code("system", Some("ja-JP,zh-TW")), "ja"); +} + +#[test] +fn traditional_chinese_is_a_draft_in_the_taiwan_design_terms() { + let zh = LANGS.iter().position(|l| l.code == "zh-Hant").expect("locales/zh-Hant.toml is baked in"); + assert_eq!(LANGS[zh].name, "繁體中文(台灣)"); + assert!(LANGS[zh].draft, "a machine-drafted catalog stays a draft until a native speaker reviews it"); + // Spot checks for the Taiwan design terms the catalog is written in. + assert!(matches!(lookup(zh, "Canvas"), Some(Text::Plain("畫布")))); + assert!(matches!(lookup(zh, "Layers"), Some(Text::Plain("圖層")))); + assert!(matches!(lookup(zh, "Frame"), Some(Text::Plain("框架")))); + assert!(matches!(lookup(zh, "Component"), Some(Text::Plain("元件")))); + assert!(matches!(lookup(zh, "Constraints"), Some(Text::Plain("約束")))); + assert!(matches!(lookup(zh, "Fill"), Some(Text::Plain("填色")))); + assert!(matches!(lookup(zh, "Stroke"), Some(Text::Plain("邊框")))); + assert!(matches!(lookup(zh, "Export"), Some(Text::Plain("匯出")))); + assert!(matches!(lookup(zh, "Version"), Some(Text::Plain("版本")))); + assert!(matches!(lookup(zh, "Prototype"), Some(Text::Plain("原型")))); +} + +/// Changing the preference and reading it back keeps the Chinese tag; the tag still resolves to +/// the same catalog after the preferences are saved and loaded (a restart). +#[test] +fn traditional_chinese_switches_and_the_preference_persists() { + let mut s = figcraft_engine::Session::new(); + s.execute("pref.language", &serde_json::json!({"language": "zh-TW"})).unwrap(); + assert_eq!(s.prefs.language, "zh-TW"); + assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hant"); + let saved = serde_json::to_string(&s.prefs).unwrap(); + let loaded: figcraft_engine::Prefs = serde_json::from_str(&saved).unwrap(); + assert_eq!(loaded.language, "zh-TW"); + assert_eq!(LANGS[resolve(&loaded.language, None)].code, "zh-Hant"); + s.execute("pref.language", &serde_json::json!({"language": "zh-Hans"})).unwrap(); + assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hans"); + s.execute("pref.language", &serde_json::json!({"language": "zh-Hant"})).unwrap(); + assert_eq!(LANGS[resolve(&s.prefs.language, None)].code, "zh-Hant"); } #[test] diff --git a/crates/ui-egui/src/layers_wide_names_tests.rs b/crates/ui-egui/src/layers_wide_names_tests.rs new file mode 100644 index 000000000..eeb833e42 --- /dev/null +++ b/crates/ui-egui/src/layers_wide_names_tests.rs @@ -0,0 +1,161 @@ +//! Long and deeply nested names in the Layers panel (#1904), drawn headless: the list is as wide +//! as its widest name needs and scrolls sideways, so a name reads in full without widening the +//! sidebar, as the reference app's Layers panel does with its horizontal scrollbar; names that +//! fit leave the list as wide as the panel; a name past the width cap (#1650) still elides. + +use egui::{Event, MouseWheelUnit, Pos2, Shape, TouchPhase, Vec2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A rectangle named `name` on the page, or inside `parent`. +fn named(s: &mut Session, name: &str, parent: Option) -> NodeId { + let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "props": {"name": name}}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(s.execute("shape.rectangle", &p).unwrap()) +} + +/// The painted text starting with `prefix`: (whether it was elided, its position, its size). +fn painted(u: &mut Ui, prefix: &str) -> (bool, Pos2, Vec2) { + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + shapes + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.galley.text().starts_with(prefix) => Some((t.galley.elided, t.pos, t.galley.size())), + _ => None, + }) + .unwrap_or_else(|| panic!("no text starting {prefix:?}")) +} + +/// Turn the wheel sideways by `dx` points over `at`, and let the scroll settle. +fn wheel_sideways(u: &mut Ui, at: Pos2, dx: f32) { + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(-dx, 0.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(at), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } +} + +const LONG: &str = "Size=small, Type=primary, State=default, Icon=leading, Width=full"; + +#[test] +fn a_long_name_is_painted_in_full_and_the_list_scrolls_sideways() { + let mut s = Session::new(); + let long = named(&mut s, LONG, None); + named(&mut s, "Short", None); + let mut u = Ui::new(s); + let (elided, p, size) = painted(&mut u, "Size=small"); + assert!(!elided, "the whole name, not an elided one"); + // The panel is 240 pt wide: the name runs past it, into the space a sideways scroll shows. + assert!(p.x + size.x > 320.0, "the name runs past the panel: {} + {}", p.x, size.x); + let at = u.row_at(long); + wheel_sideways(&mut u, at, 120.0); + let (_, scrolled, _) = painted(&mut u, "Size=small"); + assert!(scrolled.x <= p.x - 100.0, "the list scrolled sideways: {} -> {}", p.x, scrolled.x); + // Short names move with it (one list), not on their own. + let (_, short, _) = painted(&mut u, "Short"); + assert!(short.x < 100.0, "the short name moved with the list: {}", short.x); +} + +#[test] +fn a_deeply_nested_name_gets_its_indent_too() { + let mut s = Session::new(); + // A name that fits at the top level, under five frames: the indent pushes it past the panel. + let mut parent = None; + for i in 0..5 { + let f = id(s + .execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": parent, "props": {"name": format!("Frame {i}")}})) + .unwrap()); + parent = Some(f); + } + let leaf = named(&mut s, "Component set original (não usar)", parent); + s.execute("select.set", &json!({"ids": [leaf]})).unwrap(); + let mut u = Ui::new(s); + let (elided, p, size) = painted(&mut u, "Component set original"); + assert!(!elided, "the nested name reads in full"); + assert!(p.x + size.x > 320.0, "it runs past the panel: {} + {}", p.x, size.x); +} + +#[test] +fn names_that_fit_leave_the_list_as_wide_as_the_panel() { + let mut s = Session::new(); + let short = named(&mut s, "Short", None); + let mut u = Ui::new(s); + let (elided, p, _) = painted(&mut u, "Short"); + assert!(!elided); + let at = u.row_at(short); + wheel_sideways(&mut u, at, 120.0); + let (_, after, _) = painted(&mut u, "Short"); + assert_eq!(after.x, p.x, "nothing to scroll to sideways"); +} + +#[test] +fn a_name_past_the_room_cap_still_elides() { + let mut s = Session::new(); + let name = "Layer ".repeat(500); + named(&mut s, &name, None); + let mut u = Ui::new(s); + let (elided, _, size) = painted(&mut u, "Layer Layer"); + assert!(elided, "a 3000-character name elides"); + assert!(size.x <= crate::layers::NAME_ROOM + 1.0, "at the cap, not thousands of points wide: {}", size.x); +} + +/// Review: the widest name isn't always the longest in bytes. Fifty rows of narrow letters outrank +/// one row of wide ones by the bound; it is laid out all the same, so it reads in full. +#[test] +fn a_wide_name_among_many_long_narrow_ones_is_still_measured() { + let mut s = Session::new(); + for _ in 0..50 { + named(&mut s, &"i".repeat(100), None); + } + named(&mut s, &"W".repeat(70), None); + let mut u = Ui::new(s); + let (elided, ..) = painted(&mut u, "WWWW"); + assert!(!elided, "the wide name reads in full"); +} + +/// Review: a name is laid out as it is painted, on one line, so line breaks in it don't make it +/// measure narrower than it paints. +#[test] +fn a_name_with_line_breaks_is_measured_as_painted() { + let mut s = Session::new(); + named(&mut s, "Size=small,\nType=primary,\nState=default,\nIcon=leading,\nWidth=full", None); + let mut u = Ui::new(s); + let (elided, ..) = painted(&mut u, "Size=small,"); + assert!(!elided, "the whole name on one line"); +} + +/// Review: a wide row runs on under the canvas; a dragged layer released there is no drop. +#[test] +fn a_drag_released_over_the_canvas_drops_nowhere() { + let mut s = Session::new(); + let wide = named(&mut s, &"Size=small, Type=primary, ".repeat(6), None); + let short = named(&mut s, "Short", None); + let page = s.page().unwrap(); + let order = s.doc().unwrap().node(page).unwrap().children.clone(); + let mut u = Ui::new(s); + let from = u.row_at(short); + let level = u.row_at(wide).y; + // Over the canvas, level with the wide row (which runs on under it). + let over_canvas = Pos2::new(800.0, level); + u.drag_to(from, over_canvas); + u.button(over_canvas, false); + for _ in 0..5 { + u.frame(vec![]); + } + assert_eq!(u.app.session.doc().unwrap().node(page).unwrap().children, order, "no reorder"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 27dd1b522..285d83be9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -146,6 +146,8 @@ mod layers_keys_tests; #[cfg(test)] mod layers_rename_tests; #[cfg(test)] +mod layers_wide_names_tests; +#[cfg(test)] mod library_beacon_tests; #[cfg(test)] mod live_chat_tests; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index bf076d487..9a1385d90 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -112,3 +112,61 @@ fn shut_pair_fields_edit_their_own_axis() { u.frame(vec![]); assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]); } + +/// A press and release at `p` with Cmd/Ctrl held (Alt is the field's own scrub modifier). +fn command_click(u: &mut Ui, p: Pos2) { + let m = Modifiers::COMMAND; + let press = |pressed: bool| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: m }; + u.frame_with(vec![Event::PointerMoved(p)], m); + u.frame_with(vec![press(true)], m); + u.frame_with(vec![press(false)], m); + u.frame(vec![]); +} + +/// A value typed +/// after a Cmd/Ctrl-click on any padding field goes to all four sides in one undo step; a plain +/// click keeps editing that side or axis alone. +#[test] +fn a_command_click_on_a_padding_field_edits_all_sides() { + let (mut u, f) = scene(); + u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}})); + assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]); + // Cmd/Ctrl-click and Tab away without typing: nothing is committed, to any side. + let before = u.app.session.undo_depth().0; + let v = named_field(&mut u, "Vertical padding"); + command_click(&mut u, v); + u.frame(vec![ + Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + u.frame(vec![]); + assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0], "an untouched field commits nothing"); + assert_eq!(u.app.session.undo_depth().0, before); + // The shut pair: Cmd/Ctrl-click Vertical padding and type 7: every side is 7. + let history = u.app.session.undo_depth().0; + let v = named_field(&mut u, "Vertical padding"); + command_click(&mut u, v); + u.frame(vec![Event::Text("7".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [7.0; 4]); + assert_eq!(u.app.session.undo_depth().0, history + 1, "one undo step"); + // A plain click afterwards edits its own axis again. + let v = named_field(&mut u, "Vertical padding"); + click(&mut u, v); + u.frame(vec![Event::Text("9".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [9.0, 7.0, 9.0, 7.0]); + // Individual padding open: Cmd/Ctrl-click the left field and type 3: every side is 3. + let t = named_button(&mut u, "Individual padding"); + click(&mut u, t); + let left = u.ctx.read_response(Id::unique(("padding", 3)).with("field")).expect("left field shows").rect.center(); + command_click(&mut u, left); + u.frame(vec![Event::Text("3".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [3.0; 4]); + u.app.run("edit.undo", json!({})); + assert_eq!(padding(&u, f), [9.0, 7.0, 9.0, 7.0], "undone as one step"); +} diff --git a/crates/ui-egui/src/panels/export_panel.rs b/crates/ui-egui/src/panels/export_panel.rs index 14b5a8904..3ae900956 100644 --- a/crates/ui-egui/src/panels/export_panel.rs +++ b/crates/ui-egui/src/panels/export_panel.rs @@ -7,7 +7,8 @@ use std::collections::BTreeSet; use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_doc::{Document, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat}; +use figcraft_doc::color::ColorProfile; +use figcraft_doc::{Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat}; use figcraft_engine::cmd::file::export_jobs; use serde_json::{Value, json}; @@ -69,7 +70,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary); } for (i, e) in list.iter().enumerate() { - setting_row(app, ui, &ids, i, e, text); + setting_row(app, ui, &ids, i, e, text, doc.color_profile); } let Some(first) = first else { return }; preview(app, ui, doc, first); @@ -84,8 +85,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId /// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix, /// format, advanced options and remove; a PDF's print settings under it. `text`: every layer in -/// `ids` is a text layer. -fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool) { +/// `ids` is a text layer; `file`: the document's color profile. +fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool, file: ColorProfile) { row(ui, 32.0, |ui| { // Tighter spacing so the advanced options and remove buttons fit beside the fields. ui.spacing_mut().item_spacing.x = 4.0; @@ -129,7 +130,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - if let Some(mut params) = advanced_options(ui, e, text) { + if let Some(mut params) = advanced_options(ui, e, text, file) { params["ids"] = json!(ids); params["index"] = json!(i); app.run("export.update", params); @@ -161,8 +162,9 @@ const VD_COMPAT: [(VdCompat, &str); 3] = /// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box /// for text layers (and for any setting that has it on, so it can be turned off); for SVG outline /// text, the id attribute and simplified strokes; for XML (Android Vector Drawable) the id attribute -/// (as `android:name`) and the compatibility mode. Returns the changed option as command params. -fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option { +/// (as `android:name`) and the compatibility mode; for PNG, JPG and WebP the color profile (`file` +/// is the document's). Returns the changed option as command params. +fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool, file: ColorProfile) -> Option { let mut out = None; let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| { if widgets::checkbox(ui, on, label) { @@ -189,9 +191,28 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option } } } + if e.format.is_raster() { + ui.add_space(4.0); + widgets::label(ui, tr("Color profile"), Tokens::get(ui.ctx()).text_secondary); + for p in ExportColorProfile::ALL { + let on = e.color_profile == p; + if widgets::radio(ui, on, &color_profile_label(p, file)) && !on { + out = Some(json!({"colorProfile": p.key()})); + } + } + } out } +/// How the advanced options name an export color profile: "Same as file (Display P3)" names the +/// profile of `file`, the document's. +fn color_profile_label(p: ExportColorProfile, file: ColorProfile) -> String { + match p { + ExportColorProfile::SameAsFile => trf("Same as file ({profile})", &[("profile", &file.label())]), + p => p.resolve(file).label().to_string(), + } +} + /// A PDF setting's print settings: a summary row that opens them. fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 9db48dd49..4901851dd 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -492,6 +492,8 @@ struct RowCache { expanded: u64, filter: String, rows: Arc>, + /// The width the widest row needs to show its whole name ([`widest_row`]). + width: f32, } fn hash_expanded(expanded: &BTreeSet) -> u64 { @@ -504,15 +506,71 @@ fn hash_expanded(expanded: &BTreeSet) -> u64 { /// The rows to list: the expanded tree, or the layers matching `filter` (lowercase). pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 }; + let id = Id::unique("layer_rows"); + let fresh = ui + .data(|d| d.get_temp::(id)) + .filter(|c| c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter); + if let Some(c) = fresh { + return c.rows; + } + let rows = Arc::new(listed_rows(doc, page, expanded, filter)); + // Outside the context's data lock: laying out the names takes the context too. + let width = widest_row(ui, doc, page, &rows); ui.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::unique("layer_rows")); - let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; - if !fresh { - let rows = listed_rows(doc, page, expanded, filter); - *c = RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: Arc::new(rows) }; + d.insert_temp(id, RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: rows.clone(), width }) + }); + rows +} + +/// The width the rows [`rows_for`] last listed need to show their names in full, from the +/// panel's left edge (0 before any were listed). +pub(crate) fn list_width(ui: &Ui) -> f32 { + ui.data(|d| d.get_temp::(Id::unique("layer_rows"))).map_or(0.0, |c| c.width) +} + +/// A name's room on a row stops growing here: a name of thousands of characters (#1650) elides +/// past it instead of making the list that wide. +pub(crate) const NAME_ROOM: f32 = 1200.0; + +/// A row's name: the layer's, or its kind's label when it has none. +fn row_name(n: &figcraft_doc::Node) -> &str { + if n.name.is_empty() { tr(n.kind.label()) } else { &n.name } +} + +/// The font a row's name is drawn in: top-level frames and sections stand out. +fn name_font(doc: &Document, page: NodeId, n: &figcraft_doc::Node) -> egui::FontId { + let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); + if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) } +} + +/// The width the widest of `rows` needs to show its whole name, from the panel's left edge: the +/// row's margins, its indent, the disclosure triangle and icon, the name in its font (up to +/// [`NAME_ROOM`]) and the eye and padlock's room. The list is made this wide, so it scrolls +/// sideways when a name doesn't fit the panel (#1904). A name's bytes at the widest glyph bound +/// its width, and the rows are laid out in descending order of that bound until the bound falls +/// under the widest found, so no name that could be the widest is skipped: a list with one long +/// name lays out a handful, a list of names all alike lays them all out, through egui's galley +/// cache. A name is laid out as it is painted: on one line, cut at [`NAME_ROOM`]. +fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 { + // What sits left of the name (the row's margin, the indent's start, the triangle and icon) + // and right of it (the eye and padlock, the margin): see the row loop in `layers`. + const AROUND: f32 = 8.0 + 4.0 + 36.0 + 56.0 + 8.0; + // No glyph of the 11 pt fonts is wider than this: a name's byte count bounds its width. + const GLYPH: f32 = 12.0; + let indent = |r: &Row| r.depth as f32 * 16.0; + let mut bounds: Vec<(f32, usize)> = + rows.iter().enumerate().filter_map(|(i, r)| doc.node(r.id).map(|n| (indent(r) + row_name(n).len() as f32 * GLYPH, i))).collect(); + bounds.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut widest = 0.0f32; + for (bound, i) in bounds { + if bound <= widest { + break; } - c.rows.clone() - }) + let Some((r, n)) = rows.get(i).and_then(|r| doc.node(r.id).map(|n| (r, n))) else { continue }; + let w = crate::bidi::name_galley(ui.painter(), row_name(n), name_font(doc, page, n), Color32::WHITE, NAME_ROOM).size().x; + widest = widest.max(indent(r) + w); + } + widest + AROUND } /// The rows to list, uncached: the expanded tree, or the layers matching `filter` (lowercase). @@ -1030,6 +1088,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let reveal = reveal_selection(app, &doc, page, &sel); let filter = app.ui.layer_filter.trim().to_lowercase(); let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter); + // The rows are as wide as the panel, or as the widest name needs: the list then scrolls + // sideways too, so long and deeply nested names read in full without widening the sidebar, + // as the reference app's Layers panel does with its horizontal scrollbar (#1904). + let row_w = w.max(list_width(ui)); let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None }; // A rename in progress ends when its layer isn't listed any more; Tab / Shift+Tab commit it and // go on to the next / previous row. @@ -1071,13 +1133,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // The drag is over a row that takes no drop: the not-allowed cursor shows. let mut refused = false; // Only the rows in view are laid out and drawn; the rest is empty scroll space. - egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + egui::ScrollArea::both().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let pitch = ROW + ui.spacing().item_spacing.y; let top = ui.max_rect().top(); + let left = ui.max_rect().left(); let total = rows.len() as f32 * pitch; + let row_w = row_w.max(viewport.width()); ui.set_height(total.max(viewport.height())); + ui.set_width(row_w); if let Some(i) = scroll_to { - ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top + i as f32 * pitch), vec2(w, ROW)), None); + // The row at the width in view: revealing a layer scrolls up or down, not sideways. + ui.scroll_to_rect(Rect::from_min_size(pos2(left + viewport.min.x, top + i as f32 * pitch), vec2(viewport.width(), ROW)), None); } if rows.is_empty() { ui.add_space(8.0); @@ -1095,14 +1161,14 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let selected_ids: BTreeSet<_> = sel.iter().copied().collect(); let roots_start = first.saturating_sub(1); let roots: Vec<_> = rows.iter().take(last.saturating_add(1)).skip(roots_start).map(|r| selection_root(&doc, &selected_ids, r.id)).collect(); - let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch)); + let band = Rect::from_x_y_ranges(left..=(left + row_w), (top + first as f32 * pitch)..=(top + last as f32 * pitch)); let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band)); // Stable widget ids for a row wherever the list is scrolled. band_ui.skip_ahead_auto_ids(first); let ui = &mut band_ui; for (i, row) in rows.iter().enumerate().take(last).skip(first) { let Some(n) = doc.node(row.id) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); + let (r, resp) = ui.allocate_exact_size(vec2(row_w, ROW), Sense::click_and_drag()); let selected = selected_ids.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -1158,12 +1224,11 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } else { let col = row_color(is_comp, shown, t.text, t.text_tertiary); - let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); - let font = - if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) }; - let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() }; - // Room left of the lock and eye buttons. - let max_w = (rr.max.x - 56.0 - name_x).max(20.0); + let font = name_font(&doc, page, n); + let name = row_name(n).to_string(); + // Room left of the lock and eye buttons (the row is wide enough for the whole + // name, up to `NAME_ROOM`: a row widened by a deeper name gives no more). + let max_w = (rr.max.x - 56.0 - name_x).clamp(20.0, NAME_ROOM); crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); } let (v, l) = eye_lock(ui, &t, [r, rr], row, n, over && !dev, dev, &mut sweep); @@ -1187,7 +1252,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } - let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); + // In the row and in the list's view: a wide row runs on under the canvas (#1904), and a + // drag released there lands nowhere. + let clip = ui.clip_rect(); + let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p) && clip.contains(p)); // What is inside the dragged layers, and inside an instance, takes no drop. refused |= over && (doc.ancestors(row.id).iter().any(|a| moving.contains(a)) @@ -1196,6 +1264,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { && !blocked(row.id) && let Some(p) = ui.input(|i| i.pointer.hover_pos()) && r.contains(p) + && clip.contains(p) { let collapsed = row.has_kids && !app.ui.expanded.contains(&row.id); let (zone, open, released) = drop_zone(ui, p, [r, rr], x0, row.id, collapsed, n); @@ -1211,7 +1280,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if let Some((i, id, depth)) = rename_row.filter(|(i, ..)| !(first..last).contains(i)) && let Some(n) = doc.node(id) { - let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(w, ROW)); + let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(row_w, ROW)); if let Some(new) = rename_field(app, ui, id, depth, &n.name, r) { rename_done = Some((id, new)); } diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index 4762bdd08..2629e97f4 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -16,7 +16,7 @@ use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::right::{block, common, menu_item, menu_popup, row}; use crate::theme::{ACCENT, Tokens, regular}; -use crate::widgets::{self, FIELD_H, Lead}; +use crate::widgets::{self, FIELD_H, FieldEdit, Lead}; /// Most tracks listed in the panel (the rest are still laid out; edit them through commands). const MAX_TRACK_ROWS: usize = 24; @@ -416,6 +416,15 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } +/// Commit a padding field's edit: a value typed after a Cmd/Ctrl-click goes to all four sides +/// (Guide to auto layout); otherwise to the field's own side or axis. +fn padding_field(app: &mut FigApp, ui: &Ui, ids: &[NodeId], key: &str, id: Id, e: Option) { + match e { + Some(FieldEdit::Set(v)) if widgets::edit_began_with_command(ui, id) => app.set_props(ids, json!({"padding": v.max(0.0)})), + e => app.field(ids, key, e, |v| json!(v.max(0.0))), + } +} + /// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field /// per side (like Individual corners). fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { @@ -439,9 +448,9 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { for (r, pair) in sides.chunks(2).enumerate() { row(ui, 32.0, |ui| { for (key, name, icon, v, k) in pair { - let e = - crate::variable_ui::number_field(app, ui, ids, key, Id::unique(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); - app.field(ids, key, e, |v| json!(v.max(0.0))); + let id = Id::unique(("padding", *k)); + let e = crate::variable_ui::number_field(app, ui, ids, key, id, Lead::Icon(*icon, name), Some(*v), "", col); + padding_field(app, ui, ids, key, id, e); } if r == 0 { toggle(ui); @@ -461,7 +470,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { "", col, ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + padding_field(app, ui, ids, "paddingHorizontal", Id::unique("al_padh"), e); let e = crate::variable_ui::number_field( app, ui, @@ -473,7 +482,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { "", col, ); - app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + padding_field(app, ui, ids, "paddingVertical", Id::unique("al_padv"), e); toggle(ui); }); } diff --git a/crates/ui-egui/src/ui_params_tests.rs b/crates/ui-egui/src/ui_params_tests.rs index fa6464041..dd648b64a 100644 --- a/crates/ui-egui/src/ui_params_tests.rs +++ b/crates/ui-egui/src/ui_params_tests.rs @@ -1,7 +1,7 @@ //! Params the panels build at run time name only what their commands take: a name a command //! doesn't list is refused (#1413), which would break the control that sent it. -use figcraft_doc::{ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat}; +use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat}; use figcraft_engine::Session; use serde_json::{Value, json}; @@ -30,6 +30,7 @@ fn export_settings_with_every_option_are_taken() { svg_id_attribute: true, svg_simplify_stroke: false, vd_compat: VdCompat::Exact, + color_profile: ExportColorProfile::DisplayP3, quality: 70, ..ExportSetting::default() }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2e4bacdf1..3353afe3f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -186,6 +186,12 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: number_field_mixed(ui, id, lead, value, suffix, width, step)?.settled() } +/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited +/// that way sets every side (Guide to auto layout, Keyboard shortcut guide). +pub fn edit_began_with_command(ui: &Ui, id: Id) -> bool { + ui.data(|d| d.get_temp::(id.with("cmdstart"))).unwrap_or(false) +} + /// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative /// edit comes back as one for each layer ([`Edit::Each`]). pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { @@ -361,14 +367,28 @@ fn number_field_impl( // The text the field would commit to no effect: what it showed when the edit began, or the // value the arrows last stepped it to. let kept_id = id.with("kept"); + // Whether Cmd/Ctrl was held while the button was down on the field: a click's modifier is + // read on its press, so letting go of the key before the release still counts. + let cmd_press_id = id.with("cmdpress"); + let cmd_held_before = ui.data(|d| d.get_temp::(cmd_press_id)).unwrap_or(false); + let (cmd_pressed, any_down) = ui.input(|i| (field_resp.is_pointer_button_down_on() && i.modifiers.command, i.pointer.any_down())); + ui.data_mut(|d| d.insert_temp(cmd_press_id, cmd_pressed || (cmd_held_before && any_down))); if started { editing = Some(if typed_now { String::new() } else { value.map(fmt_num).unwrap_or_default() }); + // Whether Cmd/Ctrl was held on the click that began the edit: a padding field then sets + // every side ([`edit_began_with_command`]). The flag lives as long as the edit. + let with_command = !typed_now && (cmd_pressed || cmd_held_before || ui.input(|i| i.modifiers.command)); ui.data_mut(|d| { d.insert_temp(editing_id, editing.clone().unwrap_or_default()); d.insert_temp(kept_id, value.map(fmt_num).unwrap_or_default()); d.insert_temp(id.with("selectall"), !typed_now); + d.insert_temp(id.with("cmdstart"), with_command); + d.remove::(id.with("edited")); }); ui.memory_mut(|m| m.request_focus(id.with("te"))); + } else if editing.is_none() { + // No edit under way: an arrow step on the focused field is its own side's. + ui.data_mut(|d| d.remove::(id.with("cmdstart"))); } match editing { Some(mut text) => { @@ -382,6 +402,11 @@ fn number_field_impl( // top would move its baseline below the centered value painted while idle. let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect)); let r = child.add(te); + if r.changed() { + // Typing (the same value again included) is what lets a Cmd/Ctrl-begun edit commit + // to every side; a field left as it was commits nothing. + ui.data_mut(|d| d.insert_temp(id.with("edited"), true)); + } a11y::own_focus(&r); keep_focus(&r, started); crate::shortcuts::release_field_for_save(&r); @@ -408,6 +433,7 @@ fn number_field_impl( ui.data_mut(|d| { d.insert_temp(kept_id, text.clone()); d.insert_temp(id.with("selectall"), true); + d.insert_temp(id.with("edited"), true); }); out = Some(set(nv)); } @@ -423,7 +449,11 @@ fn number_field_impl( // Enter, Tab or a click elsewhere commits what was typed (a field left as it was // commits nothing: no undo step, and a rounded display isn't written back). let kept: String = ui.data(|d| d.get_temp(kept_id)).unwrap_or_default(); - if text != kept + // An edit begun with Cmd/Ctrl commits the value even retyped as it was: the other + // sides are to take it ([`edit_began_with_command`]). + let for_all = ui.data(|d| d.get_temp::(id.with("cmdstart"))).unwrap_or(false); + let edited = ui.data(|d| d.get_temp::(id.with("edited"))).unwrap_or(false); + if (text != kept || (for_all && edited)) && let Some(e) = typed(&text) { out = Some(e); diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index c4211c80d..f60cd4350 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -80,7 +80,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver |---|---|---| | Horizontal, vertical, wrap (both directions) | done | `crates/layout` | | Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps, cell alignment | done | `fr` like CSS `fr` | -| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | | +| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | a Cmd/Ctrl-click on a padding field (the pair or a side) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide; `padding_individual_tests`) | | Text baseline alignment | done | | | Hug / Fill / Fixed sizing with coherent rules (Fill turns a hugging parent Fixed) | done | `al_nesting_tests.rs`, `al_behaviour_tests.rs` | | Min/max width and height, redistribution | done | hugging text wraps at its max | @@ -102,6 +102,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| | 2026-11-05 | patch | M2, #1823: Suggest auto layout is no longer Add auto layout under another name: `layout.suggest` splits a frame's layers into rows (Vertical) or else columns (Horizontal) by the gaps between their boxes, wraps each band of several layers in a new frame without fill or clipping that hugs it, and repeats inside it (16 levels, 1,000 layers per frame at most). The frame keeps its size; gap and padding come from the bands, so layers keep their boxes where the gaps are even and the bands start at the same offset; instances and layers inside them are refused; uneven gaps (smallest used), bands that start at different offsets across the flow (lined up at the smallest) and layers that overlap along both axes come back as `warnings`. A band frame hugs only when its own split succeeds: a band whose layers overlap along both axes gets no auto layout and stays Fixed, so it cannot reflow those children. `direction` sets the top level only; one undo step; `layout.add` is unchanged. Tests in `layout_suggest_tests.rs` | +| 2026-11-02 | patch | M2: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. The modifier was ignored before. Test: `padding_individual_tests` | | 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. | | 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` | | 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. | diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 5fdb9d90d..41c73190d 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -546,10 +546,13 @@ resuming with its offline edits, and leaving the session going back to snapshot - Disconnected: while it reconnects, and after it can't go on, the tab shows a red mark and a strip across the top of the canvas says so, with **Retry now** (`collab.reconnect`). The tab can still be edited. A session you joined that ended stays on its tab with **Try again**, **Save a copy…** - and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab as someone new; if the - tab changed since it lost the server, what changed opens beside it as an unsaved - "offline copy" and the tab shows the server's version. A session that gets out of step does the - same by itself. A reconnect resumes as the same + and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab, as the same client when + the server still knows it, and what changed since it lost the server goes on top as one undoable + edit, through the three-way merge of `crates/collab/src/merge.rs`. The same thing changed both + ways keeps the other person's, and layers a new client can't make aren't sent; in those cases + your whole version also opens beside it as an unsaved "offline copy", so nothing is lost. A + session that gets out of step, or a server that no longer knows you, does the same by itself. A + reconnect resumes as the same client: the guest's connection keeps the session id, client id and last sequence number from the messages it read and sends them in its next `Hello` (see Offline and reconnect). - The browser build joins and publishes to a server (see *Built (the browser client)*); hosting diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 719874327..65545447d 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (; a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (M9: a joined live session reconnects until you leave and says when it is disconnected, and joining it again merges what you did while disconnected; ; a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave, and rejoining with offline edits merged), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 | @@ -26,6 +26,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only the host ending the session and refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss | +| 2026-11-04 | minor | M9: joining a session again merges what you did while disconnected as one undoable edit, through the three-way merge branches use, as the same client when the server still knows it. What conflicts keeps the other person's, and your whole version opens beside it as an unsaved copy. A server that no longer knows you merges the same way instead of discarding unconfirmed work | | 2026-11-06 | patch | M9, #2026: one conflicting auto layout edit added a conflict for every size and place it moved (a padding changed on both sides of a file with one instance gave 10, with 15 instances 138), and a `layout` conflict took all auto layout fields together, so a gap changed only on the branch was lost if main won the padding. `merge::plan` now leaves out values both sides work out for themselves: a hugging frame's or a filling child's size, text sizing to its characters, an auto layout child's place (when both sides turn it the same way) and a value a layer in an instance takes from its main; merging keeps the file merged into's own value and normalising lays it out from the merged inputs. `layout` and `childLayout` merge field by field: a field changed on one side merges, each field changed both ways is its own conflict (`/layout.padding`, shown as auto layout padding with both sides' values). Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`; the merge fuzz test leaves worked-out values out of what each side must show | | 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. | | 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only the host ending the session and refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss, and what changed offline opens beside it when it joins again | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6defaa1a9..bce90b008 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-11-04 · **Change:** minor (a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.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)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.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)) · **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 @@ -55,9 +55,11 @@ export limit, not a native document nesting limit. |---|---|---|---|---| | PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — | | JPG | yes | yes | white where transparent; quality 1 to 100 (`quality` on `file.export`, export settings and MCP `export`; default 90; the Export panel has no control for it yet); the file's colour profile embedded as ICC (APP2), sRGB too | — | +| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels; a color profile per setting (Same as file, the default, sRGB or Display P3: `colorProfile`), the pixels converted and the file tagged (`sRGB` chunk, or ICC and `cICP`) | — | +| JPG | yes | yes | white where transparent; fixed quality (90); a color profile per setting as for PNG (sRGB untagged, Display P3 with its ICC profile) | — | | SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | -| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 | -| WebP | — | yes (ours) | | — | +| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal; RGB is always sRGB (no color profile setting and no output intent) | CMYK output intent 2–4 | +| WebP | — | yes (ours) | a color profile per setting as for JPG | — | | Android Vector Drawable (XML) | — | yes (ours) | an `.xml` drawable resource (`file.export {format: "xml"}`, export settings, File ▸ Export…, the CLI and MCP), pretty-printed, sized in dp with the viewport in layer units, for AndroidX (VectorDrawableCompat: gradients and even-odd need API 24 without it). Shapes, booleans and text as path data with transforms baked in; solid fills and linear, radial and sweep gradients (`aapt:attr`, exact for any handles: radial and sweep in a `` that maps the unit circle); centred strokes as stroke attributes where Android draws them the same (uniform scale, one cap, no dashes or variable width), other strokes as filled outlines; clipping frames and masks as `` with ``; `android:name` from layer names with the id attribute option. What the format can't hold goes by the compatibility mode (`vdCompat`): best effort (default) applies layer opacity to each shape, rewrites even-odd fills and clip outlines as non-zero, draws diamond gradients as radial and alpha masks as outline clips, each with a warning; exact leaves out what it can't draw exactly, with a warning; ignore writes what maps without warnings: layer opacity over overlapping shapes is dropped (drawn opaque), even-odd fills keep `fillType="evenOdd"`, even-odd clip outlines clip as non-zero, diamond gradients are left out and alpha masks clip to their outline. Effects, image and pattern fills and blend modes are always left out (warned except in ignore); an export that draws nothing writes one empty path (Android refuses a drawable without a path), with a warning. Checked by parsing the XML, against FigCraft's renderer (gradients pixel by pixel), with `aapt2 compile` and `link`, and by rendering samples (linear, skewed radial and sweep gradients, a sweep with mirrored handles, even-odd booleans, centred and inside strokes, rotated text, a clipping frame, an alpha mask, a translucent group, each compatibility mode) with Android 13's (API 33) VectorDrawable on an emulator, next to FigCraft's PNG export: they match, including where the sweep starts and which way it turns, except what the compatibility mode and its warnings say (best effort: overlapping shapes in a translucent group look denser; exact and ignore leave out or draw opaque what they list). Not checked on API 21–23 (VectorDrawableCompat) | — | | Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — | | Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React, Vue, Leptos, ArkUI and Flutter | see [dev-mode-parity](dev-mode-parity.md) | — | @@ -191,6 +193,7 @@ source paint coordinates. | 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). | | 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. | | 2026-11-01 | patch | `.fig` and `.jam` import: a connector without a saved snapshot (elbowed and curved ones are routed here) took no stroke, so it was invisible (#1807); its line's paint, weight, cap and dashes now come from the file's generated line record, as a saved snapshot's do, and the connector's own values, when it has any, win. Test: `fig::connector_label_tests::a_routed_connector_takes_its_line_style_from_the_generated_record`. Numbers unchanged | +| 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG and WebP stay untagged. A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index bc67a2b96..f42b97e4e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -212,12 +212,17 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe overlay, the strings added since each catalog was drafted (1–5% of each), the Arabic draft's native review and mirrored rows inside the panels (the window mirrors since 2026-10-31), Hindi and Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the craft-fonts CJK build. + Korean, Simplified Chinese, Traditional Chinese, Indonesian and Czech drafts and the Latin + American Spanish overlay, the strings added since each catalog was drafted (1–5% of each), the + Arabic draft's native review and panel mirroring, Hindi and Vietnamese, localized engine + messages and number formatting; CJK UI text on the web without the craft-fonts CJK build. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in - `crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31 - menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, + `crates/ui-egui/locales/` (measured 2026-11-03: 1,975 UI keys plus 641 command labels and 31 + menu wordings, 2,647 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete, Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese, - Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they - were drafted show English), with `es-419.toml` a Latin American overlay listing only what + Japanese, Korean, Simplified Chinese and Traditional Chinese machine drafts cover 95–100% + (the Traditional Chinese draft covers all 2,647 entries; strings added since the other drafts + were written show English), with `es-419.toml` a Latin American overlay listing only what differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals) covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)). @@ -588,6 +593,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. | | 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. | | 2026-10-30 | minor | Gap 7: text reads only the fallback fonts it draws with: a face per fallback family and style, a file's bytes shared between families, the faces worked out once per style (first text laid out: 41 font files and 265 MiB read down to 3 files and 20 MiB on a Linux machine with Noto Sans CJK). Numbers unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9f095a882..c3490a195 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -4,7 +4,7 @@ FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian -Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted, +Portuguese, Japanese, Korean, Simplified Chinese, Traditional Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted, waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key languages, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -32,15 +32,15 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: English, Bosanski, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, - 한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文), applied at once without a + 한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文, 繁體中文(台灣)), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to es-419; regional ru, tr, cs and id tags (ru-RU, tr-TR, cs-CZ, id-ID) to their catalogs; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, - while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the - next OS language, never Simplified) or the browser's language on the web. + while zh-Hant, zh-TW, zh-HK and zh-MO read zh-Hant; an explicit Hans such as zh-Hans-TW still + reads Simplified) or the browser's language on the web. `pref.language {language}` sets it from the CLI, MCP and the control channel. - **Regional overlays:** a regional catalog whose language has a catalog lists only what differs (`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the @@ -87,10 +87,15 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v Linux and BSD: Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's BIZ UDPGothic first), the UI language's script first; Han without kana outside a Japanese or Korean UI reads as Chinese, and a Chinese UI puts its Chinese face (Microsoft YaHei, PingFang SC, Noto Sans - CJK SC) ahead of the built-in Japanese one, so Han takes Simplified Chinese shapes; changing the + CJK SC) ahead of the built-in Japanese one, so Han takes Simplified Chinese shapes, while a + Traditional Chinese UI puts an installed Noto Sans CJK TC first, then Microsoft JhengHei, PingFang + TC and Heiti TC, so Han takes Traditional shapes; changing the UI language lays the faces out again. Only that face's file is read and egui shares its bytes, so English users load nothing (startup and memory measured unchanged); no CJK font installed means tofu, never a crash. The web build has no system fonts: built with craft-fonts and `FIGCRAFT_EMBED_CJK=1` it + embeds Noto Sans CJK SC (+16 MB) and offers Japanese, Korean and Simplified Chinese; without a + Noto Sans CJK TC face built in it leaves Traditional Chinese out too; otherwise the language picker and + System leave them out. No font file is in this repo. embeds Noto Sans CJK SC (+16 MB) and offers Japanese, Korean and Simplified Chinese; otherwise the language picker and System leave them out. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,Thai,Deva` (one tag per script wanted, as the craft-fonts manifest lists them) embeds those scripts' faces as well, so an Arabic or Persian @@ -102,7 +107,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| | English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| Traditional Chinese (Taiwan) | zh-Hant | 2,647 / 2,647 (100%: 1,975 UI strings, 641 command labels, 31 menu wordings; draft, counted 2026-11-03) | yes | CJK text and IME in documents; UI text in an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC or Heiti TC (web: only with a built-in TC face); zh-TW, zh-HK, zh-MO and zh-Hant read it, an explicit Hans stays Simplified | no | draft | 1–2 (after native review) | | Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web: only a build with `FIGCRAFT_EMBED_SCRIPTS=Arab` embeds Noto Sans Arabic (the font budget leaves it out otherwise, #1902); documents draw in it and the picker offers the Arabic UI then; panels do not mirror | no | draft | review + panel mirroring (not re-estimated) | @@ -136,13 +142,14 @@ mirrors since 2026-10-31). Total: **29–51 h** (kept on every string adds). Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then -Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see English). +and variants has one, plus Simplified Chinese, Traditional Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then +Hindi and Arabic. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source and marked as a draft awaiting native review. `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it; an explicit Hans still reads Simplified Chinese. The desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled. No plural forms. Localization grade and estimates unchanged | | 2026-11-03 | patch | M10, #1902: the web build drew Persian and Arabic text as missing-glyph boxes. The browser has no system fonts, and `crates/text/build.rs` keeps the web catalog to Inter, Noto Sans Georgian, the wordmark and (with `FIGCRAFT_EMBED_CJK`) Noto Sans CJK, so craft-fonts' Noto Sans Arabic never reached it. `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,…` (script tags as the manifest's scripts column lists them) now embeds those scripts' faces in a web build; the default build and its size are unchanged, and with `Arab` embedded the picker offers the Arabic UI as well (`cjk::can_show`, as on the desktop). Noto Sans Hebrew, Devanagari and Georgian join Arabic and Thai among the document fallbacks, so an embedded face draws its script in an Inter document. Tests: `web_embeds_other_scripts_faces_only_when_asked` in the build script, `every_embeddable_script_face_is_a_document_fallback`. | | 2026-11-05 | patch | #2014: the panels drew a String variable's Amharic value as boxes: the UI took a system face for Hebrew and Arabic only. `bidi::script_fallbacks` now samples one letter of each of 16 scripts (Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as well as Hebrew and Arabic; Bengali and Thai keep `crate::cjk`'s faces) against the system's families for each (Windows, macOS, the common Linux fonts), the first installed one that draws the letter taken once. Desktop only: the web build has no system fonts. Test: `bidi::tests::other_scripts_take_an_installed_system_face` (skips scripts the machine has no face for). | | 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). | diff --git a/docs/mcp.md b/docs/mcp.md index a68d38d99..aebcbf46d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -380,7 +380,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. | `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). | | `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | -| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | +| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, colorProfile?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline; png, jpg and webp: `colorProfile` is `sameAsFile` (default: the document's profile), `srgb` or `displayP3`, the pixels converted (out-of-gamut colors clip) and the file tagged for it (SVG, PDF and XML are always sRGB). The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | | `open_file {path, variables?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` or FigJam `.jam` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. | | `undo` / `redo` | Step through history. | @@ -693,7 +693,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file (off by default; files named on the command line still open, and `--control` starts in a new file anyway, so control callers have a document). -- `pref.language {language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|bs|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|zh-Hant|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c9dacf37..75de5f1af 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,9 +16,9 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr 3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)): 40–60 h, parallel by tool. 4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja, - ko, zh-Hans, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed), + ko, zh-Hans, zh-Hant, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed), the entries added since each catalog was written, then the other key languages (Hindi, - Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain. + Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain. Traditional Chinese (Taiwan) has a complete draft too. 5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h. ## Alpha gate @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3add7e94a..031521f51 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -652,6 +652,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | | 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. | diff --git a/docs/translating.md b/docs/translating.md index b24a4e04b..6eb472281 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -10,13 +10,14 @@ catalog reads English): a regional tag reads its language's catalog (`fr-CA` → with only a regional catalog reads that one (`pt-PT` → `pt-BR`), and Latin American and US Spanish (`es-MX`, `es-AR`, `es-CO`, `es-US`, `es-419`…) read `es-419`. Chinese (`zh`, `zh-CN`, `zh-SG`, `zh-Hans`) reads `zh-Hans`; Traditional Chinese (`zh-Hant`, -`zh-TW`, `zh-HK`, `zh-MO`) has no catalog and reads English (or the next OS language), not +`zh-TW`, `zh-HK`, `zh-MO`) reads `zh-Hant`, and an explicit `Hans` (`zh-Hans-TW`) still reads Simplified. `pref.language {language}` does the same from the CLI, MCP and the control channel. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese -(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish +(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Traditional Chinese (`zh-Hant`), Czech +(`cs`), Indonesian (`id`), Finnish (`fi`), Bengali (`bn`), Italian (`it`), Vietnamese (`vi`), Thai (`th`) and Bosnian (`bs`), machine-drafted and waiting for native speakers' review. Polish (`pl`, Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status. @@ -51,6 +52,9 @@ Enter for the key, control for a gamepad, resorte) and keeps Spanish's tú. Japa concise, without です/ます endings, with full-width ()「」:、。 where Japanese UI text has them; Korean labels end without 합니다 (instructions in -세요). Simplified Chinese is concise, with full-width ():,、。“” in Chinese text and a space between Chinese and Latin words or numbers. +Traditional Chinese (Taiwan) is concise too, with full-width ():,、「」 and a space between +Chinese and Latin words or numbers, and Taiwan design terms (畫布, 圖層, 框架, 元件, 原型, 約束, +填色, 邊框, 效果, 匯出, 版本; Auto Layout stays English). None of the three has plural forms. Turkish uses bare imperatives for buttons and menu items (Kaydet, Sil) and the polite -in/-ın for instructions (seçin, tıklayın), puts the percent sign before the number (%50) and keeps a noun singular after a count, so its plural entries give only `other`. @@ -78,6 +82,12 @@ CJK and Thai text need their own fonts, which egui and Inter lack: the desktop a one when something on screen needs it, so layer and page names in those scripts show whatever the UI language (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, so Japanese, Korean and Chinese appear there only in a build with craft-fonts and +`FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker, and +Traditional Chinese is left out unless that build has a Noto Sans CJK TC face. A +Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes, and a Traditional +Chinese UI puts an installed Noto Sans CJK TC first, then the system's Taiwan faces (Microsoft +JhengHei, PingFang TC, Heiti TC), so Han takes Traditional shapes. No font is added to this +repository. Georgian is drawn in `FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. The other scripts' faces (Noto Sans Arabic, Hebrew, Thai, Devanagari) join a web build only with `FIGCRAFT_EMBED_SCRIPTS=Arab,Hebr,Thai,Deva` (one tag per script wanted, as the manifest lists them), for @@ -107,6 +117,25 @@ that face out, so Arabic is not offered on the web yet. No font is added to this repository. The interface's panels are not mirrored; this catalog does not claim a complete right-to-left interface. Native review and representative in-app checks remain required. +## Traditional Chinese (Taiwan) draft + +`zh-Hant.toml` adds 繁體中文(台灣) as a draft: 1,975 UI entries, 641 command labels and 31 menu +wordings (2,647 entries, measured 2026-11-03). It was translated clean-room from the English source +in `en.toml`; the Simplified Chinese catalog was not used as a source. It covers every English key, +so the completeness test keeps it that way. + +Taiwan design terms: 畫布 (canvas), 圖層 (layer), 框架 (frame), 元件 (component), 原型 (prototype), +Auto Layout (kept in English), 約束 (constraints), 填色 (fill), 邊框 (stroke), 效果 (effects), +匯出 (export) and 版本 (version). Actions use concise Taiwan verbs (儲存, 刪除, 建立副本), and +quotes are 「」. Placeholders, command ids, keyboard glyphs and product names stay as in English. + +Traditional Chinese has no plural forms, so plural entries give the same text for `one` and +`other`; `plural` never picks `one` for `zh-Hant`. The desktop UI adds an installed Noto Sans CJK +TC first, then Microsoft JhengHei, PingFang TC, Heiti TC and the other Taiwan faces; no font is +bundled. The web picker leaves Traditional Chinese out unless the build embeds a Noto Sans CJK TC +face, as it does for the other CJK catalogs. Native review remains open (draft), as do the in-app +checks of long labels in the right panel. + ## Adding a language 1. Copy `crates/ui-egui/locales/es.toml` to `.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 109dbaf81..e11e49eef 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-11-04 · **Change:** minor (a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; 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; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; 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; 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) · **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: @@ -133,8 +133,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | | Count | Notes | |---|---|---| -| Documented shortcuts (rows) | 134 | each with macOS and Windows/Linux keys | -| Bound as documented | 133 (110 before this suite) | 112 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | +| Documented shortcuts (rows) | 135 | each with macOS and Windows/Linux keys | +| Bound as documented | 134 (110 before this suite) | 112 keymap bindings, 16 held keys, gestures and presentation keys, 6 vector edit tool keys | | 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 | @@ -173,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet | | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | +| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a drag released over the canvas drops nowhere; a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) | | Reduced motion | done | system setting or preference | | WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -181,12 +182,14 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | +| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | | 2026-11-02 | patch | Fill section: a text whose characters were coloured differently while editing shows mixed content, as a mixed selection does ("Text nodes can have multiple sets of fills if some characters are colored differently than others", the developer documentation's `fills`); characters that all share fills the layer doesn't have (all of them styled while editing) show those instead of the layer's older list; and a layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, their own fills going in the same undo step. The section used to show the layer's own fill, a single colour the canvas no longer showed (#1856). Tests: `text_mixed_fill_tests`, engine `text_fill_tests`. Numbers unchanged | | 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties copied the selected layer's properties into the Paste properties clipboard and nothing else, so a new rectangle drawn afterwards was still grey (and what Copy properties had copied was lost). The defaults are now kept apart (`Session::default_props`, one set for shapes and one for text): rectangles, ellipses, polygons and stars drawn or clicked with their tools take the shape defaults (`apply_copied_props`, the same rules as Paste properties: corners only where a layer has them), and new text from the Text tool takes a text layer's fills and font and is laid out at that size. Frames, sections, slices, lines, arrows, vectors and layers made by commands keep their own look. `edit.setDefaultProperties` answers `{for: shapes|text, properties}`. Tests: `default_props_tests` (3) | -| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. | | 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. | +| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache lays names out in descending order of a bound on their width until the bound falls under the widest found: a list with one long name lays out a handful, names all alike all of them, through egui's galley cache) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a drag released past the list, over the canvas, drops nowhere; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. | | 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |