U: Hyperlinks and Bookmarks panels; hyperlink edit / go to source, bookmark rename
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb2e8d8b4f
commit
d357a0068d
|
|
@ -55,6 +55,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.
|
||||
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
|
||||
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
|
||||
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,45 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
}),
|
||||
cmd!(query "hyperlink.list", "Hyperlinks", [], None, "{}", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.hyperlinks).unwrap_or_default())),
|
||||
cmd!("hyperlink.edit", "Hyperlink Options…", [], None, "{id, name?, url? | email? | page? (1-based)}", has_doc, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("hyperlink.edit", "missing id"))?;
|
||||
let dest = dest_param(p);
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
let h = d.hyperlinks.iter_mut().find(|h| h.id == id).ok_or_else(|| bad("hyperlink.edit", format!("no hyperlink {id}")))?;
|
||||
if let Some(n) = name {
|
||||
h.name = n;
|
||||
}
|
||||
if let Some(dst) = dest {
|
||||
h.dest = dst;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}),
|
||||
cmd!(noundo "hyperlink.goToSource", "Go To Source", [], None, "{id} — selects the hyperlink's text or frame", has_doc, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("hyperlink.goToSource", "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
let h = st.doc.hyperlinks.iter().find(|h| h.id == id).cloned().ok_or_else(|| bad("hyperlink.goToSource", format!("no hyperlink {id}")))?;
|
||||
st.selection = match h.source {
|
||||
HyperlinkSource::Item { id } => designcraft_doc::Selection::items(vec![id]),
|
||||
HyperlinkSource::Text { story, start, end } => {
|
||||
let frame = st.doc.story(story).and_then(|x| x.frames.first().copied());
|
||||
designcraft_doc::Selection::text(designcraft_doc::TextSel { story, anchor: start, focus: end, frame, cell: None })
|
||||
}
|
||||
};
|
||||
st.revision += 1;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("bookmark.rename", "Rename Bookmark", [], None, "{index, name}", has_doc, |s, p| {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("bookmark.rename", "missing index"))? as usize;
|
||||
let name =
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("bookmark.rename", "missing name"))?.to_string();
|
||||
s.edit(|d, _| {
|
||||
let b = d.bookmarks.get_mut(i).ok_or_else(|| bad("bookmark.rename", format!("no bookmark {i}")))?;
|
||||
b.name = name;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}),
|
||||
cmd!("bookmark.add", "New Bookmark", [], None, "{name?, page? (1-based; default: current selection's page or 1)}", has_doc, |s, p| {
|
||||
let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)).unwrap_or(0);
|
||||
let n = s.doc()?.doc.page_count();
|
||||
|
|
@ -51,16 +90,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dest = if let Some(u) = str_param(p, "url") {
|
||||
HyperlinkDest::Url(u.to_string())
|
||||
fn dest_param(p: &Value) -> Option<HyperlinkDest> {
|
||||
if let Some(u) = str_param(p, "url") {
|
||||
Some(HyperlinkDest::Url(u.to_string()))
|
||||
} else if let Some(e) = str_param(p, "email") {
|
||||
HyperlinkDest::Email(e.to_string())
|
||||
} else if let Some(pg) = p.get("page").and_then(Value::as_u64) {
|
||||
HyperlinkDest::Page((pg as usize).saturating_sub(1))
|
||||
Some(HyperlinkDest::Email(e.to_string()))
|
||||
} else {
|
||||
return Err(bad("hyperlink.create", "give `url`, `email` or `page`"));
|
||||
};
|
||||
p.get("page").and_then(Value::as_u64).map(|pg| HyperlinkDest::Page((pg as usize).saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dest = dest_param(p).ok_or_else(|| bad("hyperlink.create", "give `url`, `email` or `page`"))?;
|
||||
let st = s.doc()?;
|
||||
let sources: Vec<HyperlinkSource> = if let Some(ids) = super::ids_param(p, "ids") {
|
||||
ids.into_iter().map(|id| HyperlinkSource::Item { id }).collect()
|
||||
|
|
@ -88,3 +129,30 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"ids": ids}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn hyperlinks_edit_and_go_to_source_bookmarks_rename() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Visit us"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 6, "focus": 8})).unwrap();
|
||||
let h = s.execute("hyperlink.create", &json!({"url": "https://a.example"})).unwrap()["ids"][0].clone();
|
||||
s.execute("hyperlink.edit", &json!({"id": h, "url": "https://b.example", "name": "Site"})).unwrap();
|
||||
let l = s.execute("hyperlink.list", &json!({})).unwrap();
|
||||
assert_eq!(l[0]["name"], "Site");
|
||||
assert!(l[0].to_string().contains("b.example"));
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
s.execute("hyperlink.goToSource", &json!({"id": h})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (6, 8));
|
||||
s.execute("bookmark.add", &json!({"page": 2})).unwrap();
|
||||
s.execute("bookmark.rename", &json!({"index": 0, "name": "Back"})).unwrap();
|
||||
assert_eq!(s.execute("bookmark.list", &json!({})).unwrap()[0]["name"], "Back");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
|||
("conditions", "Conditional Text", "panel-conditions"),
|
||||
("notes", "Notes", "panel-notes"),
|
||||
("library", "Library", "panel-library"),
|
||||
("hyperlinks", "Hyperlinks", "panel-hyperlinks"),
|
||||
("bookmarks", "Bookmarks", "panel-bookmarks"),
|
||||
];
|
||||
|
||||
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
|
|
@ -171,6 +173,8 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
|
|||
"conditions" => panels::conditions::show(app, ui),
|
||||
"notes" => panels::notes::show(app, ui),
|
||||
"library" => panels::library::show(app, ui),
|
||||
"hyperlinks" => panels::interactive::hyperlinks(app, ui),
|
||||
"bookmarks" => panels::interactive::bookmarks(app, ui),
|
||||
"table" => panels::table::show(app, ui),
|
||||
_ => panels::properties::info_panel(app, ui),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,6 +310,15 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
|
|||
pen.circle(10.0, 10.0, 7.0);
|
||||
pen.line(&[(6.5, 10.0), (9.0, 12.5), (13.5, 7.5)]);
|
||||
}
|
||||
"panel-hyperlinks" => {
|
||||
// Two chain links.
|
||||
pen.closed(&[(3.0, 10.0), (7.0, 6.0), (10.0, 6.0), (7.0, 9.0), (6.0, 11.0), (7.0, 13.0), (5.0, 14.0)]);
|
||||
pen.closed(&[(17.0, 10.0), (13.0, 14.0), (10.0, 14.0), (13.0, 11.0), (14.0, 9.0), (13.0, 7.0), (15.0, 6.0)]);
|
||||
pen.line(&[(8.0, 12.0), (12.0, 8.0)]);
|
||||
}
|
||||
"panel-bookmarks" => {
|
||||
pen.closed(&[(6.0, 3.0), (14.0, 3.0), (14.0, 17.0), (10.0, 13.0), (6.0, 17.0)]);
|
||||
}
|
||||
"panel-library" => {
|
||||
// Books on a shelf.
|
||||
pen.rect(3.0, 4.0, 7.0, 16.0);
|
||||
|
|
|
|||
|
|
@ -474,6 +474,10 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
|||
"ui:window.panel|Notes|{\"panel\": \"notes\"}",
|
||||
"ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}",
|
||||
"<",
|
||||
">Interactive",
|
||||
"ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}",
|
||||
"ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}",
|
||||
"<",
|
||||
">Utilities",
|
||||
"cmd:data.merge",
|
||||
"<",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
//! Hyperlinks and Bookmarks panels (Window › Interactive).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::DesignApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
fn dest_text(d: &Value) -> String {
|
||||
if let Some(u) = d.get("url").and_then(Value::as_str) {
|
||||
return u.to_string();
|
||||
}
|
||||
if let Some(e) = d.get("email").and_then(Value::as_str) {
|
||||
return format!("mailto:{e}");
|
||||
}
|
||||
if let Some(p) = d.get("page").and_then(Value::as_u64) {
|
||||
return format!("Page {}", p + 1);
|
||||
}
|
||||
d.to_string()
|
||||
}
|
||||
|
||||
pub fn hyperlinks(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = egui::Id::new("new_hyperlink_url");
|
||||
let mut url: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| "https://".into());
|
||||
let has_target = app.session.active().is_some_and(|d| d.selection.text.is_some_and(|t| !t.is_caret()) || !d.selection.items.is_empty());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut url).desired_width(170.0));
|
||||
if ui.add_enabled(has_target, egui::Button::new("New")).on_hover_text("Hyperlink from the selected text or frames").clicked() {
|
||||
let p = if let Some(m) = url.strip_prefix("mailto:") { json!({"email": m}) } else { json!({"url": url}) };
|
||||
if let Err(e) = app.run("hyperlink.create", p) {
|
||||
app.status(format!("Hyperlinks: {e}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, url));
|
||||
ui.separator();
|
||||
let list = app.session.execute("hyperlink.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||||
if list.is_empty() {
|
||||
ui.label(egui::RichText::new("No hyperlinks. Select text or a frame, type a URL and click New.").size(11.0).color(t.text_dim));
|
||||
}
|
||||
for h in list {
|
||||
let hid = h["id"].clone();
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.selectable_label(false, h["name"].as_str().unwrap_or("")).on_hover_text(dest_text(&h["dest"]));
|
||||
if r.clicked() {
|
||||
let _ = app.run("hyperlink.goToSource", json!({"id": hid}));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Delete").clicked() {
|
||||
let _ = app.run("hyperlink.delete", json!({"id": hid}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bookmarks(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if ui.button("New Bookmark for This Page").clicked() {
|
||||
let page = crate::canvas::current_page(app).unwrap_or(0) + 1;
|
||||
let _ = app.run("bookmark.add", json!({"page": page}));
|
||||
}
|
||||
ui.separator();
|
||||
let list = app.session.execute("bookmark.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||||
if list.is_empty() {
|
||||
ui.label(egui::RichText::new("No bookmarks. They become the PDF outline.").size(11.0).color(t.text_dim));
|
||||
}
|
||||
for (i, b) in list.iter().enumerate() {
|
||||
let page = b["page"].as_u64().unwrap_or(0) as usize;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.selectable_label(false, b["name"].as_str().unwrap_or("")).on_hover_text(format!("Page {}", app.session.page_label(page))).clicked()
|
||||
{
|
||||
crate::canvas::go_to_page(app, page);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Delete").clicked() {
|
||||
let _ = app.run("bookmark.delete", json!({"index": i}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub mod conditions;
|
||||
pub mod glyphs;
|
||||
pub mod interactive;
|
||||
pub mod layers;
|
||||
pub mod library;
|
||||
pub mod notes;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
|
|||
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
|
||||
|
||||
<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 87%** over 275 features.
|
||||
**Weighted breadth parity: 88%** over 275 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 99 | 3 | 0 | 99% |
|
||||
| P1 | 97 | 76 | 20 | 1 | 89% |
|
||||
| P1 | 97 | 78 | 18 | 1 | 90% |
|
||||
| P2 | 76 | 23 | 13 | 40 | 39% |
|
||||
|
||||
| Area | Parity |
|
||||
|
|
@ -28,7 +28,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
|||
| Styles | 92% |
|
||||
| Tables | 92% |
|
||||
| Long documents | 88% |
|
||||
| Interactivity & digital | 45% |
|
||||
| Interactivity & digital | 65% |
|
||||
| Output & production | 75% |
|
||||
| XML & automation | 61% |
|
||||
| View & navigation | 82% |
|
||||
|
|
@ -268,8 +268,8 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
|
|||
| Long documents | Running headers via variables | P1 | D |
|
||||
| Long documents | Footnotes/endnotes | P1 | D |
|
||||
| Long documents | Captions | P2 | P |
|
||||
| Interactivity & digital | Hyperlinks panel | P1 | P |
|
||||
| Interactivity & digital | Bookmarks | P1 | P |
|
||||
| Interactivity & digital | Hyperlinks panel | P1 | D |
|
||||
| Interactivity & digital | Bookmarks | P1 | D |
|
||||
| Interactivity & digital | Buttons and Forms | P2 | M |
|
||||
| Interactivity & digital | Object States | P2 | M |
|
||||
| Interactivity & digital | Articles panel | P2 | M |
|
||||
|
|
|
|||
Loading…
Reference in New Issue