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:
parent
c2222959de
commit
781c57ac5a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, .. } => {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue