From e51b2c6114cd2fba858f85841a4975baff585c5b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:06:54 -0700 Subject: [PATCH] =?UTF-8?q?Type=20=E2=80=BA=20Notes=20=E2=80=BA=20Convert?= =?UTF-8?q?=20to=20Note:=20the=20selected=20text=20becomes=20a=20note,=20a?= =?UTF-8?q?s=20in=20InDesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new command note.convertFromText removes the selected story text and puts it into a new editorial note whose anchor sits where the text was. It takes an optional author, and Convert Note to Text puts the text back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table, and leaves the story unchanged. The item follows New Note in Type › Notes, with translations in the catalogs that already translate Convert Note to Text. --- ROADMAP.md | 1 + crates/engine/src/cmd/editorial.rs | 117 ++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/menus.rs | 1 + docs/target-app-parity.md | 2 +- 15 files changed, 129 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..57fce9fff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 5b210da90..6106a1f3e 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -2,11 +2,11 @@ use std::sync::Arc; -use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId}; +use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; -use crate::Result; +use crate::{Result, Session}; pub fn specs() -> Vec { vec![ @@ -37,6 +37,15 @@ pub fn specs() -> Vec { Ok(json!({"story": ts.story.0, "id": id})) }) }), + cmd!( + "note.convertFromText", + "Convert to Note", + ["Type", "Notes"], + None, + "{author?} → {story, id}. The selected text moves into a new note anchored where it was.", + has_text_range, + convert_from_text + ), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); @@ -96,6 +105,55 @@ pub fn specs() -> Vec { ] } +/// Characters whose payload lives outside the story text: a note made from text that holds one +/// would lose it. +const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR]; + +fn has_text_range(s: &Session) -> std::result::Result<(), String> { + has_text(s)?; + match s.active().and_then(|d| d.selection.text) { + Some(t) if t.cell.is_some() => Err("notes go in story text".into()), + Some(t) if t.is_caret() => Err("select the text to convert".into()), + _ => Ok(()), + } +} + +/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a +/// new note, whose anchor takes the text's place. +fn convert_from_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "note.convertFromText"; + let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?; + if ts.cell.is_some() { + return Err(bad(ID, "notes go in story text")); + } + let r = ts.range(); + if r.is_empty() { + return Err(bad(ID, "select the text to convert")); + } + let author = str_param(p, "author").unwrap_or("").to_string(); + s.edit(|d, sel| { + let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?; + let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string(); + if text.contains(MARKS) { + return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker")); + } + let fmt = story.format_after(r.start).clone(); + story.delete(r.clone()); + story.insert_with(r.start, &NOTE_MARK.to_string(), fmt); + story.fix_marks(); + let k = story.text[..r.start].matches(NOTE_MARK).count(); + let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; + let n = Arc::make_mut(n); + (n.text, n.author) = (text, author); + let id = n.id; + if let Some(t) = &mut sel.text { + let c = r.start + NOTE_MARK.len_utf8(); + (t.anchor, t.focus) = (c, c); + } + Ok(json!({"story": ts.story.0, "id": id})) + }) +} + fn target(p: &Value) -> Result<(StoryId, u64)> { let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?; let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?; @@ -159,4 +217,59 @@ mod tests { assert_eq!(st.endnotes[1].text.text, "An endnote."); assert_eq!(st.notes[1].text.text, "A footnote."); } + + #[test] + fn selected_text_converts_to_a_note_at_its_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two\nthree four."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mark = designcraft_doc::NOTE_MARK.len_utf8(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + s.execute("note.new", &json!({"text": "earlier"})).unwrap(); + // "two\nthree " across the paragraph return, selected backwards. + let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len()); + s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap(); + let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + st.check().unwrap(); + assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK)); + assert_eq!(st.paras.len(), 1, "the paragraph return went into the note"); + let list = s.execute("note.list", &json!({})).unwrap(); + assert_eq!(list[0]["text"], "earlier"); + assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a))); + assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed"))); + let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)); + assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor"); + s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1)); + } + + #[test] + fn convert_to_note_needs_selected_story_text_without_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap(); + let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..))); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text"); + s.execute("note.new", &json!({"text": "keep"})).unwrap(); + let before = s.doc().unwrap().doc.stories[&sid].clone(); + let all = before.len(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap(); + let refused = s.execute("note.convertFromText", &json!({})); + assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost"); + assert_eq!(s.doc().unwrap().doc.stories[&sid], before); + s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap(); + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret())); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text"); + } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bdca83589..b7ae97ce6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1613,6 +1613,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Convert to Form Field", ["Convert to Form Field", "Convertir en champ de formulaire", "Convert to Form Field", "Convert to Form Field", "转换为表单域"], ), + ("Convert to Note", ["Convert to Note", "Convertir en note", "Convert to Note", "Convert to Note", "转换为注释"]), ("Conveyor", ["Conveyor", "Convoyeur", "Conveyor", "Conveyor", "传送带"]), ("Copy Link(s) To…", ["Copy Link(s) To…", "Copier les liens vers…", "Copy Link(s) To…", "Copy Link(s) To…", "复制链接至…"]), ("Corner Options…", ["Corner Options…", "Options d'arrondi…", "Corner Options…", "Corner Options…", "角选项…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..0411342e5 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1341,6 +1341,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "تحويل النص إلى جدول"), ("Convert to Button", "تحويل إلى زر"), ("Convert to Form Field", "تحويل إلى حقل نموذج"), + ("Convert to Note", "تحويل إلى ملاحظة"), ("Copy Link(s) To…", "نسخ الروابط إلى…"), ("Corner Options…", "خيارات الزوايا…"), ("Create Alternate Layout…", "إنشاء تخطيط بديل…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..ba3ea6f3e 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -470,6 +470,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Převést text na tabulku"), ("Convert to Button", "Převést na tlačítko"), ("Convert to Form Field", "Převést na pole formuláře"), + ("Convert to Note", "Převést na poznámku"), ("Conveyor", "Dopravník"), ("Copy Link(s) To…", "Kopírovat propojení do…"), ("Corner Options…", "Možnosti rohů…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 367ca52f8..8846683f3 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -521,6 +521,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Text in Tabelle umwandeln"), ("Convert to Button", "In Schaltfläche umwandeln"), ("Convert to Form Field", "In Formularfeld umwandeln"), + ("Convert to Note", "In Notiz umwandeln"), ("Conveyor", "Förderband"), ("Copy Link(s) To…", "Verknüpfung(en) kopieren nach …"), ("Corner Options…", "Eckenoptionen …"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..08cf12586 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -598,6 +598,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Converti testo in tabella"), ("Convert to Button", "Converti in pulsante"), ("Convert to Form Field", "Converti in campo modulo"), + ("Convert to Note", "Converti in nota"), ("Conveyor", "Nastro"), ("Copy Link(s) To…", "Copia collegamenti in…"), ("Corner Options…", "Opzioni angoli…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 37b14ac5c..ba7286728 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -713,6 +713,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "テキストを表に変換"), ("Convert to Button", "ボタンに変換"), ("Convert to Form Field", "フォームフィールドに変換"), + ("Convert to Note", "注釈に変換"), ("Conveyor", "コンベヤー"), ("Copy Link(s) To…", "リンクを指定先にコピー…"), ("Corner Options…", "角のオプション…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..de7eb4387 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -525,6 +525,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "ტექსტის ცხრილად გარდაქმნა"), ("Convert to Button", "ღილაკად გარდაქმნა"), ("Convert to Form Field", "ფორმის ველად გარდაქმნა"), + ("Convert to Note", "შენიშვნად გარდაქმნა"), ("Conveyor", "კონვეიერი"), ("Copy Link(s) To…", "ბმული ფაილების კოპირება…"), ("Corner Options…", "კუთხეების პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..cbfc7d6c4 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -477,6 +477,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Zamień tekst na tabelę"), ("Convert to Button", "Zamień na przycisk"), ("Convert to Form Field", "Zamień na pole formularza"), + ("Convert to Note", "Zamień na notatkę"), ("Conveyor", "Przenośnik"), ("Copy Link(s) To…", "Kopiuj łącza do…"), ("Corner Options…", "Opcje narożników…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..bbf7ac8d9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1478,6 +1478,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Converter texto em tabela"), ("Convert to Button", "Converter em botão"), ("Convert to Form Field", "Converter em campo de formulário"), + ("Convert to Note", "Converter em nota"), ("Copy Link(s) To…", "Copiar link(s) para…"), ("Corner Options…", "Opções de canto…"), ("Create Frame", "Criar quadro"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..6aef2b1b2 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -457,6 +457,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Преобразовать текст в таблицу"), ("Convert to Button", "Преобразовать в кнопку"), ("Convert to Form Field", "Преобразовать в поле формы"), + ("Convert to Note", "Преобразовать в заметку"), ("Conveyor", "Конвейер"), ("Copy Link(s) To…", "Копировать связанные файлы в…"), ("Corner Options…", "Параметры углов…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..eac694fa0 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -601,6 +601,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Перетворити текст на таблицю"), ("Convert to Button", "Перетворити на кнопку"), ("Convert to Form Field", "Перетворити на поле форми"), + ("Convert to Note", "Перетворити на примітку"), ("Conveyor", "Конвеєр"), ("Copy Link(s) To…", "Копіювати пов’язані файли до…"), ("Corner Options…", "Параметри кутів…"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3c052b783..b93e40b33 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -484,6 +484,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", ">Notes", "cmd:note.new", + "cmd:note.convertFromText", "ui:window.panel|Notes|{\"panel\": \"notes\"}", "<", ">Track Changes", diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ae902a21..2dfc486d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -324,7 +324,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). - Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. -- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. +- Editorial notes (`note.new/edit/delete/convertToText/convertFromText/list`, Notes panel, Type › Notes › Convert to Note): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. - Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working colour settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens coloured areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open. - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).