Panels to Illustrator parity (19 panels), ~70 menu commands, generic param dialogs, view rotation, property/model-based tests + MCP e2e; fix arrange overflow, compound grouping, double journaling, float round-trip, coordinate sanity, simplify growth, batch rollback of session state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 09:32:20 -04:00
co-authored by Claude Opus 5.5
parent 213b0927ee
commit 42df20ca7f
143 changed files with 12927 additions and 642 deletions
+333
View File
@@ -0,0 +1,333 @@
//! Edit → Edit Colors: invert, convert to CMYK/Grayscale/RGB, saturate, adjust colour balance and the
//! three Blend commands. They recolour fills and strokes (solid colours, gradient stops and text
//! runs) of the selected objects and everything inside them. Colours linked to a global swatch are
//! unlinked when they change.
use std::sync::Arc;
use drawcraft_color::{Color, Paint};
use drawcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"edit.colors.invert",
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) → {changed}",
has_selection,
|s, p| recolor(s, p, "Invert Colors", &|c| keep_model(c, c.invert()))
),
cmd!(
"edit.colors.toCMYK",
"Convert to CMYK",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, ids?} → {changed}",
has_selection,
|s, p| recolor(s, p, "Convert to CMYK", &to_cmyk)
),
cmd!(
"edit.colors.toGrayscale",
"Convert to Grayscale",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, ids?} → {changed}",
has_selection,
|s, p| recolor(s, p, "Convert to Grayscale", &to_gray)
),
cmd!("edit.colors.toRGB", "Convert to RGB", ["Edit", "Edit Colors"], None, "{fill?, stroke?, ids?} → {changed}", has_selection, |s, p| {
recolor(s, p, "Convert to RGB", &|c| {
let [r, g, b] = c.to_rgb();
Color::rgb(r, g, b)
})
}),
cmd!(
"edit.colors.saturate",
"Saturate…",
["Edit", "Edit Colors"],
None,
"{intensity: -100..100 (%), fill?, stroke?, ids?} scale saturation by (1 + intensity/100) → {changed}",
has_selection,
saturate
),
cmd!(
"edit.colors.adjustBalance",
"Adjust Color Balance…",
["Edit", "Edit Colors"],
None,
"{r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel), convert?: false (keep the result in the adjusted model), fill?, stroke?, ids?} → {changed}",
has_selection,
adjust_balance
),
cmd!(
"edit.colors.blendFrontToBack",
"Blend Front to Back",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects: intermediate fills graded between the frontmost and backmost fill → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Stack)
),
cmd!(
"edit.colors.blendHorizontally",
"Blend Horizontally",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects: fills graded between the leftmost and rightmost → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Horizontal)
),
cmd!(
"edit.colors.blendVertically",
"Blend Vertically",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects: fills graded between the topmost and bottommost → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Vertical)
),
]
}
/// Express `new` in the colour model of `orig`.
fn keep_model(orig: Color, new: Color) -> Color {
match orig {
Color::Rgb { .. } => {
let [r, g, b] = new.to_rgb();
Color::rgb(r, g, b)
}
Color::Cmyk { .. } => to_cmyk(new),
Color::Gray { .. } => to_gray(new),
}
}
pub(crate) fn to_cmyk(c: Color) -> Color {
let [c, m, y, k] = c.to_cmyk();
Color::cmyk(c, m, y, k)
}
pub(crate) fn to_gray(c: Color) -> Color {
if let Color::Gray { .. } = c {
return c;
}
let [r, g, b] = c.to_rgb();
Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0))
}
/// Apply `f` to a paint; returns whether anything changed.
fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
match p {
Paint::Solid { color, swatch } => {
let n = f(*color);
if n != *color {
*color = n;
*swatch = None;
return true;
}
false
}
Paint::Gradient(g) => {
let mut ch = false;
for st in &mut g.gradient.stops {
let n = f(st.color);
if n != st.color {
st.color = n;
ch = true;
}
}
if ch {
g.swatch = None;
}
ch
}
_ => false,
}
}
/// Recolour a node and its descendants. Returns the number of paints changed.
pub(crate) fn map_node_colors(n: &mut Node, f: &dyn Fn(Color) -> Color, fill: bool, stroke: bool) -> usize {
let mut count = 0;
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if fill => count += map_paint(&mut l.paint, f) as usize,
AppearanceItem::Stroke(l) if stroke => count += map_paint(&mut l.paint, f) as usize,
_ => {}
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
if fill {
count += map_paint(&mut r.style.fill, f) as usize;
}
if stroke {
count += map_paint(&mut r.style.stroke, f) as usize;
}
}
}
_ => {
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
count += map_node_colors(Arc::make_mut(c), f, fill, stroke);
}
}
}
}
count
}
fn recolor_ids(s: &mut Session, label: &str, ids: Vec<NodeId>, fill: bool, stroke: bool, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let n = s.edit(label, |d, _| {
let mut n = 0;
for id in &ids {
if let Some(node) = d.node_mut(*id) {
n += map_node_colors(node, f, fill, stroke);
}
}
Ok(n)
})?;
Ok(json!({ "changed": n }))
}
fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
recolor_ids(s, label, ids, bool_or(p, "fill", true), bool_or(p, "stroke", true), f)
}
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
let i = (f64_or(p, "intensity", 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
recolor(s, p, "Saturate", &move |c| {
let [h, sat, v] = c.to_hsb();
if sat <= 0.0 {
return c;
}
keep_model(c, Color::from_hsb(h, (sat * (1.0 + i)).clamp(0.0, 1.0), v))
})
}
fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
let convert = bool_or(p, "convert", false);
let cmyk = ["c", "m", "y", "k"].iter().any(|k| p.get(*k).is_some());
let gray = p.get("gray").is_some();
let rgb = ["r", "g", "b"].iter().any(|k| p.get(*k).is_some());
if !(cmyk || gray || rgb) {
return Err(bad("edit.colors.adjustBalance", "give r/g/b, c/m/y/k or gray adjustments"));
}
let (dr, dg, db) = (g("r"), g("g"), g("b"));
let (dc, dm, dy, dk) = (g("c"), g("m"), g("y"), g("k"));
let dgray = g("gray");
let cl = |v: f32| v.clamp(0.0, 1.0);
recolor(s, p, "Adjust Colors", &move |c| {
let out = if cmyk {
let [cc, m, y, k] = c.to_cmyk();
Color::cmyk(cl(cc + dc), cl(m + dm), cl(y + dy), cl(k + dk))
} else if gray {
let Color::Gray { k } = to_gray(c) else { unreachable!() };
Color::gray(cl(k + dgray))
} else {
let [r, gg, b] = c.to_rgb();
Color::rgb(cl(r + dr), cl(gg + dg), cl(b + db))
};
if convert { out } else { keep_model(c, out) }
})
}
enum BlendOrder {
Stack,
Horizontal,
Vertical,
}
/// Leaf objects with a solid fill among the selection (paint order).
fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
fn visit(n: &Node, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children {
visit(c, out);
}
}
NodeKind::Text(t) => {
if t.first_style().fill.color().is_some() {
out.push(n.id);
}
}
_ => {
if n.appearance.fill_paint().color().is_some() {
out.push(n.id);
}
}
}
}
let mut out = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
visit(n, &mut out);
}
}
out
}
fn fill_color(n: &Node) -> Option<Color> {
match &n.kind {
NodeKind::Text(t) => t.first_style().fill.color(),
_ => n.appearance.fill_paint().color(),
}
}
/// Interpolate within the endpoints' model when they share one.
pub(crate) fn lerp_model(a: Color, b: Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (a, b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
_ => a.lerp(&b, t),
}
}
fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let mut ids = filled_leaves(d, &roots);
if ids.len() < 3 {
return Err(EngineError::Other("Blend colors: select at least three objects with filled colours".into()));
}
let center = |id: &NodeId| d.node(*id).and_then(|n| n.geometric_bounds()).map(|b| b.center()).unwrap_or_default();
match order {
BlendOrder::Stack => {
// Front to back: frontmost first.
ids.reverse();
}
BlendOrder::Horizontal => ids.sort_by(|a, b| center(a).x.total_cmp(&center(b).x)),
BlendOrder::Vertical => ids.sort_by(|a, b| center(a).y.total_cmp(&center(b).y)),
}
let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default();
let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default();
let n = ids.len();
let label = match order {
BlendOrder::Stack => "Blend Front to Back",
BlendOrder::Horizontal => "Blend Horizontally",
BlendOrder::Vertical => "Blend Vertically",
};
let changed = s.edit(label, |d, _| {
let mut changed = 0;
for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) {
let c = lerp_model(first, last, i as f32 / (n - 1) as f32);
if let Some(node) = d.node_mut(*id) {
changed += map_node_colors(node, &|_| c, true, false);
}
}
Ok(changed)
})?;
Ok(json!({ "changed": changed }))
}
+13 -2
View File
@@ -26,7 +26,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
path_create
),
cmd!("view.drawMode", "Drawing Mode", [], Some("Shift+D"), "{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)", has_doc, draw_mode),
cmd!(
"view.drawMode",
"Drawing Mode",
[],
Some("Shift+D"),
"{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)",
has_doc,
draw_mode
),
cmd!(
"text.create",
"Create Text",
@@ -108,7 +116,10 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result<Value> {
};
if mode == crate::DrawMode::Inside {
let st = s.doc()?;
let target = st.selection.objects.first().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))));
let target =
st.selection.objects.first().copied().filter(|id| {
matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)))
});
match target {
Some(t) if st.selection.len() == 1 => s.draw_inside = Some(t),
_ => return Err(bad("view.drawMode", "Draw Inside needs exactly one selected path, compound path or text")),
+499
View File
@@ -0,0 +1,499 @@
//! Select menu long tail (Same → text/symbol attributes, Object → Direction Handles / text kinds /
//! brush strokes, Save/recall selections), View → Guides, and File → Close All / Document Color
//! Mode / File Info.
use std::collections::BTreeSet;
use std::sync::Arc;
use drawcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind};
use serde_json::{Value, json};
use super::colorcmds::{map_node_colors, to_cmyk};
use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("select.same.symbolInstance", "Symbol Instance", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same(
s,
"select.same.symbolInstance",
|a, b| match (&a.kind, &b.kind) {
(NodeKind::SymbolInstance { symbol: x, .. }, NodeKind::SymbolInstance { symbol: y, .. }) => x == y,
_ => false,
}
)),
cmd!("select.same.fontFamily", "Font Family", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.fontFamily",
|a, b| a.font_family == b.font_family
)),
cmd!("select.same.fontFamilyStyle", "Font Family & Style", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.fontFamilyStyle",
|a, b| a.font_family == b.font_family && a.font_style == b.font_style
)),
cmd!("select.same.fontFamilyStyleSize", "Font Family, Style & Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| {
same_text(s, "select.same.fontFamilyStyleSize", |a, b| {
a.font_family == b.font_family && a.font_style == b.font_style && (a.size - b.size).abs() < 1e-6
})
}),
cmd!("select.same.fontSize", "Font Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.fontSize",
|a, b| (a.size - b.size).abs() < 1e-6
)),
cmd!("select.same.textFillColor", "Text Fill Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.textFillColor",
|a, b| a.fill == b.fill
)),
cmd!("select.same.textStrokeColor", "Text Stroke Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.textStrokeColor",
|a, b| a.stroke == b.stroke
)),
cmd!(
"select.object.directionHandles",
"Direction Handles",
["Select", "Object"],
None,
"{} direct-select every anchor (showing all handles) of the selected paths → {anchors}",
has_selection,
direction_handles
),
cmd!("select.object.pointText", "Point Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(
s,
|n| matches!(
&n.kind,
NodeKind::Text(t) if matches!(t.kind, TextKind::Point)
)
)),
cmd!("select.object.areaText", "Area Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| matches!(
&n.kind,
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. })
))),
cmd!("select.object.brushStrokes", "Brush Strokes", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| n
.appearance
.stroke()
.is_some_and(|st| st.brush.is_some()))),
cmd!(
"select.object.bristleBrushStrokes",
"Bristle Brush Strokes",
["Select", "Object"],
None,
"{} objects whose brush name contains \"bristle\" → {count}",
has_doc,
|s, _| by_kind(s, |n| n
.appearance
.stroke()
.and_then(|st| st.brush.as_deref())
.is_some_and(|b| b.to_ascii_lowercase().contains("bristle")))
),
cmd!(
"select.save",
"Save Selection…",
["Select"],
None,
"{name?} remember the current selection for this session (default name \"Selection N\") → {name}",
has_selection,
save_selection
),
cmd!("select.recall", "Recall Selection", ["Select"], None, "{name} → {count}", has_doc, recall_selection),
cmd!("select.editSaved", "Edit Selection…", ["Select"], None, "{name, newName?: rename, delete?: bool}", has_doc, edit_saved),
cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| {
let t = s.doc()?.title();
Ok(json!(s.menu.saved_selections.iter().filter(|x| x.0 == t).map(|x| x.1.clone()).collect::<Vec<_>>()))
}),
cmd!(
"view.guides.make",
"Make Guides",
["View", "Guides"],
Some("Cmd+5"),
"{} turn the selected paths into guides → {count}",
has_selection,
make_guides
),
cmd!(
"view.guides.release",
"Release Guides",
["View", "Guides"],
Some("Cmd+Alt+5"),
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
has_doc,
release_guides
),
cmd!(
query "view.guides.lock",
"Lock Guides",
["View", "Guides"],
Some("Cmd+Alt+;"),
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
has_doc,
lock_guides
),
cmd!(
"view.guides.clear",
"Clear Guides",
["View", "Guides"],
None,
"{} delete all ruler guides and guide paths → {count}",
has_doc,
clear_guides
),
cmd!("guide.add", "Add Guide", [], None, "{vertical: bool, pos: pt (x for vertical, y for horizontal)} → {index}", has_doc, guide_add),
cmd!("guide.remove", "Remove Guide", [], None, "{index}", guides_unlocked, guide_remove),
cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", guides_unlocked, guide_move),
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
cmd!(
"file.documentColorMode",
"Document Color Mode",
["File", "Document Color Mode"],
None,
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour and swatch to the mode)}",
has_doc,
color_mode
),
cmd!(
"file.info",
"File Info…",
["File"],
Some("Cmd+Alt+Shift+I"),
"{title?} set the document title; always → {title, colorMode, units, artboards, objects}",
has_doc,
file_info
),
]
}
impl Session {
/// View → Guides → Lock Guides state (canvas guide dragging should honour it).
pub fn guides_locked(&self) -> bool {
self.menu.guides_locked
}
}
// ---------- Select ----------
fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if !n.is_container() && n.visible && !n.locked && f(n) {
ids.push(n.id);
}
});
}
Ok(ids)
}
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
let n = ids.len();
s.select(|_, sel| sel.set(ids))?;
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({})));
Ok(json!({ "count": n }))
}
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let r = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
let ids = candidates(s, |n| eq(n, &r))?;
finish_same(s, cmd, ids)
}
fn first_text_style(s: &Session) -> Option<CharStyle> {
let st = s.active()?;
let mut out = None;
for id in &st.selection.objects {
st.doc.node(*id)?.walk(&mut |n| {
if out.is_none()
&& let NodeKind::Text(t) = &n.kind
{
out = Some(t.first_style());
}
});
if out.is_some() {
break;
}
}
out
}
fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool) -> Result<Value> {
let r = first_text_style(s).ok_or_else(|| bad(cmd, "select a text object"))?;
let ids = candidates(s, |n| match &n.kind {
NodeKind::Text(t) => t.runs.iter().any(|run| eq(&run.style, &r)),
_ => false,
})?;
finish_same(s, cmd, ids)
}
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
let ids = candidates(s, f)?;
let n = ids.len();
s.select(|_, sel| sel.set(ids))?;
Ok(json!({ "count": n }))
}
fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let mut items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = vec![];
for r in &roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
items.push((c.id, p.anchors().map(|(si, ai, _)| (si, ai)).collect()));
}
});
}
}
let total: usize = items.iter().map(|x| x.1.len()).sum();
s.select(|_, sel| {
sel.clear();
for (id, refs) in items {
sel.add(id);
sel.anchors.insert(id, refs);
}
})?;
Ok(json!({ "anchors": total }))
}
fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let title = st.title();
let ids = st.selection.objects.clone();
let name = match str_param(p, "name").filter(|n| !n.trim().is_empty()) {
Some(n) => n.trim().to_string(),
None => {
let mut i = 1;
while s.menu.saved_selections.iter().any(|x| x.0 == title && x.1 == format!("Selection {i}")) {
i += 1;
}
format!("Selection {i}")
}
};
s.menu.saved_selections.retain(|x| !(x.0 == title && x.1 == name));
s.menu.saved_selections.push((title, name.clone(), ids));
Ok(json!({ "name": name }))
}
fn saved_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
let t = s.doc()?.title();
s.menu.saved_selections.iter().position(|x| x.0 == t && x.1 == name).ok_or_else(|| bad(cmd, format!("no saved selection `{name}`")))
}
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
let i = saved_index(s, p, "select.recall")?;
let ids = s.menu.saved_selections[i].2.clone();
s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?;
Ok(json!({ "count": s.doc()?.selection.len() }))
}
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
let i = saved_index(s, p, "select.editSaved")?;
if bool_or(p, "delete", false) {
s.menu.saved_selections.remove(i);
} else if let Some(n) = str_param(p, "newName").filter(|n| !n.trim().is_empty()) {
s.menu.saved_selections[i].1 = n.trim().to_string();
}
ok()
}
// ---------- Guides ----------
fn guides_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.menu.guides_locked { Err("guides are locked".into()) } else { Ok(()) }
}
fn set_guide_flag(n: &mut Node, on: bool, count: &mut usize) {
match &mut n.kind {
NodeKind::Path { guide, clipping: false, .. } => {
if *guide != on {
*guide = on;
*count += 1;
}
}
_ => {
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
set_guide_flag(Arc::make_mut(c), on, count);
}
}
}
}
}
fn make_guides(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let n = s.edit("Make Guides", |d, sel| {
let mut n = 0;
for r in &roots {
if let Some(node) = d.node_mut(*r) {
set_guide_flag(node, true, &mut n);
}
}
if n == 0 {
return Err(EngineError::Other("Make Guides: select paths".into()));
}
sel.clear();
Ok(n)
})?;
Ok(json!({ "count": n }))
}
fn guide_paths(d: &drawcraft_doc::Document) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if matches!(n.kind, NodeKind::Path { guide: true, .. }) {
v.push(n.id);
}
});
v
}
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
if targets.is_empty() {
return Err(EngineError::Other("Release Guides: no guides".into()));
}
let n = targets.len();
s.edit("Release Guides", |d, sel| {
for id in &targets {
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*guide = false;
}
}
sel.set(targets.iter().copied());
Ok(())
})?;
Ok(json!({ "count": n }))
}
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
s.menu.guides_locked = v;
Ok(json!({ "locked": v }))
}
fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let paths = guide_paths(&st.doc);
let n = paths.len() + st.doc.guides.len();
if n == 0 {
return Ok(json!({ "count": 0 }));
}
s.edit("Clear Guides", |d, _| {
d.guides.clear();
for id in &paths {
let _ = d.remove(*id);
}
Ok(())
})?;
Ok(json!({ "count": n }))
}
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
let pos = f64_req(p, "pos", "guide.add")?;
let vertical = bool_or(p, "vertical", false);
let i = s.edit("New Guide", |d, _| {
d.guides.push(Guide { vertical, pos });
Ok(d.guides.len() - 1)
})?;
Ok(json!({ "index": i }))
}
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize;
if i >= s.doc()?.doc.guides.len() {
return Err(bad(cmd, "no such guide"));
}
Ok(i)
}
fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.remove")?;
s.edit("Delete Guide", |d, _| {
d.guides.remove(i);
Ok(())
})?;
ok()
}
fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.move")?;
let pos = f64_req(p, "pos", "guide.move")?;
s.edit("Move Guide", |d, _| {
d.guides[i].pos = pos;
Ok(())
})?;
ok()
}
// ---------- File ----------
fn close_all(s: &mut Session, _: &Value) -> Result<Value> {
let n = s.documents().len();
while !s.documents().is_empty() {
s.close_document(s.documents().len() - 1);
}
Ok(json!({ "closed": n }))
}
fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.documentColorMode";
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
Some("cmyk") => ColorMode::Cmyk,
Some("rgb") => ColorMode::Rgb,
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
};
let convert = bool_or(p, "convert", true);
if s.doc()?.doc.color_mode == mode {
return ok();
}
let f = move |c: drawcraft_color::Color| match mode {
ColorMode::Cmyk => to_cmyk(c),
ColorMode::Rgb => {
let [r, g, b] = c.to_rgb();
drawcraft_color::Color::rgb(r, g, b)
}
};
s.edit("Document Color Mode", |d, _| {
d.color_mode = mode;
if convert {
for l in &mut d.layers {
map_node_colors(Arc::make_mut(l), &f, true, true);
}
for sw in &mut d.swatches {
if let drawcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
*color = f(*color);
}
}
}
Ok(())
})?;
ok()
}
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(t) = str_param(p, "title") {
let t = t.to_string();
s.edit("File Info", |d, _| {
d.title = t;
Ok(())
})?;
}
let d = &s.doc()?.doc;
Ok(json!({
"title": d.title,
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
"units": d.units.label(),
"artboards": d.artboards.len(),
"objects": d.node_count(),
}))
}
+11 -1
View File
@@ -146,7 +146,17 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let sel = st.selection.in_paint_order(&st.doc);
// A selected compound-path member acts as its compound (Illustrator treats compounds as one object).
let mut sel: Vec<NodeId> = st
.selection
.in_paint_order(&st.doc)
.into_iter()
.map(|id| match st.doc.parent_of(id).and_then(|p| st.doc.node(p).map(|n| (p, n))) {
Some((p, n)) if matches!(n.kind, drawcraft_doc::NodeKind::Compound { .. }) => p,
_ => id,
})
.collect();
sel.dedup();
Ok(sel
.iter()
.copied()
+3
View File
@@ -112,6 +112,8 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
s.begin_interaction(&label)?;
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
let mut results = vec![];
for c in &cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
@@ -124,6 +126,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(v) => results.push(v),
Err(e) => {
s.cancel_interaction()?;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
}
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1,17 +1,22 @@
//! The command registry. Ids follow Illustrator's menu structure.
mod colorcmds;
mod create;
mod docmenu;
mod draw2;
mod edit;
mod effectcmd;
mod file;
mod layer;
pub(crate) mod menucmds;
mod object;
mod paint;
mod panelcmds;
mod path;
mod pathops;
mod select;
mod typecmd;
mod typemenu;
mod xform;
use drawcraft_color::Color;
@@ -123,6 +128,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(effectcmd::specs());
v.extend(pathops::specs());
v.extend(typecmd::specs());
v.extend(menucmds::specs());
v.extend(colorcmds::specs());
v.extend(typemenu::specs());
v.extend(docmenu::specs());
v.extend(panelcmds::specs());
v
})
}
+25 -11
View File
@@ -269,17 +269,31 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
Arrange::Back => "Send to Back",
};
s.edit(label, |d, _| {
let n = ids.len();
for (k, id) in ids.iter().enumerate() {
let Some((par, idx, len)) = d.position(*id) else { continue };
let to = match how {
Arrange::Front => len - 1 - k,
Arrange::Back => k,
Arrange::Forward => (idx + 1).min(len - 1 - k.min(len - 1)),
Arrange::Backward => idx.saturating_sub(1).max(k.min(n)),
};
if to != idx {
d.move_node(*id, par, to)?;
// Arrange within each parent independently (selections may span layers/groups).
let mut by_parent: Vec<(Option<drawcraft_doc::NodeId>, Vec<drawcraft_doc::NodeId>)> = vec![];
for id in &ids {
let par = d.parent_of(*id);
match by_parent.iter_mut().find(|(p, _)| *p == par) {
Some((_, v)) => v.push(*id),
None => by_parent.push((par, vec![*id])),
}
}
for (par, group) in by_parent {
for (k, id) in group.iter().enumerate() {
let Some((_, idx, len)) = d.position(*id) else { continue };
if len == 0 {
continue;
}
let last = len - 1;
let to = match how {
Arrange::Front => last.saturating_sub(k),
Arrange::Back => k.min(last),
Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx),
Arrange::Backward => idx.saturating_sub(1).max(k.min(idx)),
};
if to != idx {
d.move_node(*id, par, to)?;
}
}
}
Ok(())
+552
View File
@@ -0,0 +1,552 @@
//! Commands the panels need beyond the core set: in-place gradient editing (Gradient panel),
//! appearance item duplicate/reorder, artboard reorder/duplicate, swatch groups, graphic style
//! management and the advanced Character/Paragraph attributes.
use drawcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint, SwatchGroup};
use drawcraft_doc::{Document, NodeKind};
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"paint.editGradient",
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
cmd!(
"stroke.setAdvanced",
"Stroke Options",
[],
None,
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras",
has_doc,
stroke_advanced
),
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item),
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item),
cmd!("artboard.reorder", "Move Artboard Up/Down", [], None, "{index, to} change an artboard's number", has_doc, artboard_reorder),
cmd!(
"artboard.duplicate",
"Duplicate Artboard",
["Window", "Artboards"],
None,
"{index} copy placed right of the last artboard",
has_doc,
artboard_duplicate
),
cmd!(
"swatch.newGroup",
"New Color Group",
["Window", "Swatches"],
None,
"{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}",
has_doc,
swatch_new_group
),
cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate),
cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort),
cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete),
cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate),
cmd!(
"text.setFormat",
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
has_doc,
set_format
),
]
}
// ---------- gradient ----------
/// Leaf paint targets (groups apply to their children), like the Swatches/Color panels.
fn leaf_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
let mut out = vec![];
for id in ids {
let Some(n) = d.node(id) else { continue };
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| {
if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) {
out.push(c.id)
}
}),
_ => out.push(id),
}
}
let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect();
out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c)));
Ok(out)
}
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<drawcraft_geom::Rect>) -> std::result::Result<Paint, String> {
let mut gp = match paint {
Paint::Gradient(g) => (**g).clone(),
_ => GradientPaint::new(Gradient::default()),
};
if let Some(k) = str_param(p, "kind") {
let kind = match k.to_ascii_lowercase().as_str() {
"linear" => GradientKind::Linear,
"radial" => GradientKind::Radial,
"freeform" => GradientKind::Freeform,
_ => return Err(format!("unknown gradient kind `{k}`")),
};
if kind != gp.gradient.kind {
gp.gradient.kind = kind;
gp.geom = None;
}
}
if let Some(stops) = p.get("stops") {
let arr = stops.as_array().ok_or("`stops` must be an array")?;
let mut out = Vec::with_capacity(arr.len());
for st in arr {
let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?;
let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?;
let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5);
out.push(GradientStop {
offset: offset.clamp(0.0, 1.0) as f32,
color,
opacity: opacity.clamp(0.0, 1.0) as f32,
midpoint: midpoint.clamp(0.13, 0.87) as f32,
});
}
if out.len() < 2 {
return Err("a gradient needs at least two stops".into());
}
gp.gradient.stops = out;
gp.gradient.sort();
gp.swatch = None;
}
if bool_or(p, "reverse", false) {
gp.gradient.reverse();
// Midpoints belong to the segment to the right; mirror them.
let n = gp.gradient.stops.len();
let mids: Vec<f32> = gp.gradient.stops.iter().map(|s| s.midpoint).collect();
for i in 0..n {
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
}
}
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
let a = ((a + 180.0).rem_euclid(360.0)) - 180.0;
gp.angle = a;
if let Some(g) = &mut gp.geom {
let r = a.to_radians();
let dir = drawcraft_geom::Vec2::new(r.cos(), -r.sin());
if gp.gradient.kind == GradientKind::Radial {
g.end = g.start + dir * g.length();
} else {
let c = g.start.midpoint(g.end);
let half = g.length() / 2.0;
g.start = c - dir * half;
g.end = c + dir * half;
}
}
}
if let Some(asp) = p.get("aspect").and_then(Value::as_f64) {
let asp = (asp / 100.0).clamp(0.005, 327.67);
if gp.geom.is_none()
&& let Some(b) = bounds
{
gp.geom = Some(gp.resolve(b));
}
if let Some(g) = &mut gp.geom {
g.aspect = asp;
}
}
Ok(Paint::Gradient(Box::new(gp)))
}
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.editGradient";
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let ids = leaf_targets(s, p)?;
// Validate against the defaults first so bad params fail without touching the document.
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
if ids.is_empty() {
if stroke {
s.paint.stroke = new_default;
} else {
s.paint.fill = new_default;
}
return Ok(json!({"ids": []}));
}
let mut err = None;
s.edit("Gradient", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill };
match apply_gradient_edit(cur, p, None) {
Ok(np) => *cur = np,
Err(e) => err = Some(e),
}
}
continue;
}
let b = n.geometric_bounds();
let ap = &mut n.appearance;
let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() };
match apply_gradient_edit(&cur, p, b) {
Ok(np) => {
if stroke {
ap.set_stroke(np)
} else {
ap.set_fill(np)
}
}
Err(e) => err = Some(e),
}
}
match err.take() {
Some(e) => Err(bad(C, e)),
None => Ok(()),
}
})?;
if stroke {
s.paint.stroke = new_default;
} else {
s.paint.fill = new_default;
}
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
// ---------- appearance ----------
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize;
let ids = leaf_targets(s, p)?;
s.edit("Duplicate Item", |d, _| {
let mut any = false;
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if let Some(item) = n.appearance.items.get(idx).cloned() {
n.appearance.items.insert(idx + 1, item);
any = true;
}
}
if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) }
})?;
ok()
}
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
let ids = leaf_targets(s, p)?;
s.edit("Reorder Appearance", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let items = &mut n.appearance.items;
if from >= items.len() {
return Err(bad(C, format!("no item at index {from}")));
}
let it = items.remove(from);
let to = to.min(items.len());
items.insert(to, it);
}
Ok(())
})?;
ok()
}
// ---------- artboards ----------
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
}
let a = d.artboards.remove(i);
let to = to.min(d.artboards.len());
d.artboards.insert(to, a);
Ok(())
})?;
ok()
}
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = s.edit("Duplicate Artboard", |d, _| {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
let mut a = src.clone();
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1;
a.name = format!("{} copy", src.name);
let dx = right + 20.0 - src.rect.x0;
a.rect = drawcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1);
d.artboards.push(a);
Ok(d.artboards.len() - 1)
})?;
Ok(json!({"index": index}))
}
// ---------- swatches ----------
fn unique_name(d: &Document, base: &str) -> String {
let exists = |n: &str| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n);
if !exists(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !exists(n)).unwrap_or_else(|| base.to_string())
}
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
let names: Vec<String> =
p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
let colors: Vec<drawcraft_color::Color> =
p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default();
let requested = str_param(p, "name").map(str::to_string);
let name = s.edit("New Color Group", |d, _| {
let name = unique_name(d, requested.as_deref().unwrap_or("Color Group"));
let mut group = SwatchGroup { name: name.clone(), swatches: vec![] };
for n in &names {
if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) {
group.swatches.push(d.swatches.remove(pos));
}
}
for c in &colors {
let [r, g, b] = c.to_rgb();
let nm = format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round());
group.swatches.push(drawcraft_color::Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false });
}
d.swatch_groups.push(group);
Ok(name)
})?;
Ok(json!({"name": name}))
}
fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string();
let new = s.edit("Duplicate Swatch", |d, _| {
let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let nm = unique_name(d, &format!("{name} copy"));
let copy = drawcraft_color::Swatch { name: nm.clone(), ..src };
if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) {
d.swatches.insert(pos + 1, copy);
} else if let Some(g) = d.swatch_groups.iter_mut().find(|g| g.swatches.iter().any(|sw| sw.name == name)) {
let pos = g.swatches.iter().position(|sw| sw.name == name).unwrap_or(0);
g.swatches.insert(pos + 1, copy);
}
Ok(nm)
})?;
Ok(json!({"name": new}))
}
fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Sort Swatches", |d, _| {
// [None] and other bracketed specials stay first, like Illustrator.
let key = |sw: &drawcraft_color::Swatch| (!sw.name.starts_with('['), sw.name.to_lowercase());
d.swatches.sort_by_key(key);
for g in &mut d.swatch_groups {
g.swatches.sort_by_key(key);
}
Ok(())
})?;
ok()
}
// ---------- graphic styles ----------
fn style_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string();
s.edit("Delete Graphic Style", |d, _| {
let before = d.graphic_styles.len();
d.graphic_styles.retain(|g| g.name != name);
if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) }
})?;
ok()
}
fn style_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string();
let new = s.edit("Duplicate Graphic Style", |d, _| {
let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let mut g = d.graphic_styles[pos].clone();
let base = format!("{name} copy");
let mut nm = base.clone();
let mut i = 2;
while d.graphic_styles.iter().any(|x| x.name == nm) {
nm = format!("{base} {i}");
i += 1;
}
g.name = nm.clone();
d.graphic_styles.insert(pos + 1, g);
Ok(nm)
})?;
Ok(json!({"name": new}))
}
// ---------- text ----------
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
};
if ids.is_empty() {
return Err(bad(C, "no text objects selected"));
}
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let kerning = match p.get("kerning") {
None | Some(Value::Null) => None,
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(C, "kerning must be a number or \"auto\""))?.clamp(-1000.0, 10000.0))),
};
let keys = [
"kerning",
"baselineShift",
"hScale",
"vScale",
"rotation",
"underline",
"strikethrough",
"allCaps",
"leftIndent",
"rightIndent",
"firstLineIndent",
"spaceBefore",
"spaceAfter",
"hyphenate",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Err(bad(C, "nothing to change"));
}
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
for r in &mut t.runs {
let st = &mut r.style;
if let Some(k) = kerning {
st.kerning = k;
}
if let Some(v) = num("baselineShift") {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
}
if let Some(v) = num("hScale") {
st.h_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = num("vScale") {
st.v_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = num("rotation") {
st.rotation = ((v + 180.0).rem_euclid(360.0)) - 180.0;
}
if let Some(v) = flag("underline") {
st.underline = v;
}
if let Some(v) = flag("strikethrough") {
st.strikethrough = v;
}
if let Some(v) = flag("allCaps") {
st.all_caps = v;
}
}
let para = &mut t.para;
if let Some(v) = num("leftIndent") {
para.left_indent = v;
}
if let Some(v) = num("rightIndent") {
para.right_indent = v;
}
if let Some(v) = num("firstLineIndent") {
para.first_line_indent = v;
}
if let Some(v) = num("spaceBefore") {
para.space_before = v;
}
if let Some(v) = num("spaceAfter") {
para.space_after = v;
}
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
super::typecmd::refresh_bounds(t);
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- stroke extras ----------
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
pub(crate) fn flip_profile(p: &drawcraft_doc::WidthProfile, along: bool) -> drawcraft_doc::WidthProfile {
let mut pts: Vec<(f64, f64, f64)> =
if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() };
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
drawcraft_doc::WidthProfile { points: pts }
}
fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.setAdvanced";
let scale = match p.get("arrowScale") {
None => None,
Some(v) => {
let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(C, "arrowScale must be [start, end]"))?;
let f = |i: usize| a[i].as_f64().map(|x| x.clamp(1.0, 1000.0));
Some((f(0).ok_or_else(|| bad(C, "bad arrowScale"))?, f(1).ok_or_else(|| bad(C, "bad arrowScale"))?))
}
};
let flip = match str_param(p, "flipProfile") {
None => None,
Some("along") => Some(true),
Some("across") => Some(false),
Some(o) => return Err(bad(C, format!("flipProfile must be along|across, got {o}"))),
};
let swap = bool_or(p, "swapArrows", false);
let brush = match p.get("brush") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(b)) => Some(Some(b.clone())),
Some(_) => return Err(bad(C, "brush must be a name or null")),
};
if scale.is_none() && flip.is_none() && !swap && brush.is_none() {
return Err(bad(C, "nothing to change"));
}
let ids = leaf_targets(s, p)?;
if ids.is_empty() {
return ok();
}
s.edit("Stroke", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let Some(st) = n.appearance.stroke_mut() else { continue };
if let Some(sc) = scale {
st.arrow_scale = sc;
}
if swap {
std::mem::swap(&mut st.start_arrow, &mut st.end_arrow);
st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0);
}
if let (Some(along), Some(pr)) = (flip, &st.profile) {
st.profile = Some(flip_profile(pr, along));
}
if let Some(b) = &brush {
st.brush = b.clone();
}
}
Ok(())
})?;
ok()
}
+603
View File
@@ -0,0 +1,603 @@
//! Type menu long tail (Change Case, Smart Punctuation, Convert To Area/Point Type, Type on a Path
//! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
use drawcraft_doc::{CharStyle, Document, NodeId, NodeKind, TextKind, TextRun};
use drawcraft_geom::{Affine, Rect, shapes};
use serde_json::{Value, json};
use super::typecmd::refresh_bounds;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"type.changeCase",
"Change Case",
["Type", "Change Case"],
None,
"{case: \"upper\"|\"lower\"|\"title\"|\"sentence\", ids?} → {ids}",
has_selection,
change_case
),
cmd!(
"type.smartPunctuation",
"Smart Punctuation…",
["Type"],
None,
"{quotes?: true (\"\" '' → curly), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}",
has_doc,
smart_punctuation
),
cmd!(
"type.convertToAreaType",
"Convert To Area Type",
["Type"],
None,
"{ids?} point type → area type framed by its current layout bounds → {ids}",
has_selection,
to_area
),
cmd!(
"type.convertToPointType",
"Convert To Point Type",
["Type"],
None,
"{ids?} area type → point type; soft line wraps become line breaks → {ids}",
has_selection,
to_point
),
cmd!(
"type.pathOptions",
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), ids?}",
has_selection,
path_options
),
cmd!(
"type.fillPlaceholder",
"Fill with Placeholder Text",
["Type"],
None,
"{ids?} replace selected text with placeholder text (area type is filled to its frame) → {ids}",
has_selection,
fill_placeholder
),
cmd!(
"type.insert",
"Insert Character",
["Type"],
None,
"{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string} append to the selected text objects",
has_selection,
insert_char
),
cmd!(
"edit.findReplace",
"Find and Replace…",
["Edit"],
None,
"{find, replace?: \"\", matchCase?: false, wholeWord?: false, ids?} replace in every text object (or ids) → {count}. Matches never span style runs.",
has_doc,
find_replace
),
cmd!(
"edit.findNext",
"Find Next",
["Edit"],
None,
"{find, matchCase?, wholeWord?} select the next text object (after the selected one, wrapping) containing `find` → {id|null}",
has_doc,
find_next
),
cmd!(
"edit.pasteWithoutFormatting",
"Paste without Formatting",
["Edit"],
Some("Cmd+Alt+V"),
"{dx?, dy?} paste; pasted text takes the default character style (one run)",
has_clipboard,
paste_plain
),
]
}
/// Text objects among `ids` and their descendants.
pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for id in ids {
if let Some(n) = d.node(*id) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
out.push(c.id);
}
});
}
}
out
}
fn texts(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = targets(s, p)?;
let t = text_ids(&s.doc()?.doc, &ids);
if t.is_empty() {
return Err(bad(cmd, "no text objects selected"));
}
Ok(t)
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// Rewrite every run of the given text objects with `f(run text, state)`.
fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl Fn(&str, &mut S) -> String) -> Result<usize> {
s.edit(label, |d, _| {
let mut changed = 0;
for id in ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let mut st = S::default();
let mut any = false;
for r in &mut t.runs {
let n = f(&r.text, &mut st);
if n != r.text {
r.text = n;
any = true;
}
}
if any {
changed += 1;
refresh_bounds(t);
}
}
Ok(changed)
})
}
// ---------- Change Case ----------
#[derive(Default)]
struct CaseState {
/// Previous character (across runs).
prev: Option<char>,
/// Sentence start pending (sentence case).
sentence: bool,
started: bool,
}
fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.changeCase";
let case = str_param(p, "case").ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase();
if !["upper", "uppercase", "lower", "lowercase", "title", "sentence"].contains(&case.as_str()) {
return Err(bad(C, "case must be upper|lower|title|sentence"));
}
let ids = texts(s, p, C)?;
edit_runs::<CaseState>(s, "Change Case", &ids, |text, st| {
if !st.started {
st.started = true;
st.sentence = true;
}
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match case.as_str() {
"upper" | "uppercase" => out.extend(c.to_uppercase()),
"lower" | "lowercase" => out.extend(c.to_lowercase()),
"title" => {
if st.prev.is_none_or(|p| p.is_whitespace() || matches!(p, '-' | '(' | '"' | '“' | '/')) {
out.extend(c.to_uppercase())
} else {
out.extend(c.to_lowercase())
}
}
_ => {
if c.is_alphabetic() && st.sentence {
out.extend(c.to_uppercase());
st.sentence = false;
} else {
out.extend(c.to_lowercase());
}
if matches!(c, '.' | '!' | '?' | '\n') {
st.sentence = true;
}
}
}
st.prev = Some(c);
}
out
})?;
Ok(ids_json(&ids))
}
// ---------- Smart Punctuation ----------
#[derive(Default)]
struct PunctState {
prev: Option<char>,
}
fn opens(prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–' | '“' | '‘'))
}
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: bool, dashes: bool, ellipsis: bool) -> String {
let mut s = text.to_string();
if dashes {
s = s.replace("---", "—").replace("--", "–");
}
if ellipsis {
s = s.replace("...", "…");
}
if !quotes {
*prev = s.chars().last().or(*prev);
return s;
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
let r = match c {
'"' => {
if opens(*prev) {
'“'
} else {
'”'
}
}
'\'' => {
if opens(*prev) {
'‘'
} else {
'’'
}
}
c => c,
};
out.push(r);
*prev = Some(r);
}
out
}
fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
let (q, d, e) = (bool_or(p, "quotes", true), bool_or(p, "dashes", true), bool_or(p, "ellipsis", true));
let st = s.doc()?;
let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() {
let mut v = vec![];
st.doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
v.push(n.id)
}
});
v
} else {
text_ids(&st.doc, &st.selection.objects)
};
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q, d, e))?;
Ok(json!({ "changed": n }))
}
// ---------- Area / Point conversion ----------
fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, "type.convertToAreaType")?;
s.edit("Convert To Area Type", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
if !matches!(t.kind, TextKind::Point) {
continue;
}
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
let b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
let size = t.first_style().size;
// A little slack on the right so the text doesn't rewrap.
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
let frame = Rect::new(b.x0, top, b.x1 + size * 0.5, b.y1.max(top + 1.0));
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
refresh_bounds(t);
}
Ok(())
})?;
Ok(ids_json(&ids))
}
/// (run, byte within the run) for a byte offset into the plain text.
fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> {
let mut off = 0;
for (i, r) in runs.iter().enumerate() {
if byte < off + r.text.len() {
return Some((i, byte - off));
}
off += r.text.len();
}
None
}
fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, "type.convertToPointType")?;
s.edit("Convert To Point Type", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let TextKind::Area { frame } = &t.kind else { continue };
let fb = frame.bounds().unwrap_or_default();
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
let plain = t.plain_text();
let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8);
// Soft wraps → explicit breaks (replace the space before the wrap when there is one).
let mut breaks: Vec<usize> =
lay.lines.iter().skip(1).map(|l| l.start).filter(|&b| b > 0 && plain.as_bytes().get(b - 1) != Some(&b'\n')).collect();
breaks.sort_unstable();
breaks.dedup();
for b in breaks.into_iter().rev() {
if plain.as_bytes().get(b - 1) == Some(&b' ') {
if let Some((ri, bi)) = locate(&t.runs, b - 1) {
t.runs[ri].text.replace_range(bi..bi + 1, "\n");
}
} else if let Some((ri, bi)) = locate(&t.runs, b) {
t.runs[ri].text.insert(bi, '\n');
}
}
t.kind = TextKind::Point;
t.xf *= Affine::translate((fb.x0, baseline));
refresh_bounds(t);
}
Ok(())
})?;
Ok(ids_json(&ids))
}
fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.pathOptions";
let ids = texts(s, p, C)?;
let start = p.get("start").and_then(Value::as_f64);
let flip = bool_or(p, "flip", false);
let n = s.edit("Type on a Path Options", |d, _| {
let mut n = 0;
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let TextKind::OnPath { path, start: st } = &mut t.kind else { continue };
if let Some(v) = start {
*st = v.clamp(0.0, 1.0);
}
if flip {
path.reverse();
}
n += 1;
refresh_bounds(t);
}
if n == 0 {
return Err(bad(C, "select type on a path"));
}
Ok(n)
})?;
Ok(json!({ "changed": n }))
}
// ---------- placeholder / insert ----------
/// DrawCraft's own placeholder copy (not the classical Latin text).
pub(crate) const PLACEHOLDER: &str = "Sample copy flows here while the layout takes shape. Swap these words for real text once the \
design is settled. Every line is only a stand-in that shows size, rhythm and colour. Headlines, captions and body text all \
start as rough drafts like this one. Keep going until the frame is full and the page feels balanced.";
fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, "type.fillPlaceholder")?;
s.edit("Fill with Placeholder Text", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let style = t.first_style();
let text = match &t.kind {
TextKind::Area { frame } => {
let b = frame.bounds().unwrap_or_default();
let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0);
let lines = (b.height() / style.effective_leading()).max(1.0);
let want = (chars_per_line * lines) as usize;
let mut out = String::new();
let words: Vec<&str> = PLACEHOLDER.split(' ').collect();
let mut i = 0;
while out.len() < want && i < 10_000 {
if !out.is_empty() {
out.push(' ');
}
out.push_str(words[i % words.len()]);
i += 1;
}
out
}
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
};
t.runs = vec![TextRun { text, style }];
refresh_bounds(t);
}
Ok(())
})?;
Ok(ids_json(&ids))
}
pub(crate) fn special_char(name: &str) -> Option<&'static str> {
Some(match name {
"bullet" => "•",
"copyright" => "©",
"ellipsis" => "…",
"paragraph" => "",
"registered" => "®",
"section" => "§",
"trademark" => "™",
"emDash" => "—",
"enDash" => "–",
"discretionaryHyphen" => "\u{00AD}",
"nonBreakingHyphen" => "\u{2011}",
"doubleLeftQuote" => "“",
"doubleRightQuote" => "”",
"singleLeftQuote" => "‘",
"singleRightQuote" => "’",
"emSpace" => "\u{2003}",
"enSpace" => "\u{2002}",
"hairSpace" => "\u{200A}",
"sixthSpace" => "\u{2006}",
"thinSpace" => "\u{2009}",
"nonBreakingSpace" => "\u{00A0}",
"figureSpace" => "\u{2007}",
"punctuationSpace" => "\u{2008}",
"thirdSpace" => "\u{2004}",
"quarterSpace" => "\u{2005}",
"tab" => "\t",
// Our text model breaks lines at paragraph returns only.
"forcedLineBreak" | "paragraphReturn" => "\n",
_ => return None,
})
}
fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.insert";
let text = match (str_param(p, "char"), str_param(p, "text")) {
(Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(),
(None, Some(t)) => t.to_string(),
_ => return Err(bad(C, "give `char` or `text`")),
};
let ids = texts(s, p, C)?;
s.edit("Typing", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
match t.runs.last_mut() {
Some(r) => r.text.push_str(&text),
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }),
}
refresh_bounds(t);
}
Ok(())
})?;
Ok(ids_json(&ids))
}
// ---------- Find and Replace ----------
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Byte ranges of `needle` in `hay`.
pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
let nc: Vec<char> = needle.chars().collect();
if nc.is_empty() {
return vec![];
}
let hc: Vec<(usize, char)> = hay.char_indices().collect();
let eq = |a: char, b: char| if match_case { a == b } else { a == b || a.to_lowercase().eq(b.to_lowercase()) };
let mut out = vec![];
let mut i = 0;
while i + nc.len() <= hc.len() {
if (0..nc.len()).all(|k| eq(hc[i + k].1, nc[k])) {
let before = i.checked_sub(1).map(|j| hc[j].1);
let after = hc.get(i + nc.len()).map(|x| x.1);
if !whole_word || (!before.is_some_and(is_word) && !after.is_some_and(is_word)) {
let start = hc[i].0;
let end = hc.get(i + nc.len()).map(|x| x.0).unwrap_or(hay.len());
out.push((start, end));
i += nc.len();
continue;
}
}
i += 1;
}
out
}
fn all_texts(d: &Document) -> Vec<NodeId> {
let mut v = vec![];
for l in d.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked {
v.push(n.id);
}
});
}
v
}
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.findReplace";
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
let rep = str_param(p, "replace").unwrap_or("").to_string();
let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false));
let ids = match ids_param(p, "ids") {
Some(v) => text_ids(&s.doc()?.doc, &v),
None => all_texts(&s.doc()?.doc),
};
let st = s.doc()?;
let total: usize = ids
.iter()
.filter_map(|id| match &st.doc.node(*id)?.kind {
NodeKind::Text(t) => Some(t.runs.iter().map(|r| find_all(&r.text, &find, mc, ww).len()).sum::<usize>()),
_ => None,
})
.sum();
if total == 0 {
return Ok(json!({ "count": 0 }));
}
edit_runs::<()>(s, "Replace", &ids, |text, _| {
let mut out = String::with_capacity(text.len());
let mut last = 0;
for (a, b) in find_all(text, &find, mc, ww) {
out.push_str(&text[last..a]);
out.push_str(&rep);
last = b;
}
out.push_str(&text[last..]);
out
})?;
Ok(json!({ "count": total }))
}
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.findNext";
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false));
let st = s.doc()?;
let all = all_texts(&st.doc);
let cur = st.selection.objects.first().and_then(|id| all.iter().position(|x| x == id));
let n = all.len();
let start = cur.map(|c| c + 1).unwrap_or(0);
let hit = (0..n).map(|k| all[(start + k) % n.max(1)]).find(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => !find_all(&t.plain_text(), &find, mc, ww).is_empty(),
_ => false,
});
match hit {
Some(id) => {
s.select(|_, sel| sel.set([id]))?;
Ok(json!({ "id": id.0 }))
}
None => Ok(json!({ "id": null })),
}
}
// ---------- Paste without Formatting ----------
fn strip_formatting(n: &mut drawcraft_doc::Node) {
if let NodeKind::Text(t) = &mut n.kind {
let text = t.plain_text();
t.runs = vec![TextRun { text, style: CharStyle::default() }];
t.para = Default::default();
refresh_bounds(t);
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
strip_formatting(std::sync::Arc::make_mut(c));
}
}
}
fn paste_plain(s: &mut Session, p: &Value) -> Result<Value> {
let saved = s.clipboard.clone();
for n in &mut s.clipboard {
strip_formatting(n);
}
let paste = find_command("edit.paste").map(|c| c.run).ok_or_else(|| EngineError::Other("paste unavailable".into()))?;
let r = paste(s, p);
s.clipboard = saved;
if r.is_ok()
&& let Some(e) = s.active_mut().and_then(|d| d.history.undo.last_mut())
{
e.label = "Paste without Formatting".into();
}
r
}