mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 19:56:51 -06:00
* Guides: a dragged guide shows where it is (#1282) While a guide is dragged out of a ruler or moved, a label beside the pointer reads X: or Y: and the guide's place, in the ruler units and measured the way the rulers measure (from the zero point, and from the page origin when rulers restart per page). * Guides: keep the drag readout inside the canvas Near the canvas's right or bottom edge the readout flips to the other side of the pointer, and it is kept inside the canvas in every case (readout_at). * Guides: the drag readout measures the spread under the pointer The readout measured from the spread in the middle of the view (where the rulers measure), but a guide is dropped on the spread under the pointer, so with spreads above one another the number shown could differ from the guide's place. It now measures that spread (ruler_pieces_of). * Guides: no readout for an existing guide over another spread An existing guide moves only on its own spread (guide_drag), so dropped over another it stays where it was: the readout no longer shows a place there it won't go. * #1282: guide selection commands (guide.select, guide.selectAll, guide.deselect, guide.moveBy) * #1282: select ruler guides on the canvas, X/Y in the Control bar and Properties * #1282: guides take clicks over objects, a new guide is selected, group drag preview * ROADMAP: the progress line lands in one commit after the guide PRs --------- Co-authored-by: SBaksa <sabaksa11@gmail.com>
354 lines
13 KiB
Rust
354 lines
13 KiB
Rust
//! The command registry. Ids follow InDesign's menu structure.
|
||
|
||
mod anchored;
|
||
pub mod anchors;
|
||
mod articles;
|
||
pub mod book;
|
||
mod buttons;
|
||
pub(crate) mod captions;
|
||
mod changes;
|
||
mod color_theme;
|
||
mod colorsettings;
|
||
mod conditions;
|
||
mod datamerge;
|
||
mod edit;
|
||
mod editorial;
|
||
mod endnotes;
|
||
mod export;
|
||
mod file;
|
||
pub(crate) mod find;
|
||
pub use find::same_find_domain;
|
||
pub(crate) mod flex;
|
||
mod fonts;
|
||
pub(crate) mod guides;
|
||
mod index;
|
||
mod inspect;
|
||
mod interactive;
|
||
pub mod interchange;
|
||
mod layout;
|
||
pub mod library;
|
||
mod linked;
|
||
mod links;
|
||
mod liquid;
|
||
mod lists;
|
||
mod load_styles;
|
||
mod mathexpr;
|
||
mod media;
|
||
mod notes;
|
||
mod object;
|
||
mod overrides;
|
||
mod package;
|
||
pub mod params;
|
||
mod paste_svg;
|
||
mod path_type;
|
||
mod paths;
|
||
mod pdflayers;
|
||
mod place_text;
|
||
pub mod preflight;
|
||
mod prefs;
|
||
mod print;
|
||
mod qr;
|
||
mod rulers;
|
||
mod scripts;
|
||
mod select;
|
||
pub mod spelling;
|
||
mod states;
|
||
mod strokes;
|
||
mod style;
|
||
pub mod table;
|
||
pub mod tabs;
|
||
pub mod text;
|
||
mod thread;
|
||
mod toc;
|
||
mod transitions;
|
||
mod variables;
|
||
mod xml;
|
||
mod xref;
|
||
|
||
use designcraft_doc::{ItemId, SpreadRef};
|
||
use designcraft_geom::{Point, Rect};
|
||
use serde::Serialize;
|
||
use serde_json::Value;
|
||
|
||
use crate::{EngineError, Result, Session};
|
||
|
||
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||
|
||
pub struct CommandSpec {
|
||
pub id: &'static str,
|
||
pub label: &'static str,
|
||
/// Menu placement, e.g. `["Object", "Arrange"]`. Empty = not in menus.
|
||
pub menu: &'static [&'static str],
|
||
pub shortcut: Option<&'static str>,
|
||
pub params: &'static str,
|
||
pub enabled: Enabled,
|
||
pub run: Run,
|
||
pub journal: bool,
|
||
/// Record an undo step when the document changes.
|
||
pub undoable: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct CommandInfo {
|
||
pub id: &'static str,
|
||
pub label: &'static str,
|
||
pub menu: Vec<&'static str>,
|
||
pub shortcut: Option<&'static str>,
|
||
pub params: &'static str,
|
||
pub enabled: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub disabled_reason: Option<String>,
|
||
}
|
||
|
||
impl CommandSpec {
|
||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||
let e = (self.enabled)(s);
|
||
CommandInfo {
|
||
id: self.id,
|
||
label: self.label,
|
||
menu: self.menu.to_vec(),
|
||
shortcut: self.shortcut,
|
||
params: self.params,
|
||
enabled: e.is_ok(),
|
||
disabled_reason: e.err(),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||
Ok(())
|
||
}
|
||
pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||
}
|
||
/// Why [`has_selection`] disables a command. A command that documents `ids` still runs when the
|
||
/// call names its objects (see [`named_targets`]).
|
||
pub const NOTHING_SELECTED: &str = "nothing selected";
|
||
/// Why Object › Group is disabled for a selection it can't group. Like [`NOTHING_SELECTED`], a
|
||
/// call that names its objects still runs, and the command checks those instead.
|
||
pub const CANT_GROUP_SELECTION: &str = "select two or more objects on one spread, all top-level or all in the same group";
|
||
|
||
/// Whether `reason` only concerns the selection, so a call that names its objects runs anyway.
|
||
pub(crate) fn selection_reason(reason: &str) -> bool {
|
||
reason == NOTHING_SELECTED || reason == CANT_GROUP_SELECTION
|
||
}
|
||
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
if s.active().is_some_and(|d| !d.selection.items.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) }
|
||
}
|
||
pub fn has_text(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
|
||
}
|
||
pub fn has_text_or_frames(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.items.iter().any(|i| d.doc.item(*i).is_some_and(|x| x.is_text_frame()))) {
|
||
Ok(())
|
||
} else {
|
||
Err("select text or a text frame".into())
|
||
}
|
||
}
|
||
|
||
macro_rules! cmd {
|
||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||
};
|
||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||
};
|
||
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||
};
|
||
}
|
||
pub(crate) use cmd;
|
||
|
||
pub fn command_specs() -> &'static [CommandSpec] {
|
||
static SPECS: std::sync::OnceLock<Vec<CommandSpec>> = std::sync::OnceLock::new();
|
||
SPECS.get_or_init(|| {
|
||
let mut v = Vec::new();
|
||
v.extend(file::specs());
|
||
v.extend(interchange::specs());
|
||
v.extend(export::specs());
|
||
v.extend(edit::specs());
|
||
v.extend(paste_svg::specs());
|
||
v.extend(endnotes::specs());
|
||
v.extend(editorial::specs());
|
||
v.extend(object::specs());
|
||
v.extend(anchors::specs());
|
||
v.extend(articles::specs());
|
||
v.extend(paths::specs());
|
||
v.extend(flex::specs());
|
||
v.extend(book::specs());
|
||
v.extend(buttons::specs());
|
||
v.extend(captions::specs());
|
||
v.extend(colorsettings::specs());
|
||
v.extend(color_theme::specs());
|
||
v.extend(changes::specs());
|
||
v.extend(conditions::specs());
|
||
v.extend(library::specs());
|
||
v.extend(lists::specs());
|
||
v.extend(linked::specs());
|
||
v.extend(mathexpr::specs());
|
||
v.extend(media::specs());
|
||
v.extend(pdflayers::specs());
|
||
v.extend(liquid::specs());
|
||
v.extend(xml::specs());
|
||
v.extend(strokes::specs());
|
||
v.extend(scripts::specs());
|
||
v.extend(states::specs());
|
||
v.extend(transitions::specs());
|
||
v.extend(path_type::specs());
|
||
v.extend(qr::specs());
|
||
v.extend(select::specs());
|
||
v.extend(package::specs());
|
||
v.extend(print::specs());
|
||
v.extend(text::specs());
|
||
v.extend(thread::specs());
|
||
v.extend(tabs::specs());
|
||
v.extend(style::specs());
|
||
v.extend(load_styles::specs());
|
||
v.extend(table::specs());
|
||
v.extend(layout::specs());
|
||
v.extend(overrides::specs());
|
||
v.extend(prefs::specs());
|
||
v.extend(inspect::specs());
|
||
v.extend(find::specs());
|
||
v.extend(variables::specs());
|
||
v.extend(toc::specs());
|
||
v.extend(notes::specs());
|
||
v.extend(xref::specs());
|
||
v.extend(index::specs());
|
||
v.extend(anchored::specs());
|
||
v.extend(guides::specs());
|
||
v.extend(rulers::specs());
|
||
v.extend(links::specs());
|
||
v.extend(fonts::specs());
|
||
v.extend(spelling::specs());
|
||
v.extend(datamerge::specs());
|
||
v.extend(interactive::specs());
|
||
v.extend(preflight::specs());
|
||
v
|
||
})
|
||
}
|
||
|
||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||
command_specs().iter().find(|c| c.id == id)
|
||
}
|
||
|
||
// ---------- param helpers ----------
|
||
|
||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||
}
|
||
/// `p` with `key` set to `v`. Parameters come from callers (control channel, MCP, scripts) and
|
||
/// may not be an object: `p[key] = v` panics on an array or a string, so start from `{}` then.
|
||
pub(crate) fn with_param(p: &Value, key: &str, v: Value) -> Value {
|
||
let mut m = p.as_object().cloned().unwrap_or_default();
|
||
m.insert(key.to_string(), v);
|
||
Value::Object(m)
|
||
}
|
||
pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
|
||
p.get(key).and_then(Value::as_f64).unwrap_or(default)
|
||
}
|
||
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
|
||
p.get(key).and_then(Value::as_bool).unwrap_or(default)
|
||
}
|
||
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||
p.get(key).and_then(Value::as_str)
|
||
}
|
||
/// Plain text from a parameter, with CR LF and lone CR (classic Mac and InDesign line ends) as
|
||
/// `\n`, the paragraph separator, as edit.paste and text import already do. Kept as they were,
|
||
/// the CRs joined the paragraphs into one. A vertical tab (Word's and PowerPoint's Shift+Enter,
|
||
/// Chr(11)) is a forced line break: kept, it was drawn as a missing-glyph box.
|
||
pub(crate) fn text_param(p: &Value, key: &str) -> String {
|
||
str_param(p, key)
|
||
.unwrap_or("")
|
||
.replace("\r\n", "\n")
|
||
.replace('\r', "\n")
|
||
.replace('\u{B}', designcraft_doc::story::FORCED_LINE_BREAK.encode_utf8(&mut [0; 4]))
|
||
}
|
||
pub(crate) fn id_param(p: &Value, key: &str) -> Option<ItemId> {
|
||
p.get(key).and_then(Value::as_u64).map(ItemId)
|
||
}
|
||
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<ItemId>> {
|
||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect())
|
||
}
|
||
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
|
||
let a = p.get(key)?.as_array()?;
|
||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||
}
|
||
/// `r`, computed from coordinate params, or an error when a value overflowed to infinity or NaN:
|
||
/// a document holding one can't be saved (#1050).
|
||
pub(crate) fn finite_rect(command: &str, r: Rect) -> Result<Rect> {
|
||
if [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { Ok(r) } else { Err(bad(command, "the position and size must stay finite numbers")) }
|
||
}
|
||
|
||
pub(crate) fn rect_param(p: &Value, key: &str) -> Option<Rect> {
|
||
let a = p.get(key)?.as_array()?;
|
||
let f = |i: usize| a.get(i).and_then(Value::as_f64);
|
||
Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?))
|
||
}
|
||
/// The `spread` param: a body spread index (`0`), a spread reference (`{"kind":"doc"|"parent","index":0}`)
|
||
/// or absent or null (= spread 0; commands that forward an optional `spread` pass null). Anything else is an error: read as spread 0, a script aiming at a parent
|
||
/// spread got its items on the first page without a word.
|
||
pub(crate) fn spread_param(p: &Value, command: &str) -> Result<SpreadRef> {
|
||
let index = |v: &Value, key: &str| {
|
||
v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))
|
||
};
|
||
let Some(value) = p.get("spread").filter(|v| !v.is_null()) else { return Ok(SpreadRef::Doc(0)) };
|
||
if value.is_number() {
|
||
return Ok(SpreadRef::Doc(index(value, "spread")?));
|
||
}
|
||
let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or {kind: doc|parent, index}"))?;
|
||
let i = index(object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?, "spread.index")?;
|
||
match object.get("kind").and_then(Value::as_str) {
|
||
Some("doc") => Ok(SpreadRef::Doc(i)),
|
||
Some("parent") => Ok(SpreadRef::Parent(i)),
|
||
_ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")),
|
||
}
|
||
}
|
||
|
||
/// The objects a call names with a non-empty `ids` or an `id`, for a command whose params
|
||
/// document `ids` (it acts on [`targets`]). `None` when the call names none.
|
||
pub(crate) fn named_targets(spec: &CommandSpec, p: &Value) -> Option<Vec<ItemId>> {
|
||
if !spec.params.split(|c: char| !c.is_ascii_alphanumeric()).any(|w| w == "ids") {
|
||
return None;
|
||
}
|
||
// As `targets` reads them: `ids`, when given, wins over `id` (an empty list names nothing).
|
||
if let Some(ids) = ids_param(p, "ids") {
|
||
return (!ids.is_empty()).then_some(ids);
|
||
}
|
||
id_param(p, "id").map(|i| vec![i])
|
||
}
|
||
|
||
/// Targets: `ids` / `id` params or the selection.
|
||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ItemId>> {
|
||
if let Some(ids) = ids_param(p, "ids") {
|
||
return Ok(ids);
|
||
}
|
||
if let Some(id) = id_param(p, "id") {
|
||
return Ok(vec![id]);
|
||
}
|
||
Ok(s.doc()?.selection.items.clone())
|
||
}
|
||
|
||
pub(crate) fn ok() -> Result<Value> {
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
pub fn file_bytes(d: &designcraft_doc::Document) -> Result<Vec<u8>> {
|
||
file::to_bytes(d)
|
||
}
|
||
pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> {
|
||
file::from_bytes(b)
|
||
}
|
||
pub(crate) use datamerge::sync_placeholders;
|
||
pub(crate) use file::load_document_fonts;
|
||
pub use file::{
|
||
DOCUMENT_SNIFF_BYTES, RecentPreview, base64_decode, base64_encode, from_bytes, is_document, recent_preview, to_bytes, with_document_extension,
|
||
};
|
||
pub use layout::chapter_numbering;
|
||
pub use paste_svg::is_svg_markup;
|
||
pub(crate) use place_text::autoflow as place_text_autoflow;
|