Merge pull request #1756 from dotCooCoo/feat/convert-to-note

Type › Notes › Convert to Note: the selected text becomes a note, as in InDesign
This commit is contained in:
Brandon Thomas
2026-10-24 10:23:26 -04:00
committed by GitHub
15 changed files with 129 additions and 3 deletions
+1
View File
@@ -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-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change.
- **2026-10-24** · Story panel (Window › Type & Tables › Story): Optical Margin Alignment and its size for the stories of the text selection or the selected text frames, disabled without either; `type.selectionAttrs` reports them as `storyOptions`, null where the stories differ. The Paragraph panel menu has Ignore Optical Margin in place of the per-paragraph Optical Margin Alignment switch, which stays in `type.para`, and Paragraph Style Options › Indents and Spacing has Ignore Optical Margin (#947). No readiness percentage change.
- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change.
+115 -2
View File
@@ -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<CommandSpec> {
vec![
@@ -37,6 +37,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
]
}
/// 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<Value> {
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");
}
}
+1
View File
@@ -1774,6 +1774,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…", "角选项…"]),
+1
View File
@@ -1378,6 +1378,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…", "إنشاء تخطيط بديل…"),
+1
View File
@@ -507,6 +507,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ů…"),
+1
View File
@@ -544,6 +544,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 …"),
+1
View File
@@ -635,6 +635,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…"),
+1
View File
@@ -736,6 +736,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…", "角のオプション…"),
+1
View File
@@ -562,6 +562,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…", "კუთხეების პარამეტრები…"),
+1
View File
@@ -514,6 +514,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…"),
+1
View File
@@ -1515,6 +1515,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"),
+1
View File
@@ -494,6 +494,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…", "Параметры углов…"),
+1
View File
@@ -638,6 +638,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…", "Параметри кутів…"),
+1
View File
@@ -498,6 +498,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
">Notes",
"cmd:note.new",
"cmd:note.convertFromText",
"ui:window.panel|Notes|{\"panel\": \"notes\"}",
"<",
">Track Changes",
+1 -1
View File
@@ -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).