Merge locked-paths: fix(paths): path edits refuse a path on a locked layer or in a locked group (#735); fix(properties): links to other panels open them when panel docking is on (#736)

This commit is contained in:
aiongg
2026-10-13 14:28:19 +00:00
7 changed files with 81 additions and 16 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Path edits (`path.setAnchor`, `path.moveAnchors`, `path.appendAnchor`, `path.reverse`, `path.close`, anchor add/delete/convert, Erase, Smooth and Scissors) refuse a path that is locked, sits in a locked group or on a locked layer (#735). Properties links to the Stroke, Character, Paragraph, Effects, Text Wrap and Info panels open them in a customized panel layout too (#736). No readiness percentage change.
- **2026-10-13** · The Story Editor stays with the document its story belongs to: it hides while another document is active and closes with its document, so its edits never reach another document's story with the same id (#729). Undo restores the selection from before the command and Redo the one after it (#716). No readiness percentage change.
- **2026-10-13** · Saving never reports success without a file: a document that can't be written is an error from Save, Save As, Save a Copy, Package, snippet export and the web serializer, leaving the existing file, the unsaved state and the recovery checkpoint as they were. Placed files over 4 GiB are written with zip64 sizes. A web Save whose browser download can't start reports the error and keeps the document unsaved (#741, #708). No readiness percentage change.
- **2026-10-13** · Colour management runs on craft-libs' `craft-cms`. The default CMYK working space is its Coated FOGRA39 profile (Generic CMYK and Device CMYK stay selectable); CMYK swatches show relative colorimetric with the settings' black point compensation, and PDF/X-4 output intents embed the working CMYK profile (FOGRA39 by default). Profile names the built-in stands in for resolve to it; other unknown names are an error, and saved settings naming one start with the default. No readiness percentage change.
+5
View File
@@ -5,6 +5,7 @@ use designcraft_doc::{Document, ItemId, Shape};
use designcraft_geom::{Affine, Anchor, AnchorKind, Point};
use serde_json::{Value, json};
use super::object::path_editable;
use super::{CommandSpec, bad, cmd, has_doc, point_param};
use crate::{Result, Session};
@@ -69,6 +70,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
.map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
s.edit(|d, _| {
path_editable(d, id, cmd)?;
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let scale = inv.determinant().abs().sqrt().max(1e-9);
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
@@ -135,6 +137,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?;
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);
s.edit(|d, _| {
path_editable(d, id, "path.addAnchor")?;
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let q = inv * at;
@@ -154,6 +157,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?;
s.edit(|d, sel| {
path_editable(d, id, "path.deleteAnchor")?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?;
if ai >= sp.anchors.len() {
@@ -181,6 +185,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let to = point_param(p, "to");
let corner = p.get("corner").and_then(Value::as_bool);
s.edit(|d, _| {
path_editable(d, id, "path.convertAnchor")?;
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?;
+28 -6
View File
@@ -2086,6 +2086,20 @@ fn unlocked(d: &Document, it: &Item) -> bool {
!it.locked && d.layer(it.layer).is_none_or(|l| !l.locked)
}
/// Refuses a path edit on `id` when it, a group it sits in, or its layer is locked.
pub(super) fn path_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let mut items = d.spread(loc.spread).map(|sp| &sp.items[..]).unwrap_or_default();
for &i in &loc.path {
let Some(it) = items.get(i) else { break };
if !unlocked(d, it) {
return Err(bad(cmd, "the path is locked"));
}
items = it.children();
}
Ok(())
}
fn has_editable_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?;
@@ -2902,6 +2916,7 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")),
};
s.edit(|d, _| {
path_editable(d, id, "path.appendAnchor")?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
@@ -2941,12 +2956,10 @@ fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let subpath = p.get("subpath").and_then(Value::as_u64);
let which = p.get("anchor").cloned().unwrap_or(Value::Null);
s.edit(|d, _| {
path_editable(d, id, ID)?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if it.locked {
return Err(bad(ID, "the path is locked"));
}
let sp = match subpath {
Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)),
None => it.path.subpaths.last_mut(),
@@ -2988,12 +3001,19 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad("path.reverse", "no such subpath"));
}
let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
let ids: Vec<ItemId> = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
let doc = &s.doc()?.doc;
let editable: Vec<ItemId> = ids.iter().copied().filter(|id| path_editable(doc, *id, "path.reverse").is_ok()).collect();
if editable.is_empty()
&& let Some(id) = ids.first()
{
path_editable(doc, *id, "path.reverse")?;
}
s.edit(|d, _| {
let mut reversed = 0;
for id in &ids {
for id in &editable {
let Some(it) = d.item_mut(*id) else { continue };
if it.locked || it.is_group() {
if it.is_group() {
continue;
}
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
@@ -3015,6 +3035,7 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
s.edit(|d, _| {
path_editable(d, id, "path.close")?;
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
if only.is_some_and(|i| i >= it.path.subpaths.len()) {
return Err(bad("path.close", "no such subpath"));
@@ -3037,6 +3058,7 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let handle = str_param(p, "handle").map(str::to_string);
s.edit(|d, _| {
path_editable(d, id, "path.moveAnchors")?;
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
// Spread-space delta → inner-space delta (linear part only).
+1
View File
@@ -317,6 +317,7 @@ fn split_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::id_param(p, "id").ok_or_else(|| bad("path.split", "missing id"))?;
let at = point_param(p, "at").ok_or_else(|| bad("path.split", "missing at"))?;
s.edit(|d, sel| {
super::object::path_editable(d, id, "path.split")?;
let (sr, xf) = spread_xf(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
let it = d.item(id).cloned().ok_or(designcraft_doc::DocError::NoItem(id))?;
if !matches!(it.content, Content::Unassigned) {
+27
View File
@@ -301,6 +301,33 @@ fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() {
}
}
#[test]
fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() {
let mut app = app();
app.run("file.new", json!({"width":480,"height":360})).unwrap();
app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap();
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into()));
let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state(
|ui, app: &mut DesignApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(5);
let at = h.get_by_role_and_label(egui::accesskit::Role::Link, "Stroke").rect().center();
h.hover_at(at);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
assert!(h.state().ui.docking.as_ref().unwrap().contains(&"stroke".into()));
assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke"));
}
#[test]
fn changing_workspace_during_a_drag_cancels_the_stale_gesture() {
let mut app = app();
+18 -10
View File
@@ -16,6 +16,14 @@ use crate::widgets::{
};
use crate::{DesignApp, icons};
/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it
/// toggles the flyout, so a flyout that is already open is left as it is.
fn show_panel(app: &mut DesignApp, panel: &str) {
if app.ui.open_panel.as_deref() != Some(panel) {
let _ = app.run("window.panel", json!({"panel": panel}));
}
}
fn units(app: &DesignApp) -> Unit {
app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas)
}
@@ -479,7 +487,7 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
}
transform_angles(app, ui, rp);
if more_options(ui, &app.ui.language).clicked() {
app.ui.open_panel = Some("info".into());
show_panel(app, "info");
}
}
@@ -683,7 +691,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch"));
swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false);
if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() {
app.ui.open_panel = Some("stroke".into());
show_panel(app, "stroke");
}
const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0];
if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| {
@@ -715,7 +723,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
let b = block(ui, FIELD_H);
icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "opacity", t.icon);
if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Opacity"), 56.0)).clicked() {
app.ui.open_panel = Some("effects".into());
show_panel(app, "effects");
}
if let Some(v) = place(ui, sub(b, 96.0, 0.0, 51.0, FIELD_H), |ui| {
NumField::number("opacity", Some((i.opacity * 100.0).round()), "%", 0).width(51.0).range(0.0, 100.0).show(ui)
@@ -727,13 +735,13 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon);
if mresp.clicked() {
app.ui.open_panel = Some("effects".into());
show_panel(app, "effects");
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
if icon_toggle(ui, "frame-dashed", false, crate::i18n::tr(&app.ui.language, "Frame Fitting")).clicked() {
app.ui.open_panel = Some("textWrap".into());
show_panel(app, "textWrap");
}
if icon_toggle(ui, "fx", i.shadow, crate::i18n::tr(&app.ui.language, "Effects: Drop Shadow")).clicked() {
let _ = app.run("object.dropShadow", json!({"on": !i.shadow}));
@@ -886,7 +894,7 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
}
});
if more_options(ui, &app.ui.language).clicked() {
app.ui.open_panel = Some("textWrap".into());
show_panel(app, "textWrap");
}
}
@@ -1101,7 +1109,7 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) {
);
} else {
if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, label), 56.0)).clicked() {
app.ui.open_panel = Some("stroke".into());
show_panel(app, "stroke");
}
const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0];
if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| {
@@ -1212,7 +1220,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang));
});
if more_options(ui, &app.ui.language).clicked() {
app.ui.open_panel = Some("character".into());
show_panel(app, "character");
}
}
@@ -1244,7 +1252,7 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) {
}
});
if more_options(ui, &app.ui.language).clicked() {
app.ui.open_panel = Some("paragraph".into());
show_panel(app, "paragraph");
}
}
@@ -1262,7 +1270,7 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() {
app.ui.open_panel = Some("paragraph".into());
show_panel(app, "paragraph");
}
});
});
+1
View File
@@ -370,6 +370,7 @@ pub fn link_label(ui: &mut Ui, text: &str, width: f32) -> Response {
ui.painter().galley(pos, g, t.text);
let y = r.center().y + 6.5;
ui.painter().line_segment([pos2(r.min.x, y), pos2(r.min.x + w, y)], Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.text_dim }));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text));
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}