mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
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:
@@ -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,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"))?;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user