From 55aa0165fd8ba2ef9d2e0821ef20a6f7b661a8db Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:41:11 +0700 Subject: [PATCH] #801: keep a component set's outline as its real stroke (reverts the export change) --- crates/doc/src/node.rs | 4 +-- crates/engine/src/export_behaviour_tests.rs | 14 --------- crates/format/src/large_tests.rs | 30 ------------------- crates/format/src/lib.rs | 33 ++------------------- crates/ui-egui/src/canvas.rs | 7 ----- crates/ui-egui/src/canvas_fixes_tests.rs | 14 --------- docs/components-parity.md | 1 - 7 files changed, 4 insertions(+), 99 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 582cdb75..00f7d559 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -565,14 +565,14 @@ impl Node { NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)], _ => vec![], }; - // A component set's purple dashed frame is editor chrome: the canvas draws it, and it is - // never a stroke in the document, so exports don't show it. let strokes = match &kind { NodeKind::Line => vec![Paint::solid(Color::BLACK)], + NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))], _ => vec![], }; let stroke = match &kind { NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() }, + NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() }, _ => StrokeProps::default(), }; let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal }; diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index e3652a19..6896ce3e 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -317,20 +317,6 @@ fn svg_export_options() { assert!(svg.contains("stroke-width=\"4\""), "an inside stroke on a rectangle simplifies too (the rectangle inset by half the weight)"); } -#[test] -fn a_component_sets_editor_outline_is_not_exported() { - let mut s = Session::new(); - let a = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40}))); - let b = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 300, "y": 0, "width": 100, "height": 40}))); - let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); - assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the purple dashed frame is editor chrome, not a stroke"); - let svg = text(&export(&mut s, json!({"ids": [set], "format": "svg"}))); - assert!(!svg.to_ascii_lowercase().contains("9747ff") && !svg.contains("stroke-dasharray"), "{svg}"); - let png = pixels(&export(&mut s, json!({"ids": [set]}))); - let purple = png.pixels().filter(|p| p[3] > 0 && p[0].abs_diff(0x97) < 24 && p[1].abs_diff(0x47) < 24 && p[2].abs_diff(0xFF) < 24).count(); - assert_eq!(purple, 0, "the exported set has no purple outline pixels"); -} - #[test] fn copy_as_png_svg_and_code() { let mut s = Session::new(); diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 143dc82a..889074f1 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -186,36 +186,6 @@ fn files_with_every_field_spelled_out_still_open() { assert_same_doc(&d, &again); } -#[test] -fn a_component_sets_old_editor_outline_is_dropped_only_without_the_marker() { - let mut d = Document::new("Sets"); - let page = d.first_page().unwrap(); - let old = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 100.0, 40.0).unwrap(); - let custom = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Chip", 0.0, 80.0, 100.0, 40.0).unwrap(); - // As 0.6.0 wrote every set: the canvas's purple dashed outline stored as a real stroke. - let n = d.get_mut(old).unwrap(); - n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))]; - n.stroke.dashes = vec![10.0, 5.0]; - // A stroke someone chose is design content and stays. - let n = d.get_mut(custom).unwrap(); - n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x11, 0x22, 0x33))]; - n.stroke.dashes = vec![1.0, 2.0]; - // A file this version writes carries the marker: the same stroke is a choice, not decoration. - let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); - let (current, _) = from_json(&text).unwrap(); - assert_eq!(current.node(old).unwrap().strokes.len(), 1, "a marked file keeps a stroke someone chose"); - assert_eq!(current.node(custom).unwrap().strokes.len(), 1); - // A file without the marker is from a version that stored the decoration; drop exactly that. - let mut legacy = to_json(&d).unwrap(); - legacy.as_object_mut().unwrap().remove("editorOutlines"); - let (back, _) = from_json(&serde_json::to_string(&legacy).unwrap()).unwrap(); - let old = back.node(old).unwrap(); - assert!(old.strokes.is_empty() && old.stroke.dashes.is_empty(), "the decoration is canvas chrome, not a document stroke"); - let custom = back.node(custom).unwrap(); - assert_eq!(custom.strokes.len(), 1, "a real stroke is not the decoration"); - assert_eq!(custom.stroke.dashes, vec![1.0, 2.0]); -} - #[test] fn the_header_is_read_on_its_own() { // A newer file is refused before its document is looked at, whatever the version's size. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4ae154e7..9f7e51bc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -28,8 +28,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Seek, Write}; use std::sync::Arc; -use figcraft_doc::color::{Color, Paint}; -use figcraft_doc::{Document, ImageData, NodeKind}; +use figcraft_doc::{Document, ImageData}; use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor}; use serde::{Deserialize, Serialize, Serializer}; use serde_json::{Value, json}; @@ -232,18 +231,13 @@ struct DocumentJson<'a> { version: u32, #[serde(rename = "imageIndex", serialize_with = "image_index")] images: &'a BTreeMap, - /// The editor draws component-set outlines itself instead of storing them as strokes. Files - /// without this marker were written by a version that stored the purple dashed outline as a - /// real stroke (see [`drop_component_set_decoration`]). - #[serde(rename = "editorOutlines")] - editor_outlines: bool, #[serde(flatten)] doc: &'a Document, } impl<'a> DocumentJson<'a> { fn new(doc: &'a Document) -> Self { - Self { format: FORMAT, version: VERSION, images: &doc.images, editor_outlines: true, doc } + Self { format: FORMAT, version: VERSION, images: &doc.images, doc } } } @@ -282,8 +276,6 @@ type ImageIndex = Option>; struct Header { version: Option, images: ImageIndex, - /// Whether the writer drew component-set outlines outside the document. - editor_outlines: bool, } impl<'de> Deserialize<'de> for Header { @@ -299,7 +291,6 @@ impl<'de> Deserialize<'de> for Header { while let Some(key) = map.next_key::()? { match key.as_str() { "version" => h.version = map.next_value::()?.as_u64(), - "editorOutlines" => h.editor_outlines = map.next_value::()?.as_bool().unwrap_or(false), "imageIndex" => { h.images = map .next_value::()? @@ -318,21 +309,6 @@ impl<'de> Deserialize<'de> for Header { } } -/// Earlier versions gave every component set the purple dashed outline of the canvas as a real -/// stroke, so it exported with the set. New sets carry no paint; files written back then lose -/// exactly that decoration when opened, and the canvas draws the outline itself. -fn drop_component_set_decoration(doc: &mut Document) { - let decoration = Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF)); - let ids: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).map(|n| n.id).collect(); - for id in ids { - let Some(n) = doc.node_mut(id) else { continue }; - if n.strokes.len() == 1 && n.strokes.first() == Some(&decoration) && n.stroke.dashes.as_slice() == [10.0, 5.0] { - n.strokes.clear(); - n.stroke.dashes.clear(); - } - } -} - /// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first /// (a newer file is refused before its document is read), then the document is deserialized /// straight from the text. @@ -345,11 +321,6 @@ fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatE return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX))); } let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; - // Only files from before the marker can carry the old stored decoration; a file this version - // wrote keeps a stroke someone chose, even one that looks exactly like it. - if !header.editor_outlines { - drop_component_set_decoration(&mut doc); - } let issues = doc.validate(); if !issues.is_empty() { doc.repair(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 627fae56..9e0c1d79 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -936,13 +936,6 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } - // Component sets keep Figma's purple dashed frame on the canvas: editor chrome, not a - // document paint, so it never shows in an export. - if matches!(n.kind, NodeKind::ComponentSet { .. }) - && let Some(q) = node_quad(app, &doc, *c) - { - painter.add(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); - } } // Prototype connections. crate::proto_canvas::draw(app, painter); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index bca1f4fb..4cd32d56 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -51,20 +51,6 @@ fn frame_outlines_and_slices_draw_when_shown() { assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again"); } -#[test] -fn component_sets_draw_their_editor_outline() { - let mut s = Session::new(); - let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); - let b = id(&s.execute("node.create", &json!({"type": "component", "x": 200, "y": 0, "width": 100, "height": 40})).unwrap()); - let set = id(&s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap()); - s.execute("select.set", &json!({"ids": []})).unwrap(); - assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the outline is not a document stroke"); - let mut u = Ui::new(s); - let q = crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), set).unwrap(); - let shapes = flat(&u.frame_output(vec![]).shapes); - assert!(dashes_along(&shapes, q[0], q[1]) > 3, "the set shows its dashed outline"); -} - /// A job a few hundred milliseconds of rendering big. fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job { crate::render_worker::Job { diff --git a/docs/components-parity.md b/docs/components-parity.md index 8be23362..2640ed1c 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -76,7 +76,6 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Component sets: the purple dashed frame is canvas chrome again. A new set carries no stroke, the canvas draws the outline, and exports and screenshots leave it out; a file written by 0.6.0 loses exactly that stored decoration when opened. A stroke someone chose stays. Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. |