mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
H: linked stories — Place and Link, out-of-date, update
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4eed1d3177
commit
10b599ab2c
8 files changed
+157
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())) };
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user