M2: formatted text clipboard, Paste without Formatting, Change Case, special-character menus

- doc: Story::extract / insert_story carry formats, footnotes, cross-references, index markers
  and tables through copy and paste
- engine: edit.copy/cut on text keep a formatted slice; edit.paste uses it while the system
  clipboard matches (else plain text, no smart quotes); edit.pasteWithoutFormatting (⇧⌘V);
  type.changeCase (upper/lower/title/sentence, run formats kept)
- UI: canvas copy/cut/paste go through the engine; Type › Insert Special Character (Symbols,
  Markers, Hyphens and Dashes, Quotation Marks, Other), Insert White Space, Insert Break
  Character, Change Case; nested submenus in the menu parser (+ test)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 22:57:27 -04:00
parent c2222959de
commit 781c57ac5a
8 changed files with 515 additions and 23 deletions

View File

@ -20,6 +20,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.

View File

@ -17,6 +17,7 @@ pub mod item;
pub mod notes;
pub mod page;
pub mod selection;
mod slice;
pub mod story;
pub mod styles;
pub mod table;

154
crates/doc/src/slice.rs Normal file
View File

@ -0,0 +1,154 @@
//! Formatted text slices: copy a range of a story (with paragraph and character formats,
//! footnotes, cross-references, index markers and tables) and insert it into another story.
use std::sync::Arc;
use crate::ids::StoryId;
use crate::index::INDEX_MARK;
use crate::notes::FOOTNOTE_REF;
use crate::story::{CharRun, Story, TABLE_ANCHOR};
use crate::xref::{ANCHOR_MARK, XREF_MARK};
impl Story {
/// A frameless story holding `range` with its formatting and the objects anchored in it.
pub fn extract(&self, range: std::ops::Range<usize>) -> Story {
let a = crate::story::floor_char_boundary(&self.text, range.start.min(self.len()));
let b = crate::story::floor_char_boundary(&self.text, range.end.min(self.len())).max(a);
let mut out = Story::new(StoryId(0));
out.text = self.text[a..b].to_string();
let (p0, p1) = (self.para_at(a), self.para_at(b));
out.paras = self.paras[p0..=p1].to_vec();
out.chars = Vec::new();
for (r, f) in self.runs() {
let s = r.start.max(a);
let e = r.end.min(b);
if s < e {
out.chars.push(CharRun { len: e - s, format: f.clone() });
}
}
if out.chars.is_empty() {
out.chars.push(CharRun { len: 0, format: self.char_format_at(a).clone() });
}
let count = |m: char, upto: usize| self.text[..upto].matches(m).count();
let span = |m: char| count(m, a)..count(m, b);
out.notes = self.notes[span(FOOTNOTE_REF)].to_vec();
out.anchors = self.anchors[span(ANCHOR_MARK)].to_vec();
out.xrefs = self.xrefs[span(XREF_MARK)].to_vec();
out.index_refs = self.index_refs[span(INDEX_MARK)].to_vec();
for p in &out.paras {
if let Some(id) = p.table
&& let Some(t) = self.tables.get(&id)
&& out.text.contains(TABLE_ANCHOR)
{
out.tables.insert(id, t.clone());
}
}
out.fix_tables();
out
}
/// Insert a slice made by [`Story::extract`] at `pos`, keeping its formatting: the first
/// pasted paragraph merges into the paragraph at `pos` (it keeps that paragraph's format, as
/// in InDesign), later paragraphs keep their own formats. Returns the end of the insertion.
pub fn insert_story(&mut self, pos: usize, src: &Story) -> usize {
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
if src.text.is_empty() {
return pos;
}
// Text first (creates empty marker entries), then formats and marker payloads.
let mut at = pos;
for (r, f) in src.runs() {
if r.is_empty() {
continue;
}
self.insert_with(at, &src.text[r.clone()], f.clone());
at += r.len();
}
let end = at;
// Paragraph formats of the pasted paragraphs after the first.
let p0 = self.para_at(pos);
for (k, pf) in src.paras.iter().enumerate().skip(1) {
if let Some(p) = self.paras.get_mut(p0 + k) {
*p = crate::story::ParaFormat { table: None, ..pf.clone() };
}
}
// Payloads of footnotes, cross-references and index markers, in order.
let first = |m: char| self.text[..pos].matches(m).count();
let (nf, nx, ni, na) = (first(FOOTNOTE_REF), first(XREF_MARK), first(INDEX_MARK), first(ANCHOR_MARK));
for (k, n) in src.notes.iter().enumerate() {
if let Some(slot) = self.notes.get_mut(nf + k) {
let id = slot.id;
*slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() });
}
}
for (k, x) in src.xrefs.iter().enumerate() {
if let Some(slot) = self.xrefs.get_mut(nx + k) {
*slot = x.clone();
}
}
for (k, r) in src.index_refs.iter().enumerate() {
if let Some(slot) = self.index_refs.get_mut(ni + k) {
*slot = r.clone();
}
}
// Anchors are new destinations (fresh ids) with the copied names.
for (k, a) in src.anchors.iter().enumerate() {
if let Some(slot) = self.anchors.get_mut(na + k) {
let id = slot.id;
*slot = Arc::new(crate::xref::TextAnchor { id, name: a.name.clone() });
}
}
// Tables: re-attach by paragraph (fresh ids so a pasted copy never shares an id).
if !src.tables.is_empty() {
let mut next = self.tables.keys().max().copied().unwrap_or(0) + 1;
for (k, pf) in src.paras.iter().enumerate() {
let Some(tid) = pf.table else { continue };
let Some(t) = src.tables.get(&tid) else { continue };
let pi = p0 + k;
let mut t = (**t).clone();
t.id = next;
next += 1;
self.tables.insert(t.id, Arc::new(t));
if let Some(p) = self.paras.get_mut(pi) {
p.table = Some(next - 1);
}
}
self.fix_tables();
}
self.rev += 1;
end
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::story::ParaFormat;
#[test]
fn copy_paste_keeps_formatting_and_notes() {
let mut a = Story::with_text(StoryId(1), "Alpha beta\nGamma delta", ParaFormat::default());
a.format_chars(0..5, |f| f.over.size = Some(20.0));
a.format_paras(11..11, |p| p.style = "Head".into());
a.insert_note(8, "A note.", ParaFormat::default());
a.check().unwrap();
let slice = a.extract(0..a.len());
slice.check().unwrap();
assert_eq!(slice.notes.len(), 1);
let mut b = Story::with_text(StoryId(2), "Start: end", ParaFormat::default());
let end = b.insert_story(7, &slice);
b.check().unwrap();
assert_eq!(&b.text[7..end], slice.text.as_str());
assert_eq!(b.char_format_at(8).over.size, Some(20.0));
assert_eq!(b.paras[1].style, "Head");
assert_eq!(b.notes.len(), 1);
assert_eq!(b.notes[0].text.text, "A note.");
// A partial slice.
let e = 8 + FOOTNOTE_REF.len_utf8();
let part = a.extract(2..e);
part.check().unwrap();
assert_eq!(part.text, a.text[2..e]);
assert_eq!(part.notes.len(), 1);
assert!(a.extract(2..8).notes.is_empty());
}
}

View File

@ -3,7 +3,7 @@
use std::sync::Arc;
use designcraft_doc::{ItemId, Selection, SpreadRef};
use serde_json::json;
use serde_json::{Value, json};
use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
use crate::{HistoryEntry, Result, Session};
@ -16,7 +16,71 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> {
}
fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_some() { Ok(()) } else { Err("clipboard is empty".into()) }
let text = s.text_clipboard.is_some() && s.active().is_some_and(|d| d.selection.text.is_some());
if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) }
}
fn has_clip_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.selection.text.is_some()) {
return Ok(());
}
has_clip(s)
}
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) {
return Ok(());
}
has_selection(s)
}
/// Plain text of a story slice: markers and anchored objects dropped, footnote text omitted.
fn plain(st: &designcraft_doc::Story) -> String {
st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect()
}
/// Copy the selected text with its formatting. Returns its plain text (for the system clipboard).
fn copy_text(s: &mut Session) -> Result<Option<String>> {
let st = s.doc()?;
let Some(t) = st.selection.text.filter(|t| !t.is_caret()) else { return Ok(None) };
let Some(story) = st.doc.text_story(t.story, t.cell) else { return Ok(None) };
let slice = story.extract(t.range());
let text = plain(&slice);
s.text_clipboard = Some((Arc::new(slice), text.clone()));
Ok(Some(text))
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p));
// Footnotes, cross-references and tables only paste into story text, not cells or footnotes.
if use_clip && let Some((slice, _)) = clip {
let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() };
return s.edit(|d, sel| {
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = r.start.min(st.len())..r.end.min(st.len());
st.delete(r.clone());
let end = st.insert_story(r.start, &slice);
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "formatted": true}))
});
}
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true}))
}
fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story {
let mut out = st.clone();
let marks = [designcraft_doc::FOOTNOTE_REF, designcraft_doc::XREF_MARK, designcraft_doc::TABLE_ANCHOR];
while let Some(i) = out.text.find(marks) {
let n = out.text[i..].chars().next().map_or(1, char::len_utf8);
out.delete(i..i + n);
}
out
}
pub fn specs() -> Vec<CommandSpec> {
@ -115,24 +179,54 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"deleted": ids.len()}))
})
}),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, |s, _| {
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| {
if let Some(text) = copy_text(s)? {
return Ok(json!({"text": text}));
}
s.clipboard = Some(Arc::new(clip_doc(s)?));
ok()
}),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, |s, p| {
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection_or_text, |s, p| {
if let Some(text) = copy_text(s)? {
super::text::delete_selection(s)?;
return Ok(json!({"text": text}));
}
s.clipboard = Some(Arc::new(clip_doc(s)?));
s.execute("edit.clear", p)
}),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{inPlace?: bool}", has_clip, |s, p| {
let clip = s.clipboard.clone().expect("checked");
let off = if bool_or(p, "inPlace", false) { 0.0 } else { 12.0 };
let ids: Vec<ItemId> = clip.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
s.edit(|d, sel| {
let new = super::object::duplicate_from(d, &clip, &ids, SpreadRef::Doc(0), designcraft_geom::Vec2::new(off, off))?;
*sel = Selection::items(new.clone());
Ok(json!({"ids": new.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}),
cmd!(
"edit.pasteWithoutFormatting",
"Paste without Formatting",
["Edit"],
Some("Cmd+Shift+V"),
"{text?} — plain text at the insertion point, in the format there",
super::has_text,
|s, p| paste_text(s, super::str_param(p, "text").map(str::to_string), false)
),
cmd!(
"edit.paste",
"Paste",
["Edit"],
Some("Cmd+V"),
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}",
has_clip_or_text,
|s, p| {
if s.doc()?.selection.text.is_some() {
return paste_text(s, super::str_param(p, "text").map(str::to_string), true);
}
if s.clipboard.is_none() {
return Err(super::bad("edit.paste", "clipboard is empty"));
}
let clip = s.clipboard.clone().expect("checked");
let off = if bool_or(p, "inPlace", false) { 0.0 } else { 12.0 };
let ids: Vec<ItemId> = clip.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
s.edit(|d, sel| {
let new = super::object::duplicate_from(d, &clip, &ids, SpreadRef::Doc(0), designcraft_geom::Vec2::new(off, off))?;
*sel = Selection::items(new.clone());
Ok(json!({"ids": new.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
),
cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clip, |s, _| s
.execute("edit.paste", &json!({"inPlace": true}))),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Alt+Shift+D"), "{}", has_selection, |s, _| s
@ -185,3 +279,73 @@ pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
d.bookmarks.clear();
Ok(d)
}
#[cfg(test)]
mod text_clipboard_tests {
use super::*;
fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId {
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap();
s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
}
#[test]
fn formatted_copy_paste_and_plain_paste() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = frame(&mut s, "Bold words here.");
let b = frame(&mut s, "Target: .");
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.bold", &json!({})).unwrap();
// A footnote inside the copied range travels with it.
s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap();
s.execute("footnote.insert", &json!({"text": "n"})).unwrap();
let fl = designcraft_doc::FOOTNOTE_REF.len_utf8();
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap();
let r = s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(r["text"], "Bold words");
// Formatted paste when the system clipboard matches.
s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap();
s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap();
let st = s.doc().unwrap().doc.story(b).unwrap().clone();
assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF));
assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold"));
assert_eq!(st.notes.len(), 1);
assert_eq!(st.notes[0].text.text, "n");
// Other text on the system clipboard pastes as plain text.
s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap();
assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste");
// Paste without formatting takes the insertion format.
s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
let st = s.doc().unwrap().doc.story(b).unwrap().clone();
assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text);
assert_eq!(st.char_format_at(1).over.font_style, None);
// Cut removes and copies.
s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap();
assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold");
assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget"));
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn change_case_keeps_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = frame(&mut s, "the quick brown fox. jumps over straße");
s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap();
s.execute("type.italic", &json!({})).unwrap();
let len = s.doc().unwrap().doc.story(a).unwrap().len();
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap();
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße");
s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße");
s.execute("type.changeCase", &json!({"case": "upper"})).unwrap();
let st = s.doc().unwrap().doc.story(a).unwrap().clone();
assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE");
assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic"));
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len());
st.check().unwrap();
}
}

View File

@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
st.revision += 1;
ok()
}),
cmd!("text.insert", "Type", [], None, "{text} — replaces the selected text", has_text, insert),
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
@ -131,6 +131,15 @@ pub fn specs() -> Vec<CommandSpec> {
)),
cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)),
cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)),
cmd!(
"type.changeCase",
"Change Case",
["Type"],
None,
"{case: upper|lower|title|sentence} — the selected text, or the stories of selected frames (formatting kept)",
has_text_or_frames,
change_case
),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
]
}
@ -205,7 +214,8 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) {
return super::table::step_cell(s, true);
}
let text = if s.prefs.typographers_quotes { smart_quotes(s, &text) } else { text };
let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false);
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@ -562,3 +572,78 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn format_targets_pub(s: &Session) -> Vec<Target> {
format_targets(s)
}
/// The case-changed text of `src`, char by char (a char may map to several, e.g. ß → SS).
pub(crate) fn case_map(src: &str, case: &str) -> Vec<String> {
let mut out = Vec::with_capacity(src.len());
let mut prev = ' ';
let mut sentence_start = true;
for c in src.chars() {
let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}'));
let up = |c: char| c.to_uppercase().collect::<String>();
let low = |c: char| c.to_lowercase().collect::<String>();
let m = match case {
"upper" => up(c),
"lower" => low(c),
"title" => {
if word_start {
up(c)
} else {
low(c)
}
}
_ => {
if sentence_start && c.is_alphanumeric() {
up(c)
} else {
low(c)
}
}
};
if c.is_alphanumeric() {
sentence_start = false;
} else if matches!(c, '.' | '!' | '?' | '\n' | '\u{2028}') {
sentence_start = true;
}
out.push(m);
prev = c;
}
out
}
fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
let case = str_param(p, "case").unwrap_or("upper").to_string();
if !matches!(case.as_str(), "upper" | "lower" | "title" | "sentence") {
return Err(bad("type.changeCase", format!("unknown case `{case}`")));
}
let targets = format_targets(s);
s.edit(|d, sel| {
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
let r = t.range.start.min(st.len())..t.range.end.min(st.len());
let mapped = case_map(st.slice(r.clone()), &case);
// Replace run by run (from the end) so every run keeps its formatting.
let segs: Vec<std::ops::Range<usize>> =
st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect();
let starts: Vec<usize> = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect();
let mut delta: isize = 0;
for seg in segs.iter().rev() {
let old = st.slice(seg.clone()).to_string();
let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect();
if new != old {
delta += new.len() as isize - old.len() as isize;
st.replace(seg.clone(), &new);
}
}
if let Some(ts) = sel.text.as_mut().filter(|ts| ts.story == t.story && ts.cell == t.cell && !ts.is_caret()) {
let end = (r.end as isize + delta).max(r.start as isize) as usize;
if ts.anchor <= ts.focus {
ts.focus = end;
} else {
ts.anchor = end;
}
}
}
Ok(Value::Null)
})
}

View File

@ -139,6 +139,8 @@ pub struct Session {
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
pub journal: Vec<(String, Value)>,
pub clipboard: Option<Arc<Document>>,
/// Copied text with its formatting (a frameless story slice) and its plain text.
pub text_clipboard: Option<(Arc<designcraft_doc::Story>, String)>,
/// Requests for the UI (dialogs, view changes) produced by tools/commands.
pub ui_requests: Vec<UiRequest>,
/// Graphic loaded in the place cursor: (asset, natural size in points).
@ -162,6 +164,7 @@ impl Session {
tool: designcraft_tools::create("selection"),
journal: vec![],
clipboard: None,
text_clipboard: None,
ui_requests: vec![],
loaded: None,
untitled: 0,

View File

@ -1142,14 +1142,18 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let _ = app.run("text.insert", json!({"text": t}));
}
egui::Event::Paste(t) if wants_text => {
let _ = app.run("text.insert", json!({"text": t.replace("\r\n", "\n").replace('\r', "\n")}));
// Formatted when the system clipboard still holds what was copied here;
// Shift (⇧⌘V) pastes without formatting.
let plain = ui.input(|i| i.modifiers.shift);
let id = if plain { "edit.pasteWithoutFormatting" } else { "edit.paste" };
let _ = app.run(id, json!({"text": t}));
}
egui::Event::Copy | egui::Event::Cut if wants_text => {
if let Some(t) = selected_text(app) {
ui.ctx().copy_text(t);
if matches!(e, egui::Event::Cut) {
let _ = app.run("text.delete", json!({}));
}
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
if let Ok(r) = app.run(id, json!({}))
&& let Some(t) = r.get("text").and_then(serde_json::Value::as_str)
{
ui.ctx().copy_text(t.to_string());
}
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {

View File

@ -149,6 +149,69 @@ pub const MENUS: &[(&str, &[&str])] = &[
"Type",
&[
"cmd:type.fillWithPlaceholder",
">Insert Special Character",
">Symbols",
"cmd:text.insert|Bullet Character|{\"text\": \"\u{2022}\", \"raw\": true}",
"cmd:text.insert|Copyright Symbol|{\"text\": \"\u{a9}\", \"raw\": true}",
"cmd:text.insert|Ellipsis|{\"text\": \"\u{2026}\", \"raw\": true}",
"cmd:text.insert|Paragraph Symbol|{\"text\": \"\u{b6}\", \"raw\": true}",
"cmd:text.insert|Registered Trademark Symbol|{\"text\": \"\u{ae}\", \"raw\": true}",
"cmd:text.insert|Section Symbol|{\"text\": \"\u{a7}\", \"raw\": true}",
"cmd:text.insert|Trademark Symbol|{\"text\": \"\u{2122}\", \"raw\": true}",
"<",
">Markers",
"cmd:text.insert|Current Page Number|{\"text\": \"\u{e000}\", \"raw\": true}",
"cmd:text.insert|Next Page Number|{\"text\": \"\u{e007}\", \"raw\": true}",
"cmd:text.insert|Previous Page Number|{\"text\": \"\u{e008}\", \"raw\": true}",
"cmd:text.insert|Section Marker|{\"text\": \"\u{e001}\", \"raw\": true}",
"<",
">Hyphens and Dashes",
"cmd:text.insert|Em Dash|{\"text\": \"\u{2014}\", \"raw\": true}",
"cmd:text.insert|En Dash|{\"text\": \"\u{2013}\", \"raw\": true}",
"cmd:text.insert|Discretionary Hyphen|{\"text\": \"\u{ad}\", \"raw\": true}",
"cmd:text.insert|Nonbreaking Hyphen|{\"text\": \"\u{2011}\", \"raw\": true}",
"<",
">Quotation Marks",
"cmd:text.insert|Double Left Quotation Marks|{\"text\": \"\u{201c}\", \"raw\": true}",
"cmd:text.insert|Double Right Quotation Marks|{\"text\": \"\u{201d}\", \"raw\": true}",
"cmd:text.insert|Single Left Quotation Mark|{\"text\": \"\u{2018}\", \"raw\": true}",
"cmd:text.insert|Single Right Quotation Mark|{\"text\": \"\u{2019}\", \"raw\": true}",
"cmd:text.insert|Straight Double Quotation Marks|{\"text\": \"\\\"\", \"raw\": true}",
"cmd:text.insert|Straight Single Quotation Mark (Apostrophe)|{\"text\": \"'\", \"raw\": true}",
"<",
">Other",
"cmd:text.insert|Tab|{\"text\": \"\\t\", \"raw\": true}",
"cmd:text.insert|Right Indent Tab|{\"text\": \"\u{e006}\", \"raw\": true}",
"cmd:text.insert|Indent to Here|{\"text\": \"\u{e005}\", \"raw\": true}",
"<",
"<",
">Insert White Space",
"cmd:text.insert|Em Space|{\"text\": \"\u{2003}\", \"raw\": true}",
"cmd:text.insert|En Space|{\"text\": \"\u{2002}\", \"raw\": true}",
"cmd:text.insert|Nonbreaking Space|{\"text\": \"\u{a0}\", \"raw\": true}",
"cmd:text.insert|Hair Space|{\"text\": \"\u{200a}\", \"raw\": true}",
"cmd:text.insert|Sixth Space|{\"text\": \"\u{2006}\", \"raw\": true}",
"cmd:text.insert|Thin Space|{\"text\": \"\u{2009}\", \"raw\": true}",
"cmd:text.insert|Quarter Space|{\"text\": \"\u{2005}\", \"raw\": true}",
"cmd:text.insert|Third Space|{\"text\": \"\u{2004}\", \"raw\": true}",
"cmd:text.insert|Punctuation Space|{\"text\": \"\u{2008}\", \"raw\": true}",
"cmd:text.insert|Figure Space|{\"text\": \"\u{2007}\", \"raw\": true}",
"cmd:text.insert|Flush Space|{\"text\": \"\u{2001}\", \"raw\": true}",
"<",
">Insert Break Character",
"cmd:text.insert|Column Break|{\"text\": \"\u{e002}\", \"raw\": true}",
"cmd:text.insert|Frame Break|{\"text\": \"\u{e003}\", \"raw\": true}",
"cmd:text.insert|Page Break|{\"text\": \"\u{e004}\", \"raw\": true}",
"cmd:text.insert|Paragraph Return|{\"text\": \"\\n\", \"raw\": true}",
"cmd:text.insert|Forced Line Break|{\"text\": \"\u{2028}\", \"raw\": true}",
"<",
"-",
">Change Case",
"cmd:type.changeCase|UPPERCASE|{\"case\": \"upper\"}",
"cmd:type.changeCase|lowercase|{\"case\": \"lower\"}",
"cmd:type.changeCase|Title Case|{\"case\": \"title\"}",
"cmd:type.changeCase|Sentence case|{\"case\": \"sentence\"}",
"<",
"-",
"cmd:type.alignLeft",
"cmd:type.alignCenter",
@ -655,8 +718,18 @@ fn parse_entries(entries: &[&str]) -> Vec<Item> {
if e == "-" {
out.push(Item::Sep);
} else if let Some(name) = e.strip_prefix('>') {
// Find the matching `<` (submenus nest).
let start = i;
while i < entries.len() && entries[i] != "<" {
let mut depth = 1;
while i < entries.len() {
if entries[i].starts_with('>') {
depth += 1;
} else if entries[i] == "<" {
depth -= 1;
if depth == 0 {
break;
}
}
i += 1;
}
let sub = parse_entries(&entries[start..i]);
@ -939,6 +1012,13 @@ mod tests {
assert!(!o.is_empty(), "{id}: empty fixed params");
}
}
// Nested submenus parse (Type › Insert Special Character › Symbols › …).
let ty = menu_tree().into_iter().find(|(m, _)| *m == "Type").unwrap().1;
let special = ty.iter().find_map(|i| match i {
Item::Sub(n, c) if n == "Insert Special Character" => Some(c.clone()),
_ => None,
});
assert!(special.is_some_and(|c| c.iter().any(|i| matches!(i, Item::Sub(n, _) if n == "Symbols"))));
// Fixed-parameter variants keep their own labels.
assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally"));
}