From 781c57ac5ada37c44ac9d49c2000383bee6db818 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:57:27 -0400 Subject: [PATCH] M2: formatted text clipboard, Paste without Formatting, Change Case, special-character menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 1 + crates/doc/src/slice.rs | 154 +++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 192 +++++++++++++++++++++++++++++++--- crates/engine/src/cmd/text.rs | 89 +++++++++++++++- crates/engine/src/lib.rs | 3 + crates/ui-egui/src/canvas.rs | 16 +-- crates/ui-egui/src/menus.rs | 82 ++++++++++++++- 8 files changed, 515 insertions(+), 23 deletions(-) create mode 100644 crates/doc/src/slice.rs diff --git a/ROADMAP.md b/ROADMAP.md index 69d2368..5573daf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f6a83a5..4fc067e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs new file mode 100644 index 0000000..a22e749 --- /dev/null +++ b/crates/doc/src/slice.rs @@ -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) -> 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()); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ef4e720..9c017e4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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> { + 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, formatted: bool) -> Result { + 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 { @@ -115,24 +179,54 @@ pub fn specs() -> Vec { 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 = 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::>()})) - }) - }), + 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 = 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::>()})) + }) + } + ), 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 { 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(); + } +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a886243..1cbc6c4 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { 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 { )), 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 { 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 { pub(crate) fn format_targets_pub(s: &Session) -> Vec { 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 { + 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::(); + let low = |c: char| c.to_lowercase().collect::(); + 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 { + 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> = + st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect(); + let starts: Vec = 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) + }) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0bc950d..fd7cdd6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -139,6 +139,8 @@ pub struct Session { pub(crate) tool: Box, pub journal: Vec<(String, Value)>, pub clipboard: Option>, + /// Copied text with its formatting (a frameless story slice) and its plain text. + pub text_clipboard: Option<(Arc, String)>, /// Requests for the UI (dialogs, view changes) produced by tools/commands. pub ui_requests: Vec, /// 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, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9af4743..ac23d27 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, .. } => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8857764..4abcf04 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { 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")); }