H: linked stories — Place and Link, out-of-date, update

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 03:37:01 -04:00
1 parent 4eed1d3177
commit 10b599ab2c
8 files changed
+157 -3

No files matched your search

+1
View File
@@ -78,6 +78,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging and DTDs aren't there yet.
- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position.
- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it.
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
+4
View File
@@ -116,6 +116,9 @@ pub struct Story {
/// Editorial notes, one per [`crate::endnotes::NOTE_MARK`] in text order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub editorial: Vec<Arc<crate::endnotes::EditorialNote>>,
/// Place and Link: the parent story this one copies, and the parent's revision when copied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<(StoryId, u64)>,
}
impl Story {
@@ -135,6 +138,7 @@ impl Story {
index_refs: Vec::new(),
objects: Vec::new(),
editorial: Vec::new(),
link: None,
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Edit › Place and Link: a story that copies another (the parent) and can be updated when the
//! parent changes (Links panel: out of date).
use designcraft_doc::{Document, ParaFormat, Selection, Story, StoryId};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, rect_param, spread_param};
use crate::Result;
/// `child` re-made from `parent`'s content (keeping the child's id and frames).
fn copy_into(d: &mut Document, parent: StoryId, child: StoryId) -> Result<()> {
let src = d.story(parent).ok_or_else(|| bad("story.link", format!("no story {}", parent.0)))?.clone();
let st = d.story_mut(child).ok_or_else(|| bad("story.link", format!("no story {}", child.0)))?;
let (frames, rev) = (std::mem::take(&mut st.frames), st.rev);
*st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), ..src };
Ok(())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"story.placeAndLink",
"Place and Link",
["Edit"],
None,
"{story (parent), rect, spread?} — a new frame whose story copies the parent and stays linked → {id, story}",
has_doc,
|s, p| {
let parent = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.placeAndLink", "missing story"))?);
let rect = rect_param(p, "rect").ok_or_else(|| bad("story.placeAndLink", "missing rect"))?;
let sr = spread_param(p, "spread");
let layer = s.doc()?.active_layer;
s.edit(|d, sel| {
if d.story(parent).is_none() {
return Err(bad("story.placeAndLink", format!("no story {}", parent.0)));
}
let (fid, sid) = d.add_text_frame(sr, rect, layer, "", ParaFormat::default())?;
copy_into(d, parent, sid)?;
*sel = Selection::items(vec![fid]);
Ok(json!({"id": fid.0, "story": sid.0}))
})
}
),
cmd!(query "story.links", "Linked Stories", [], None, "{} → [{story, parent, outOfDate}]", has_doc, |s, _| {
let d = &s.doc()?.doc;
let out: Vec<Value> = d
.stories
.values()
.filter_map(|st| st.link.map(|(parent, rev)| json!({"story": st.id.0, "parent": parent.0, "outOfDate": d.story(parent).is_none_or(|ps| ps.rev != rev)})))
.collect();
Ok(Value::Array(out))
}),
cmd!(
"story.updateLink",
"Update Link",
[],
None,
"{story? (default: every out-of-date linked story)} — the child takes the parent's current content (its own edits are replaced)",
has_doc,
|s, p| {
let only = p.get("story").and_then(Value::as_u64).map(StoryId);
s.edit(|d, _| {
let todo: Vec<(StoryId, StoryId)> = d
.stories
.values()
.filter(|st| only.is_none_or(|o| o == st.id))
.filter_map(|st| {
st.link.and_then(|(parent, rev)| {
(only.is_some() || d.story(parent).is_some_and(|ps| ps.rev != rev)).then_some((st.id, parent))
})
})
.collect();
for (child, parent) in &todo {
copy_into(d, *parent, *child)?;
}
Ok(json!({"updated": todo.len()}))
})
}
),
cmd!("story.unlink", "Unlink", [], None, "{story}", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.unlink", "missing story"))?);
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or_else(|| bad("story.unlink", "no such story"))?;
st.link = None;
Ok(Value::Null)
})
}),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn linked_story_goes_out_of_date_and_updates() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
let p = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Original"})).unwrap();
let c = s.execute("story.placeAndLink", &json!({"story": p["story"], "rect": [72, 72, 300, 200], "spread": 1})).unwrap();
let child = designcraft_doc::StoryId(c["story"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().doc.stories[&child].text, "Original");
assert_eq!(s.execute("story.links", &json!({})).unwrap()[0]["outOfDate"], false);
s.execute("story.setText", &json!({"story": p["story"], "text": "Revised"})).unwrap();
assert_eq!(s.execute("story.links", &json!({})).unwrap()[0]["outOfDate"], true);
assert_eq!(s.execute("story.updateLink", &json!({})).unwrap()["updated"], 1);
let d = &s.doc().unwrap().doc;
assert_eq!(d.stories[&child].text, "Revised");
assert!(!d.stories[&child].frames.is_empty(), "keeps its frame");
assert_eq!(s.execute("story.links", &json!({})).unwrap()[0]["outOfDate"], false);
}
}
+2
View File
@@ -20,6 +20,7 @@ mod interactive;
pub mod interchange;
mod layout;
pub mod library;
mod linked;
mod links;
mod lists;
mod notes;
@@ -149,6 +150,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(conditions::specs());
v.extend(library::specs());
v.extend(lists::specs());
v.extend(linked::specs());
v.extend(xml::specs());
v.extend(strokes::specs());
v.extend(path_type::specs());
+1
View File
@@ -1305,6 +1305,7 @@ impl<'r> Importer<'r> {
index_refs: index_refs.into_iter().map(Arc::new).collect(),
objects: objects.into_iter().map(Arc::new).collect(),
editorial: Vec::new(),
link: None,
};
st.fix_notes();
st.fix_marks();
+14
View File
@@ -24,6 +24,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"),
("app.userDictionary", "User Dictionary…", None, "{} — hyphenation exceptions (hyphenation.* commands)"),
("app.fittingOptions", "Frame Fitting Options…", None, "{} — the dialog for object.fittingOptions"),
("app.placeAndLink", "Place and Link", None, "{} — a linked copy of the selected frame's story, beside it"),
("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{} — Word/RTF style mapping before placing"),
("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"),
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
@@ -144,6 +145,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:edit.clear",
"-",
"cmd:edit.duplicate",
"ui:app.placeAndLink",
"cmd:edit.stepAndRepeat",
"-",
"cmd:edit.selectAll",
@@ -591,6 +593,18 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
"app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"),
"app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"),
"app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"),
"app.placeAndLink" => {
let target = app.session.active().and_then(|d| {
let id = *d.selection.items.first()?;
let it = d.doc.item(id)?;
let loc = d.doc.find(id)?;
Some((it.text_frame()?.story, it.bounds(), loc.spread))
});
let Some((sid, b, sr)) = target else { return Some(Err("select a text frame".into())) };
Ok(app
.run("story.placeAndLink", json!({"story": sid.0, "rect": [b.x0 + 24.0, b.y0 + 24.0, b.x1 + 24.0, b.y1 + 24.0], "spread": sr}))
.unwrap_or(Value::Null))
}
"app.fittingOptions" => {
let g = app.session.active().and_then(|d| d.selection.items.first().and_then(|id| d.doc.item(*id)).and_then(|it| it.graphic().cloned()));
let Some(g) = g else { return Some(Err("select a frame with a placed graphic".into())) };
+18
View File
@@ -1618,6 +1618,24 @@ pub fn align_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
/// Links: status (missing / modified / embedded), page and effective resolution of every placed
/// graphic; Relink, Go To Link, Update Link, Embed Link for the chosen row.
pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
// Linked stories (Place and Link).
let stories = app.session.execute("story.links", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
if !stories.is_empty() {
ui.label(egui::RichText::new("Linked Stories").strong());
for l in &stories {
let out = l["outOfDate"].as_bool().unwrap_or(false);
ui.horizontal(|ui| {
ui.label(format!("Story {} ← story {}", l["story"], l["parent"]));
if out {
ui.label(egui::RichText::new("⚠ out of date").color(egui::Color32::from_rgb(230, 160, 40)));
if ui.small_button("Update").clicked() {
let _ = app.run("story.updateLink", json!({"story": l["story"]}));
}
}
});
}
ui.separator();
}
let Some(st) = app.session.active() else { return };
let t = Tokens::get(ui.ctx());
// The list checks files on disk: refresh it at most twice a second.
+3 -3
View File
@@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 92 | 4 | 1 | 97% |
| P2 | 76 | 30 | 18 | 28 | 51% |
| P2 | 76 | 31 | 18 | 27 | 53% |
| Area | Parity |
|---|---|
@@ -22,7 +22,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Transform | 100% |
| Fill, stroke, colour | 95% |
| Effects & transparency | 71% |
| Placing & links | 87% |
| Placing & links | 90% |
| Type & text frames | 94% |
| Typography | 98% |
| Styles | 97% |
@@ -178,7 +178,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Placing & links | Copy Links To / Package | P1 | D |
| Placing & links | Captions | P2 | P |
| Placing & links | Image import options | P1 | D |
| Placing & links | Linked text/stories | P2 | M |
| Placing & links | Linked text/stories | P2 | D |
| Placing & links | Display performance | P0 | D |
| Placing & links | Video/audio | P2 | M |
| Type & text frames | Text frames | P0 | D |