Merge main into #2224

This commit is contained in:
bflatastic
2026-11-12 11:03:16 +07:00
25 changed files with 460 additions and 26 deletions
+9
View File
@@ -1453,8 +1453,10 @@ pub struct PrintSettings {
/// is printed this far out, to be trimmed off.
pub bleed: f64,
/// Crop marks outside the bleed, at the trim's corners.
#[serde(alias = "crop_marks")]
pub crop_marks: bool,
/// Bleed marks outside the bleed, at the bleed's corners.
#[serde(alias = "bleed_marks")]
pub bleed_marks: bool,
/// CMYK colour (converted from RGB) instead of RGB.
pub cmyk: bool,
@@ -1523,25 +1525,32 @@ pub struct ExportSetting {
pub print: PrintSettings,
/// Only the layer itself (on), or every layer that shows in its area (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "contents_only")]
pub contents_only: bool,
/// The layer's own box, cutting off what it paints outside it (shadows, outside strokes).
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "include_bounding_box")]
pub include_bounding_box: bool,
/// SVG: text as outlines (on) or as `<text>` elements (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_outline_text")]
pub svg_outline_text: bool,
/// SVG: layer names as `id` attributes.
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "svg_id_attribute")]
pub svg_id_attribute: bool,
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_simplify_stroke")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
#[serde(alias = "vd_compat")]
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")]
#[serde(alias = "color_profile")]
pub color_profile: ExportColorProfile,
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
#[serde(skip_serializing_if = "is_default_quality")]
+10 -1
View File
@@ -58,6 +58,7 @@ pub enum ToolId {
Text,
TextOnPath,
Comment,
Annotation,
Eyedropper,
ShapeBuilder,
// Board tools.
@@ -71,7 +72,7 @@ pub enum ToolId {
}
impl ToolId {
pub const ALL: [ToolId; 26] = [
pub const ALL: [ToolId; 27] = [
ToolId::Move,
ToolId::Scale,
ToolId::Hand,
@@ -89,6 +90,7 @@ impl ToolId {
ToolId::Text,
ToolId::TextOnPath,
ToolId::Comment,
ToolId::Annotation,
ToolId::Eyedropper,
ToolId::ShapeBuilder,
ToolId::Sticky,
@@ -118,6 +120,7 @@ impl ToolId {
ToolId::Text => "text",
ToolId::TextOnPath => "textOnPath",
ToolId::Comment => "comment",
ToolId::Annotation => "annotation",
ToolId::Eyedropper => "eyedropper",
ToolId::ShapeBuilder => "shapeBuilder",
ToolId::Sticky => "sticky",
@@ -129,6 +132,11 @@ impl ToolId {
ToolId::Stamp => "stamp",
}
}
/// Whether Developer Mode keeps this tool: it only inspects, so the tools that edit leave it.
/// The one list behind the engine's pointer routing and command allowlist and the toolbar.
pub fn works_in_dev_mode(self) -> bool {
matches!(self, ToolId::Move | ToolId::Hand | ToolId::Comment | ToolId::Annotation)
}
pub fn from_id(s: &str) -> Option<ToolId> {
ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s))
}
@@ -151,6 +159,7 @@ impl ToolId {
ToolId::Text => "Text",
ToolId::TextOnPath => "Text on a path",
ToolId::Comment => "Comment",
ToolId::Annotation => "Annotation",
ToolId::Eyedropper => "Pick color",
ToolId::ShapeBuilder => "Shape builder",
ToolId::Sticky => "Sticky note",
+15 -1
View File
@@ -110,7 +110,7 @@ impl Session {
/// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects.
fn acting_tool(&self) -> ToolId {
match self.tool.tool {
t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move,
t if self.tool.dev_mode && !t.works_in_dev_mode() => ToolId::Move,
t => t,
}
}
@@ -176,6 +176,7 @@ impl Session {
self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y}));
Ok(())
}
ToolId::Annotation => self.annotation_down(ev),
ToolId::Eyedropper => self.eyedrop(ev),
t if t.on_boards() => self.board_down(ev),
t if t.draws_shape() => self.draw_down(ev),
@@ -206,6 +207,19 @@ impl Session {
Ok(true)
}
/// The Annotation tool's press: the layer under the pointer is selected and the UI is asked
/// for its note (the engine writes nothing; the note is typed in the Inspect tab). A press on
/// empty canvas does nothing.
fn annotation_down(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, page) = (ds.doc.clone(), ds.page);
// The same reach as the Move tool's pick (`ev.tol() * 2.0`); never the page itself.
let Some(id) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0).filter(|i| doc.node(*i).is_some_and(|n| !n.is_page())) else { return Ok(()) };
self.set_selection(vec![id]);
self.request_ui("annotation.new", json!({"id": id}));
Ok(())
}
pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> {
let tool = self.acting_tool();
let ds = self.ds()?;
+1
View File
@@ -291,6 +291,7 @@ pub static MENU: &[MenuItem] = &[
m!("Tools", "Pencil", "tool.pencil"),
m!("Tools", "Text", "tool.text"),
m!("Tools", "Comment", "tool.comment"),
m!("Tools", "Annotation", "tool.annotation"),
m!("Tools", "Shape builder", "tool.shapeBuilder"),
m!("Tools", "Actions", "app.actions"),
];
+6 -3
View File
@@ -26,8 +26,8 @@ pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS;
use export::add_member;
pub(super) use export::export_setting;
pub use export::{
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
frames_pdf, jobs_for, penpot_bytes, safe_file_name,
ExportJob, backdrop_layers, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers,
fig_bytes, frames_pdf, jobs_for, penpot_bytes, safe_file_name,
};
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
use load::font_report;
@@ -409,7 +409,10 @@ 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 mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
// Layers exported on their own still read the page beneath them for a background blur
// or glass (#2213); a "contents only" off export already draws the whole page.
let page = if setting.contents_only { backdrop_layers(doc, ids) } else { Vec::new() };
let mut pix = figcraft_render::render_area_over(doc, &drawn, &page, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
// The render is in the document's profile; the setting's profile converts the pixels
// (out-of-gamut colors clip) and tags the file.
+6
View File
@@ -55,6 +55,12 @@ pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) ->
crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own))
}
/// The top-level layers of the page of `ids`, bottom to top: what a background blur or glass in an
/// export of `ids` reads from (empty when `ids` are on no page).
pub fn backdrop_layers(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default()
}
/// The frame or group a slice sits in: None on the page or in a section.
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
let parent = doc.node(slice)?.parent?;
+27 -1
View File
@@ -235,6 +235,26 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
Ok(json!({"tool": t.id()}))
}
/// Shift+T and choosing the Annotation tool: with a layer selected (or named by `id`/`ids`) the UI
/// is asked for its note at once; in the tool, a press on a layer selects it and asks (the engine
/// never writes the note). A named target is checked first: one that isn't a layer in the document
/// is an error and the tool stays as it was.
fn annotation(s: &mut Session, p: &Value) -> Result<Value> {
let named = p.get("id").is_some_and(|v| !v.is_null()) || p.get("ids").is_some_and(|v| !v.is_null());
let first = crate::params::targets(s, p).into_iter().find(|i| s.doc().is_ok_and(|d| d.node(*i).is_some_and(|n| !n.is_page())));
if named && first.is_none() {
return Err(bad("tool.annotation", "`id` must name a layer in this document (not a page)".to_string()));
}
let r = tool(s, ToolId::Annotation)?;
if let Some(id) = first {
if named {
crate::cmd::edit::select_and_page(s, vec![id])?;
}
s.request_ui("annotation.new", json!({"id": id}));
}
Ok(r)
}
/// The View menu's commands and their parameters.
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -528,7 +548,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"tool.set",
"Choose tool",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
false,
|s, p| {
let t = req_string("tool.set", p, "tool")?;
@@ -536,6 +556,9 @@ pub fn specs() -> Vec<CommandSpec> {
if t.on_boards() {
crate::board::require(s, &format!("tool.{}", t.id()))?;
}
if t == ToolId::Annotation {
return annotation(s, &json!({}));
}
tool(s, t)
}
),
@@ -638,6 +661,9 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, ToolId::TextOnPath)
}),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
// Shift+T: with a layer selected (or an `id`) the UI is asked for its note at once; in the
// tool, a press on a layer selects it and asks (the engine never writes the note).
spec!("tool.annotation", "Annotation", "{id?|ids?}", false, annotation),
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
+1
View File
@@ -107,6 +107,7 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() {
(ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"),
(ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"),
(ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"),
(ToolId::Annotation, NONE, ON_SQUARE, Crosshair, "the annotation tool marks the layer it is pressed on"),
(ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"),
];
for (tool, m, at, want, why) in rows {
+1 -1
View File
@@ -230,7 +230,7 @@ pub fn dev_mode_allows(id: &str) -> bool {
// Edits recorded as one grouped undo step, so their spec says no history of its own.
const GROUPED: [&str; 1] = ["data.fill"];
match id.strip_prefix("tool.") {
Some(t) => matches!(t, "move" | "hand" | "comment"),
Some(t) => crate::tools::ToolId::from_id(t).is_some_and(crate::tools::ToolId::works_in_dev_mode),
None if id == "file.revert" || GROUPED.contains(&id) => false,
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
}
@@ -99,6 +99,102 @@ fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved");
}
/// The `annotation.new` requests queued so far, as the layer ids they name.
fn note_requests(s: &Session) -> Vec<NodeId> {
s.ui_requests.iter().filter(|r| r.kind == "annotation.new").filter_map(|r| crate::params::id_of(&r.data["id"])).collect()
}
fn click(s: &mut Session, x: f64, y: f64) {
s.pointer(&ev(PointerKind::Down, x, y)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y)).unwrap();
}
#[test]
fn the_annotation_tool_picks_a_layer_and_asks_for_its_note() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(s.tool.tool, ToolId::Annotation);
assert!(note_requests(&s).is_empty(), "nothing selected: no note is asked for yet");
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
// A press on empty canvas does nothing.
click(&mut s, 1000.0, 1000.0);
assert!(note_requests(&s).is_empty(), "empty canvas asks for nothing (dev_mode {dev_mode})");
// A press on the rectangle selects it and asks for its note.
click(&mut s, 40.0, 40.0);
assert_eq!(s.selection(), vec![r], "dev_mode {dev_mode}");
assert_eq!(note_requests(&s), vec![r], "dev_mode {dev_mode}");
assert_eq!(s.tool.tool, ToolId::Annotation, "the tool stays until V or Escape");
// The tool writes nothing.
assert_eq!(*s.doc().unwrap(), doc_before);
assert_eq!(s.undo_labels(1000).len(), undo_before);
}
// The keyboard flow: with a layer selected, choosing the tool asks at once.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(note_requests(&s), vec![r]);
// And with an `id`.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "tool.annotation", json!({"id": r}));
assert_eq!(note_requests(&s), vec![r]);
assert_eq!(s.selection(), vec![r]);
}
/// A named target that isn't a layer (a page, an unknown id) is an error, and the tool is left as it
/// was; several targets pick the first layer among them and select it.
#[test]
fn the_annotation_tool_checks_the_layer_it_is_named_for_before_changing_anything() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
let page = s.page().unwrap();
assert!(s.execute("tool.annotation", &json!({"id": page})).is_err(), "a page is not a layer");
assert!(s.execute("tool.annotation", &json!({"id": "9999:9999"})).is_err(), "no such layer");
assert!(s.execute("tool.annotation", &json!({"ids": []})).is_err(), "nothing named");
assert_eq!(s.tool.tool, ToolId::Move, "the tool did not change");
assert!(note_requests(&s).is_empty());
// The page first, then the rectangle: the rectangle is the target, and selected.
ex(&mut s, "tool.annotation", json!({"ids": [page, r]}));
assert_eq!((s.tool.tool, s.selection(), note_requests(&s)), (ToolId::Annotation, vec![r], vec![r]));
}
/// Choosing the tool with `tool.set` (the toolbar) asks for the selected layer's note like Shift+T.
#[test]
fn choosing_the_annotation_tool_from_the_toolbar_asks_for_the_selected_layers_note() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.set", json!({"tool": "annotation"}));
assert_eq!((s.tool.tool, note_requests(&s)), (ToolId::Annotation, vec![r]));
}
/// A drag with the tool never moves the layer it starts on, in Developer Mode or out of it.
#[test]
fn dragging_with_the_annotation_tool_moves_nothing() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
assert_eq!(*s.doc().unwrap(), doc_before, "dev_mode {dev_mode}");
assert_eq!(s.undo_labels(1000).len(), undo_before, "dev_mode {dev_mode}");
}
}
#[test]
fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() {
for id in [
@@ -121,6 +217,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts
"tool.move",
"tool.hand",
"tool.comment",
"tool.annotation",
"inspect.code",
] {
assert!(dev_mode_allows(id), "{id} runs in Dev Mode");
+161
View File
@@ -385,6 +385,167 @@ fn copy_as_png_svg_and_code() {
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
}
/// A red square with a fill-less pane over its right part, holding `effect` (#2213).
fn pane_scene(effect: EffectKind) -> (figcraft_doc::Document, NodeId, NodeId) {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export");
let page = d.first_page().unwrap();
let under = d.create(page, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(page, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: effect, visible: true }];
(d, under, pane)
}
/// The PNG export of `ids` as the Export command makes it, and what the canvas draws there.
fn export_vs_canvas(d: &figcraft_doc::Document, ids: &[NodeId]) -> (image::RgbaImage, image::RgbaImage) {
let setting = figcraft_doc::ExportSetting::default();
let page = d.first_page().unwrap();
let area = crate::cmd::file::export_area(d, ids, &setting).unwrap();
let canvas = figcraft_render::render_area(d, &d.get(page).unwrap().children.clone(), area, 1.0).unwrap();
let canvas = image::RgbaImage::from_raw(canvas.width(), canvas.height(), figcraft_render::to_rgba8(&canvas)).unwrap();
let (bytes, _) = crate::cmd::file::export_file(d, ids, &setting, 1.0).unwrap();
(pixels(&bytes), canvas)
}
/// #2213: a layer with a background blur exported on its own shows the same pixels as the canvas
/// shows for that area: the blur reads what lies under it, so the export must draw the layers
/// beneath it as the backdrop (clipped to the layer) rather than blurring nothing.
#[test]
fn exporting_a_background_blur_layer_keeps_the_blurred_backdrop() {
let (d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
// The pane's left half sits over the red square, which ends at x = 120 (60 px into the pane).
assert_ne!(canvas.get_pixel(60, 40).0, [0, 0, 0, 0], "the canvas draws the blurred backdrop");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "export lost the backdrop the blur reads");
assert_eq!(exported.dimensions(), (120, 80), "the pane's own bounds");
}
/// #2213, as reported and as measured in Figma (2026-11-10): the pane inside a frame, exported on
/// its own, keeps the frame's fill and the blurred layer beneath it, opaque.
#[test]
fn a_blurred_pane_inside_a_frame_keeps_the_frame_and_the_layer_beneath() {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export-frame");
let page = d.first_page().unwrap();
let art = d.create(page, NodeKind::Frame, "Art", 0.0, 0.0, 300.0, 200.0).unwrap();
d.get_mut(art).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FFFFFF").unwrap())];
let under = d.create(art, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(art, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)), visible: true }];
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "the blurred square under the pane");
assert_eq!(exported.get_pixel(100, 40).0[3], 255, "the frame's white behind the pane, opaque");
}
/// #2213: the same for Glass.
#[test]
fn exporting_a_glass_layer_keeps_the_backdrop_it_bends() {
let (d, _, pane) = pane_scene(EffectKind::Glass(figcraft_doc::Glass::default()));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_ne!(canvas.get_pixel(30, 40).0, [0, 0, 0, 0]);
assert_eq!(exported.get_pixel(30, 40), canvas.get_pixel(30, 40), "export lost the backdrop the glass reads");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40));
}
/// Measured in Figma 2026-11-10: inside the pane's bounds the export is opaque where the backdrop
/// is (the blurred red), and the layers over the pane stay out.
#[test]
fn a_blur_export_is_opaque_inside_the_pane_and_leaves_layers_above_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let page = d.first_page().unwrap();
let above = d.create(page, figcraft_doc::NodeKind::Rectangle, "Above", 0.0, 0.0, 200.0, 200.0).unwrap();
d.get_mut(above).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("0000FF").unwrap())];
let (exported, _) = export_vs_canvas(&d, &[pane]);
let left = exported.get_pixel(10, 40).0;
assert_eq!(left[3], 255, "red under the pane: opaque");
assert!(left[0] > 150 && left[2] < 60, "red, not the blue layer above: {left:?}");
// The far right of the pane has no square under it: nothing to read, nothing drawn.
assert_eq!(exported.get_pixel(110, 40).0[3], 0);
}
/// Guard: a layer without backdrop effects over another still exports alone ("contents only").
#[test]
fn a_layer_without_backdrop_effects_still_leaves_the_layers_below_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(0.0)));
d.get_mut(pane).unwrap().effects.clear();
let (exported, _) = export_vs_canvas(&d, &[pane]);
assert!(exported.pixels().all(|p| p.0 == [0, 0, 0, 0]), "a fill-less pane alone exports clear");
}
#[test]
fn node_set_takes_back_the_export_settings_node_get_returns() {
use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, PrintSettings, VdCompat};
// Every multi-word field (and `quality`) away from its default.
let expected = ExportSetting {
format: ExportFormat::Jpg,
suffix: "@3x".into(),
print: PrintSettings { bleed: 9.0, crop_marks: true, bleed_marks: true, cmyk: true },
contents_only: false,
include_bounding_box: true,
svg_outline_text: false,
svg_id_attribute: true,
svg_simplify_stroke: false,
vd_compat: VdCompat::Exact,
color_profile: ExportColorProfile::DisplayP3,
quality: 55,
..Default::default()
};
let default = ExportSetting::default();
for (field, differs) in [
("print", expected.print != default.print),
("contents_only", expected.contents_only != default.contents_only),
("include_bounding_box", expected.include_bounding_box != default.include_bounding_box),
("svg_outline_text", expected.svg_outline_text != default.svg_outline_text),
("svg_id_attribute", expected.svg_id_attribute != default.svg_id_attribute),
("svg_simplify_stroke", expected.svg_simplify_stroke != default.svg_simplify_stroke),
("vd_compat", expected.vd_compat != default.vd_compat),
("color_profile", expected.color_profile != default.color_profile),
("quality", expected.quality != default.quality),
] {
assert!(differs, "{field} must be non-default in the expected setting");
}
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, "#FF0000", None);
let stored = |s: &Session| s.doc().unwrap().node(a).unwrap().exports.clone();
ex(
&mut s,
"export.add",
json!({"ids": [a], "setting": {
"format": "jpg", "suffix": "@3x", "bleed": 9, "cropMarks": true, "bleedMarks": true, "cmyk": true,
"contentsOnly": false, "includeBoundingBox": true, "svgOutlineText": false, "svgIdAttribute": true,
"svgSimplifyStroke": false, "vdCompat": "exact", "colorProfile": "displayP3", "quality": 55,
}}),
);
assert_eq!(stored(&s), vec![expected.clone()], "export.add stores every field");
// What node.get returns goes back through node.set unchanged.
let got = ex(&mut s, "node.get", json!({"id": a}))["exports"].clone();
set(&mut s, &[a], json!({"exports": []}));
assert!(stored(&s).is_empty());
set(&mut s, &[a], json!({"exports": got}));
assert_eq!(stored(&s), vec![expected.clone()], "node.set stores what node.get returned, unchanged");
// The snake_case spelling is taken too.
set(&mut s, &[a], json!({"exports": []}));
set(
&mut s,
&[a],
json!({"exports": [{
"format": "jpg", "suffix": "@3x",
"print": {"bleed": 9, "crop_marks": true, "bleed_marks": true, "cmyk": true},
"contents_only": false, "include_bounding_box": true, "svg_outline_text": false, "svg_id_attribute": true,
"svg_simplify_stroke": false, "vd_compat": "exact", "color_profile": "displayP3", "quality": 55,
}]}),
);
assert_eq!(stored(&s), vec![expected], "node.set takes the snake_case keys");
}
#[test]
fn export_rejects_an_invalid_layer_id_instead_of_exporting_the_page() {
let mut s = Session::new();
+1
View File
@@ -192,6 +192,7 @@ pub static KEYS: &[Binding] = &[
k(Tools, "tool.pencil", &["Shift+P"]),
k(Tools, "tool.text", &["T"]),
k(Tools, "tool.comment", &["C"]),
k(Tools, "tool.annotation", &["Shift+T"]),
k(Tools, "tool.shapeBuilder", &["Shift+M"]),
// Board tools (on boards only), on keys nothing else uses.
k(Tools, "tool.sticky", &["Q"]),
+2
View File
@@ -206,6 +206,8 @@ fn tool_keys_come_from_the_keymap() {
assert_eq!(ToolId::Eyedropper.shortcut(), "I");
assert_eq!(ToolId::Polygon.shortcut(), "");
assert_eq!(crate::catalog::shortcut("tool.text"), Some("T"));
assert_eq!(ToolId::Annotation.shortcut(), "Shift+T");
assert_eq!(crate::catalog::shortcut("tool.annotation"), Some("Shift+T"));
}
#[test]
+2 -1
View File
@@ -110,6 +110,7 @@ static ROWS: &[Row] = &[
doc(Tools, "Pencil tool", &["Shift+P"], &[], Bound("tool.pencil"), ""),
doc(Tools, "Text tool", &["T"], &[], Bound("tool.text"), ""),
doc(Tools, "Comment mode", &["C"], &[], Bound("tool.comment"), ""),
doc(Tools, "Annotation tool", &["Shift+T"], &[], Bound("tool.annotation"), "Accessibility at Figma, Add an annotation; Developer Mode"),
doc(Tools, "Hand tool while held", &["Space"], &[], Gesture("Pan the canvas"), "held, not pressed"),
doc(Tools, "Pan with the arrows when nothing is selected (Shift: farther)", &["ArrowRight"], &[], Gesture("Pan with nothing selected (Shift: farther)"),
"view.pan; nudges move the selection when there is one"),
@@ -416,7 +417,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), (136, 135, 1, 0, 2, 57));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (137, 136, 1, 0, 2, 57));
}
#[test]
+1 -1
View File
@@ -336,7 +336,7 @@ fn tools() -> Vec<Value> {
"y": num("Page y"),
"to": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "Drag end [x, y] (page)"},
"steps": int("Intermediate move events for drag (default 8)"),
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment"},
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation"},
"shift": boolean("Shift held"),
"alt": boolean("Alt/Option held"),
"cmd": boolean("Cmd (macOS) / Ctrl held"),
+39 -7
View File
@@ -266,11 +266,11 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
nodes_into(doc, ids, view, pix, 0.0);
nodes_into(doc, ids, view, pix, 0.0, None);
}
/// [`render_nodes_into`] `time` seconds into animated GIFs.
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64, under: Option<&backdrop::Under>) {
let bounds = draw::Bounds::default();
let ctx = draw::Ctx {
doc,
@@ -282,7 +282,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
lod: false,
preview: false,
magnified_image_pixels: false,
under: None,
under,
time,
bounds: &bounds,
};
@@ -353,16 +353,24 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
/// [`render_node`] `time` seconds into animated GIFs (the presenter plays them).
pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option<Pixmap> {
area_at(doc, &[id], export_bounds(doc, id)?, scale, time)
area_at(doc, &[id], None, export_bounds(doc, id)?, scale, time)
}
/// Rasterise `ids` (bottom to top) over page area `b` at `scale` (export): the image starts at the
/// area's top-left and its sides round up to whole pixels. `None` past 16384 pixels a side.
pub fn render_area(doc: &Document, ids: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, b, scale, 0.0)
area_at(doc, ids, None, b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
/// [`render_area`] for an export of layers that sit among `page_layers` (the page's top-level
/// layers, in z-order): a background blur or glass in `ids` reads what lies under the first of
/// them on that page, clipped to the area (#2213). Only `ids` are drawn; the layers above stay
/// out. Without such an effect it is [`render_area`].
pub fn render_area_over(doc: &Document, ids: &[NodeId], page_layers: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, Some(page_layers), b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], page_layers: Option<&[NodeId]>, b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
@@ -371,7 +379,31 @@ fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Op
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
nodes_into(doc, ids, &view, &mut pix, time);
// What the backdrop effects read: the page under the first layer, with the margin they reach.
let reach = page_layers.and_then(|_| {
ids.iter()
.flat_map(|i| doc.subtree(*i))
.filter_map(|i| doc.node(i))
.filter(|n| n.visible && backdrop::has_effects(n))
.map(|n| backdrop::pad(n, scale))
.max()
});
let beneath = match (page_layers, ids.first(), reach) {
(Some(layers), Some(first), Some(m)) => {
let m = i32::try_from(m).unwrap_or(0);
let p = f64::from(m) / scale;
let around = View {
origin: (b.x0 - p, b.y0 - p),
scale,
width: view.width.saturating_add(m.unsigned_abs() * 2),
height: view.height.saturating_add(m.unsigned_abs() * 2),
};
backdrop::render_beneath(doc, layers, *first, &around).map(|back| (back, m))
}
_ => None,
};
let under = beneath.as_ref().map(|(back, m)| backdrop::Under::new(back, (*m, *m), None));
nodes_into(doc, ids, &view, &mut pix, time, under.as_ref());
Some(pix)
}
+1
View File
@@ -122,6 +122,7 @@ name = "English"
"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Angular"
"Animation" = "Animation"
"Annotation" = "Annotation"
"Annotations" = "Annotations"
"Annotations shown on the canvas" = "Annotations shown on the canvas"
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
+2
View File
@@ -22,6 +22,7 @@ status = "draft — needs native review"
"Along the top" = "A lo largo del borde superior"
"An earlier vote" = "Una votación anterior"
"Android Vector Drawable" = "Android Vector Drawable"
"Annotation" = "Anotación"
"Best effort" = "Mejor aproximación"
"Between the selected layers" = "Entre las capas seleccionadas"
"Brainstorm" = "Lluvia de ideas"
@@ -2366,6 +2367,7 @@ status = "draft — needs native review"
"text.underline" = "Subrayado"
"text.weightDown" = "Reducir peso de fuente"
"text.weightUp" = "Aumentar peso de fuente"
"tool.annotation" = "Anotación"
"tool.arrow" = "Flecha"
"tool.comment" = "Comentario"
"tool.connector" = "Conector"
+12
View File
@@ -266,6 +266,18 @@ impl FigApp {
self.ui.comments.show(id);
}
}
"annotation.new" => {
// The Annotation tool: show the Inspect tab (never toggle it away) and put the
// caret in the layer's new-note field.
// Only a selected layer's field can show; a request for anything else would wait
// and take the keyboard later.
let shown =
|id| self.session.selection().contains(&id) && self.session.doc().is_ok_and(|d| d.node(id).is_some_and(|n| !n.is_page()));
if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of).filter(|id| shown(*id)) {
self.ui.right_tab = RightTab::Inspect;
self.ui.dev.focus_new_note = Some(id);
}
}
"comment.new" => {
let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
+44
View File
@@ -196,3 +196,47 @@ fn the_playground_swaps_an_instance_in_the_preview_only() {
let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap();
assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}");
}
#[test]
fn the_annotation_tool_opens_the_inspect_tab_with_the_note_field_focused() {
let (mut u, _, r) = setup();
u.app.ui.right_tab = crate::RightTab::Design;
u.frames(2, Vec::new);
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect, "the request shows the Inspect tab");
let field = egui::Id::unique(("annot_new", r)).with("te");
assert!(u.ctx.memory(|m| m.has_focus(field)), "the layer's new-note field has the caret");
assert_eq!(u.app.ui.dev.focus_new_note, None, "the focus request is spent");
// Asking again while already on the Inspect tab does not toggle it away.
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(field)), "the field has the caret again");
assert_eq!(u.app.ui.dev.focus_new_note, None, "and the request is spent");
}
/// A request for a layer that isn't selected (or is gone) does not wait to take the keyboard later.
#[test]
fn a_note_request_for_a_layer_that_is_not_selected_is_dropped() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": []})).unwrap();
u.frames(2, Vec::new);
u.app.session.request_ui("annotation.new", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.dev.focus_new_note, None);
}
/// Choosing the Annotation tool from the toolbar on the Inspect tab keeps the tab (an editing tool
/// would leave it for Design) and asks for the selected layer's note.
#[test]
fn the_toolbar_keeps_the_inspect_tab_for_the_annotation_tool() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap();
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Annotation);
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(egui::Id::unique(("annot_new", r)).with("te"))), "the note field has the caret");
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Rectangle);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Design, "a drawing tool leaves it");
}
+8
View File
@@ -92,6 +92,9 @@ impl CategoryFilter {
/// revision), the page or the choice change.
#[derive(Default)]
pub struct DevUi {
/// The layer whose new-note field takes the keyboard focus once its section draws (the
/// Annotation tool asked for that layer's note).
pub focus_new_note: Option<NodeId>,
pub filter: usize,
pub categories: CategoryFilter,
pub compare: Option<crate::dev_compare::CompareView>,
@@ -618,6 +621,11 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
}
ui.add_space(6.0);
}
// The Annotation tool asked for this layer's note: put the caret in the new-note field, once.
if app.ui.dev.focus_new_note == Some(id) {
app.ui.dev.focus_new_note = None;
ui.memory_mut(|m| m.request_focus(Id::unique(("annot_new", id)).with("te")));
}
// A new annotation starts as a note; properties are pinned to it afterwards.
if n.annotations.len() < annotations::MAX_PER_LAYER
&& let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1)
+2 -2
View File
@@ -626,8 +626,8 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
/// Choose `tool` from the toolbar. Dev Mode only inspects: a tool that edits leaves it for the
/// design.
fn choose(app: &mut FigApp, tool: ToolId) {
if app.ui.right_tab == crate::RightTab::Inspect && !matches!(tool, ToolId::Move | ToolId::Hand | ToolId::Comment) {
pub(crate) fn choose(app: &mut FigApp, tool: ToolId) {
if app.ui.right_tab == crate::RightTab::Inspect && !tool.works_in_dev_mode() {
app.ui.right_tab = crate::RightTab::Design;
}
app.run("tool.set", json!({"tool": tool.id()}));
+3 -2
View File
@@ -1,6 +1,6 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** patch (the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
to the code components of a repository (with their imports, also inside generated layer trees),
@@ -15,7 +15,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Annotations: pinned properties, categories, coloured callouts, filter | done | the Annotation tool (Shift+T, `tool.annotation`) picks a layer by a click or from the selection and opens its note field in the Inspect tab. Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions |
@@ -29,6 +29,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | The Annotation tool (Shift+T, `tool.annotation {id?}`): selects the layer pressed on (or the selection, or `id`) and asks the UI for its note, which the Inspect tab takes in its new-note field; the engine writes nothing itself, and Developer Mode lets the tool through its gates (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged |
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
File diff suppressed because one or more lines are too long
+6 -4
View File
File diff suppressed because one or more lines are too long