mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
R: crash recovery, Revert, Save a Copy
- engine::recovery: unsaved documents written to a recovery folder (atomic write + metadata), reopened as unsaved documents that remember their files; entries dropped on save/close (+ tests); file.recovery.list/save/open, file.revert, file.saveACopy (+ test) - app: recovery folder per platform, recovered on launch with a status message; UI writes recovery data every 30 s while documents are unsaved; File › Save a Copy…, Revert - parity: recovery, revert, save a copy done Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7145107896
commit
4696178f62
@@ -26,6 +26,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
|
||||
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
|
||||
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
|
||||
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
|
||||
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
|
||||
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
|
||||
|
||||
@@ -128,7 +128,14 @@ fn main() -> eframe::Result {
|
||||
"DesignCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = DesignApp::new(Session::new(), services());
|
||||
let mut session = Session::new();
|
||||
// Crash recovery: reopen what a previous run left unsaved, then keep it current.
|
||||
session.recovery_dir = designcraft_engine::recovery::default_dir();
|
||||
let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok();
|
||||
let mut app = DesignApp::new(session, services());
|
||||
if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) {
|
||||
app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" }));
|
||||
}
|
||||
load_prefs(&mut app);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(port) = control_port {
|
||||
|
||||
@@ -21,6 +21,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string();
|
||||
let bytes = to_bytes(&s.doc()?.doc);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
|
||||
cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect()))
|
||||
}),
|
||||
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
Ok(json!({"saved": crate::recovery::save(s, &dir)?}))
|
||||
}),
|
||||
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
Ok(json!({"opened": crate::recovery::open(s, &dir)?}))
|
||||
}),
|
||||
cmd!(query "file.serialize", "Serialize", [], None, "{} → {json}", has_doc, |s, _| Ok(json!({"json": String::from_utf8_lossy(&to_bytes(&s.doc()?.doc)).to_string()}))),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0);
|
||||
@@ -157,6 +177,11 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.path = Some(path.clone());
|
||||
st.saved_revision = st.revision;
|
||||
st.saved_doc = st.doc.clone();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(dir) = &s.recovery_dir {
|
||||
let uid = s.doc()?.uid;
|
||||
crate::recovery::discard(dir, uid);
|
||||
}
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -392,3 +417,53 @@ fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": new.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
})
|
||||
}
|
||||
|
||||
fn recovery_dir(s: &Session, p: &Value) -> Result<std::path::PathBuf> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (s, p);
|
||||
Err(EngineError::Other("no recovery folder on the web".into()))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
str_param(p, "dir")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| s.recovery_dir.clone())
|
||||
.ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)"))
|
||||
}
|
||||
|
||||
fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.active() {
|
||||
Some(d) if d.path.is_some() && d.is_dirty() => Ok(()),
|
||||
Some(d) if d.path.is_none() => Err("the document has never been saved".into()),
|
||||
Some(_) => Err("no changes since the last save".into()),
|
||||
None => Err("no document open".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let d = if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)?
|
||||
};
|
||||
let uid = s.doc()?.uid;
|
||||
let st = s.doc_mut()?;
|
||||
let mut fresh = DocState::new(d, Some(path.clone()));
|
||||
fresh.uid = uid;
|
||||
fresh.revision = st.revision + 1;
|
||||
*st = fresh;
|
||||
if let Some(dir) = &s.recovery_dir {
|
||||
crate::recovery::discard(dir, uid);
|
||||
}
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (s, path);
|
||||
Err(EngineError::Other("revert isn't available on the web".into()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,4 +207,4 @@ pub fn file_bytes(d: &designcraft_doc::Document) -> Vec<u8> {
|
||||
pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> {
|
||||
file::from_bytes(b)
|
||||
}
|
||||
pub use file::{base64_decode, base64_encode};
|
||||
pub use file::{base64_decode, base64_encode, from_bytes, to_bytes};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod links;
|
||||
pub mod recovery;
|
||||
pub mod sample;
|
||||
pub mod script;
|
||||
mod tooling;
|
||||
@@ -140,6 +141,8 @@ pub struct Session {
|
||||
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
|
||||
pub journal: Vec<(String, Value)>,
|
||||
pub clipboard: Option<Arc<Document>>,
|
||||
/// Crash-recovery folder (the app sets it; saving or closing a document removes its entry).
|
||||
pub recovery_dir: Option<std::path::PathBuf>,
|
||||
/// Copied text with its formatting (a frameless story slice) and its plain text.
|
||||
pub text_clipboard: Option<(Arc<designcraft_doc::Story>, String)>,
|
||||
/// Requests for the UI (dialogs, view changes) produced by tools/commands.
|
||||
@@ -165,6 +168,7 @@ impl Session {
|
||||
tool: designcraft_tools::create("selection"),
|
||||
journal: vec![],
|
||||
clipboard: None,
|
||||
recovery_dir: None,
|
||||
text_clipboard: None,
|
||||
ui_requests: vec![],
|
||||
loaded: None,
|
||||
@@ -201,6 +205,10 @@ impl Session {
|
||||
self.docs.len() - 1
|
||||
}
|
||||
pub fn close_document(&mut self, i: usize) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
|
||||
recovery::discard(dir, d.uid);
|
||||
}
|
||||
if i < self.docs.len() {
|
||||
self.docs.remove(i);
|
||||
self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Crash recovery (InDesign's automatic recovery data): unsaved changes of every open document are
|
||||
//! written to a recovery folder (`file.recovery.save`, called on a timer by the app); saving or
|
||||
//! closing a document removes its file; after a crash `file.recovery.open` reopens what was left
|
||||
//! there as unsaved documents that remember where they were saved.
|
||||
//!
|
||||
//! A recovery entry is `<uid>.designcraft` (the document) plus `<uid>.json` (`{"path", "title",
|
||||
//! "saved"}`: the original file, the title, and when it was written).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
/// The platform's per-user recovery folder.
|
||||
pub fn default_dir() -> Option<PathBuf> {
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home.map(|h| h.join("Library/Application Support/DesignCraft/Recovery"));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("DesignCraft").join("Recovery"));
|
||||
}
|
||||
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("designcraft/recovery"))
|
||||
}
|
||||
|
||||
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
(dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json")))
|
||||
}
|
||||
|
||||
/// Remove a document's recovery entry (it was saved or closed).
|
||||
pub fn discard(dir: &Path, uid: u64) {
|
||||
let (d, m) = files(dir, uid);
|
||||
let _ = std::fs::remove_file(d);
|
||||
let _ = std::fs::remove_file(m);
|
||||
}
|
||||
|
||||
/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how
|
||||
/// many were written.
|
||||
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
let mut n = 0;
|
||||
for d in &s.docs {
|
||||
if !d.is_dirty() {
|
||||
discard(dir, d.uid);
|
||||
continue;
|
||||
}
|
||||
let (doc, meta) = files(dir, d.uid);
|
||||
let bytes = crate::cmd::to_bytes(&d.doc);
|
||||
// Write then rename, so a crash mid-write never leaves a torn file.
|
||||
let tmp = doc.with_extension("tmp");
|
||||
std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, &doc)).map_err(|e| EngineError::Other(format!("{}: {e}", doc.display())))?;
|
||||
let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
|
||||
std::fs::write(&meta, m.to_string()).map_err(|e| EngineError::Other(format!("{}: {e}", meta.display())))?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Recovery entries in `dir`: (uid, metadata).
|
||||
pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("designcraft") {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
let meta = std::fs::read_to_string(p.with_extension("json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
|
||||
out.push((uid, meta));
|
||||
}
|
||||
out.sort_by_key(|e| e.0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Reopen every recovery entry as an unsaved document (and remove the entries: the new session
|
||||
/// writes its own). Returns the opened documents' indices.
|
||||
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
let mut opened = Vec::new();
|
||||
for (uid, meta) in list(dir) {
|
||||
let (doc, _) = files(dir, uid);
|
||||
let Ok(bytes) = std::fs::read(&doc) else { continue };
|
||||
let d = match crate::cmd::from_bytes(&bytes) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
let mut st = DocState::new(d, path);
|
||||
// Unsaved: the copy on disk (if any) is older than what was recovered.
|
||||
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
|
||||
st.revision += 1;
|
||||
opened.push(s.add_document(st));
|
||||
discard(dir, uid);
|
||||
}
|
||||
Ok(opened)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recovers_unsaved_documents() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-recovery-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// Only the edited document is written.
|
||||
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
assert_eq!(save(&s, &dir).unwrap(), 1);
|
||||
assert_eq!(list(&dir).len(), 1);
|
||||
// "Crash": a new session reopens it, unsaved, with the item.
|
||||
let mut s2 = Session::new();
|
||||
let opened = open(&mut s2, &dir).unwrap();
|
||||
assert_eq!(opened.len(), 1);
|
||||
let st = s2.doc().unwrap();
|
||||
assert!(st.is_dirty());
|
||||
assert_eq!(st.doc.spreads[0].items.len(), 1);
|
||||
assert!(list(&dir).is_empty(), "entries are consumed");
|
||||
// Saving a document drops its entry.
|
||||
s.recovery_dir = Some(dir.clone());
|
||||
assert_eq!(save(&s, &dir).unwrap(), 1);
|
||||
let path = dir.join("saved.designcraft");
|
||||
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(list(&dir).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod file_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn revert_and_save_a_copy() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-revert-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, copy) = (dir.join("a.designcraft"), dir.join("copy.designcraft"));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(s.execute("file.revert", &json!({})).is_err(), "never saved");
|
||||
s.execute("file.saveAs", &json!({"path": a.to_string_lossy()})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
// A copy keeps the document unsaved and on its own file.
|
||||
s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert!(copy.exists());
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(a.to_string_lossy().as_ref()));
|
||||
// Revert drops the frame.
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.spreads[0].items.is_empty());
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,8 @@ pub struct DesignApp {
|
||||
/// Shortcuts the native menu handles (skip them in the egui shortcut handler).
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
last_time: f64,
|
||||
/// When recovery data was last written (seconds, egui time).
|
||||
pub last_recovery: f64,
|
||||
}
|
||||
|
||||
impl DesignApp {
|
||||
@@ -270,6 +272,7 @@ impl DesignApp {
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
last_time: 0.0,
|
||||
last_recovery: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +384,16 @@ impl DesignApp {
|
||||
self.restyle = false;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Crash recovery: unsaved documents are written to the recovery folder every 30 s.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.session.recovery_dir.is_some() && now - self.last_recovery > 30.0 {
|
||||
self.last_recovery = now;
|
||||
if self.session.documents().iter().any(|d| d.is_dirty())
|
||||
&& let Err(e) = self.session.execute("file.recovery.save", &json!({}))
|
||||
{
|
||||
self.status(format!("Couldn't write recovery data: {e}"));
|
||||
}
|
||||
}
|
||||
let dt = now - self.last_time;
|
||||
if dt > 0.0 {
|
||||
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
|
||||
|
||||
@@ -11,6 +11,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.placeDialog", "Place…", Some("Cmd+D"), "{}"),
|
||||
("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
|
||||
("app.save", "Save", Some("Cmd+S"), "{}"),
|
||||
("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"),
|
||||
("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
|
||||
("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"),
|
||||
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
|
||||
@@ -77,6 +78,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:file.close",
|
||||
"ui:app.save",
|
||||
"ui:app.saveDialog",
|
||||
"ui:app.saveCopyDialog",
|
||||
"cmd:file.revert",
|
||||
"-",
|
||||
"ui:app.placeDialog",
|
||||
"-",
|
||||
@@ -426,6 +429,13 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.saveCopyDialog" => {
|
||||
let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default();
|
||||
if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) {
|
||||
return Some(app.run("file.saveACopy", json!({"path": path})));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.exportPng" => export_png(app, p),
|
||||
"app.exportIdml" => export_idml(app, p),
|
||||
"app.storyEditor" => {
|
||||
|
||||
+3
-3
@@ -54,7 +54,7 @@ Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup
|
||||
| Application shell & workspace | Keyboard shortcut editor | P1 | M |
|
||||
| Application shell & workspace | Menu customization | P2 | M |
|
||||
| Application shell & workspace | Show Full Menus | P2 | M |
|
||||
| Application shell & workspace | Recovery / autosave | P0 | M |
|
||||
| Application shell & workspace | Recovery / autosave | P0 | D |
|
||||
| Application shell & workspace | Preferences | P0 | P |
|
||||
| Application shell & workspace | Localization | P2 | M |
|
||||
| Application shell & workspace | Scripting (own API: commands, scripts, MCP) | P2 | D |
|
||||
@@ -310,8 +310,8 @@ Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup
|
||||
| View & navigation | Rotate spread view | P2 | M |
|
||||
| View & navigation | Find/zoom to object | P1 | P |
|
||||
| Undo, history, saving | Unlimited undo/redo | P0 | D |
|
||||
| Undo, history, saving | Revert | P0 | P |
|
||||
| Undo, history, saving | Save / Save As / Save a Copy | P0 | P |
|
||||
| Undo, history, saving | Revert | P0 | D |
|
||||
| Undo, history, saving | Save / Save As / Save a Copy | P0 | D |
|
||||
| Accessibility | Tagged PDF export | P2 | M |
|
||||
| Accessibility | Alt text | P2 | M |
|
||||
| Accessibility | Keyboard navigation of UI | P1 | P |
|
||||
|
||||
Reference in New Issue
Block a user