mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
213b0927ee
commit
42df20ca7f
@@ -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(¢er(b).x)),
|
||||
BlendOrder::Vertical => ids.sort_by(|a, b| center(a).y.total_cmp(¢er(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 }))
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user