mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
437 lines
22 KiB
Rust
437 lines
22 KiB
Rust
//! File menu: documents, save/open, import/export, versions.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use base64::Engine as _;
|
|
use figcraft_doc::{
|
|
BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle,
|
|
VdCompat,
|
|
};
|
|
use figcraft_format::Version;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, ok};
|
|
use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_string, string, targets};
|
|
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
|
|
|
mod export;
|
|
mod load;
|
|
mod open;
|
|
mod save;
|
|
mod version;
|
|
|
|
pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS;
|
|
#[cfg(test)]
|
|
use export::add_member;
|
|
pub(super) use export::export_setting;
|
|
pub use export::{
|
|
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
|
|
frames_pdf, jobs_for, safe_file_name,
|
|
};
|
|
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
|
|
#[cfg(test)]
|
|
pub(crate) use load::{installed_family, sketch_line};
|
|
pub use load::{load, load_bytes, load_path};
|
|
pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
|
|
pub use open::MAX_RECENT;
|
|
#[cfg(test)]
|
|
use open::append_pages;
|
|
use open::{import, open, open_recent};
|
|
pub(crate) use save::save_ends_preview;
|
|
pub use save::{FileSnapshot, file_bytes, thumbnail};
|
|
use save::{color_profile, mark_saved, save, save_as};
|
|
pub use version::MAX_AUTOSAVES;
|
|
pub(crate) use version::{add_version, keep_in_history, read_version, unique_id};
|
|
use version::{autosave, restore_version, save_version, version_index, version_json, version_list};
|
|
|
|
/// A new file of `kind` in a tab of its own.
|
|
fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result<Value> {
|
|
let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into());
|
|
let mut doc = Document::new(name);
|
|
doc.kind = kind;
|
|
// A property of the page from here on: a later change of theme leaves it alone. Boards are
|
|
// light whatever the theme.
|
|
let background = if kind == FileKind::Board { BOARD_PAGE } else { s.new_page_background() };
|
|
for page in doc.pages.clone() {
|
|
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
|
*b = background;
|
|
}
|
|
}
|
|
s.docs.push(DocState::new(doc, None));
|
|
s.active = s.docs.len() - 1;
|
|
Ok(json!({"index": s.active, "kind": kind.id()}))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)),
|
|
spec!(
|
|
"file.newBoard",
|
|
"New board",
|
|
"{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page",
|
|
false,
|
|
|s, p| new_file(s, p, FileKind::Board)
|
|
),
|
|
spec!(
|
|
"file.open",
|
|
"Open",
|
|
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
|
false,
|
|
open
|
|
),
|
|
spec!(
|
|
"file.openRecent",
|
|
"Open recent",
|
|
"{path? | index? (into the list), clear? (forget them all), remove? (a path: forget that one)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
|
|
false,
|
|
open_recent
|
|
),
|
|
spec!(
|
|
"file.save",
|
|
"Save",
|
|
"{path?, overwrite? (replace what another app wrote to the file since it was opened or saved: without it that save is refused)} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
|
false,
|
|
save
|
|
),
|
|
spec!(
|
|
"file.saveAs",
|
|
"Save as",
|
|
"{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
|
false,
|
|
save_as
|
|
),
|
|
spec!(
|
|
"file.markSaved",
|
|
"Mark saved",
|
|
"{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)",
|
|
false,
|
|
mark_saved
|
|
),
|
|
spec!(
|
|
"file.export",
|
|
"Export",
|
|
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
|
|
false,
|
|
export
|
|
),
|
|
spec!(
|
|
"file.exportLayers",
|
|
"Export layers",
|
|
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
|
|
false,
|
|
export_layers
|
|
),
|
|
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
|
|
let mut q = p.clone();
|
|
if let Some(o) = q.as_object_mut() {
|
|
o.insert("format".into(), json!("svg"));
|
|
}
|
|
export(s, &q)
|
|
}),
|
|
spec!(
|
|
"file.exportFramesPdf",
|
|
"Export frames to PDF",
|
|
"{path?, page? (page id, default the current page), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)} every top-level frame in reading order, one PDF page each; returns base64 `data` without a path",
|
|
false,
|
|
export_frames_pdf
|
|
),
|
|
spec!(
|
|
"file.import",
|
|
"Import",
|
|
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
|
true,
|
|
import
|
|
),
|
|
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
|
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
|
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
|
|
let from = req_index("file.moveTab", p, "from")?;
|
|
let to = req_index("file.moveTab", p, "to")?;
|
|
if from >= s.docs.len() || to >= s.docs.len() {
|
|
return Err(bad("file.moveTab", "no such document"));
|
|
}
|
|
if from != to {
|
|
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
|
let doc = s.docs.remove(from);
|
|
s.docs.insert(to, doc);
|
|
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
|
|
}
|
|
Ok(json!({"index": to}))
|
|
}),
|
|
spec!(
|
|
"file.recover",
|
|
"Recover file",
|
|
"{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
|
|
false,
|
|
|s, p| {
|
|
let path = req_string("file.recover", p, "path")?;
|
|
s.docs.push(recovered(&path)?);
|
|
s.active = s.docs.len() - 1;
|
|
Ok(json!({"index": s.active}))
|
|
}
|
|
),
|
|
spec!("file.rename", "Rename file", "{name}", true, |s, p| {
|
|
let name = req_string("file.rename", p, "name")?;
|
|
s.doc_mut()?.name = name;
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"file.colorProfile",
|
|
"Color profile",
|
|
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
|
|
true,
|
|
color_profile
|
|
),
|
|
spec!("file.duplicate", "Duplicate file", "{}", false, |s, _| {
|
|
let ds = s.ds()?;
|
|
let mut d = (*ds.doc).clone();
|
|
d.name = format!("{} (Copy)", d.name);
|
|
s.docs.push(DocState::new(d, None));
|
|
s.active = s.docs.len() - 1;
|
|
Ok(json!({"index": s.active}))
|
|
}),
|
|
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
|
|
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
|
// Stamped before reading: a change made during the read makes the next save refuse.
|
|
let on_disk = crate::library::stat(&path).ok();
|
|
let (loaded, _) = load_path(&path, None)?;
|
|
let i = s.active;
|
|
let uid = s.ds()?.uid;
|
|
if let Some(slot) = s.docs.get_mut(i) {
|
|
*slot = DocState::new(loaded.doc, Some(path.clone()))
|
|
.with_versions(loaded.versions)
|
|
.with_branches(loaded.branches)
|
|
.with_images_left_in_file(&path, loaded.images_past_budget);
|
|
slot.on_disk = on_disk;
|
|
}
|
|
// Edits on its branch tabs are reverted too.
|
|
crate::branches::close_tabs(s, uid, None);
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"file.saveVersion",
|
|
"Save to version history",
|
|
"{name?, description?} snapshot the document into the file's version history (on a branch tab: the branch's own history; kept when the file is saved); returns the version",
|
|
false,
|
|
save_version
|
|
),
|
|
spec!("file.versionHistory", "Show version history", "{} opens the panel; returns the versions like `version.list`", false, |s, _| {
|
|
let list = version_list(s.ds()?);
|
|
s.request_ui("panel.versionHistory", json!({}));
|
|
Ok(list)
|
|
}),
|
|
spec!(
|
|
"version.list",
|
|
"List versions",
|
|
"{} the version history, oldest first: [{id, index, name, description, time (Unix seconds), auto?, previewing?}]",
|
|
false,
|
|
|s, _| Ok(version_list(s.ds()?))
|
|
),
|
|
spec!(
|
|
"version.restore",
|
|
"Restore version",
|
|
"{id | index} replace the design with the version (one undo step; the file name and the comments stay; the design as it was is kept in the history first, unless a version of it was just saved or restored)",
|
|
true,
|
|
restore_version
|
|
),
|
|
spec!("version.rename", "Rename version", "{id | index, name, description?} a titled autosave becomes a named version", false, |s, p| {
|
|
const CMD: &str = "version.rename";
|
|
let name = req_string(CMD, p, "name")?;
|
|
if name.trim().is_empty() {
|
|
return Err(bad(CMD, "`name` can't be empty"));
|
|
}
|
|
let ds = s.ds_mut()?;
|
|
let i = version_index(CMD, ds, p)?;
|
|
let description = string(p, "description");
|
|
let v = ds.versions_mut().get_mut(i).ok_or_else(|| bad(CMD, "no such version"))?;
|
|
v.name = figcraft_format::clip(name.trim(), figcraft_format::MAX_VERSION_NAME);
|
|
// A titled autosave is a named version (never dropped to make room).
|
|
v.auto = false;
|
|
if let Some(d) = description {
|
|
v.description = figcraft_format::clip(d.trim(), figcraft_format::MAX_VERSION_DESCRIPTION);
|
|
}
|
|
Ok(version_json(i, v))
|
|
}),
|
|
spec!("version.delete", "Delete version", "{id | index}", false, |s, p| {
|
|
let ds = s.ds_mut()?;
|
|
let i = version_index("version.delete", ds, p)?;
|
|
let v = ds.versions_mut().remove(i);
|
|
if ds.preview.as_ref().is_some_and(|pv| pv.id == v.id) {
|
|
ds.end_preview();
|
|
}
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"version.preview",
|
|
"View version",
|
|
"{id | index} show the version on the canvas in place of the document (read-only; `version.restore` keeps it, `version.exitPreview` goes back)",
|
|
false,
|
|
|s, p| {
|
|
let ds = s.ds_mut()?;
|
|
let i = version_index("version.preview", ds, p)?;
|
|
let v = ds.versions.get(i).ok_or_else(|| bad("version.preview", "no such version"))?;
|
|
let (id, name, time, out) = (v.id.clone(), v.name.clone(), v.time, version_json(i, v));
|
|
let doc = read_version(v)?;
|
|
ds.start_preview(&id, &name, time, doc);
|
|
Ok(out)
|
|
}
|
|
),
|
|
spec!(
|
|
"version.autosave",
|
|
"Autosave version",
|
|
"{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}",
|
|
false,
|
|
autosave
|
|
),
|
|
spec!("version.exitPreview", "Back to current version", "{} end a version preview", false, |s, _| {
|
|
Ok(json!({"ended": s.ds_mut()?.end_preview()}))
|
|
}),
|
|
spec!(
|
|
"version.duplicate",
|
|
"Duplicate version",
|
|
"{id | index} open the version as a new document; returns its tab `index`",
|
|
false,
|
|
|s, p| {
|
|
let ds = s.ds()?;
|
|
let v = ds.versions.get(version_index("version.duplicate", ds, p)?).ok_or_else(|| bad("version.duplicate", "no such version"))?;
|
|
let mut doc = (*read_version(v)?).clone();
|
|
doc.name = format!("{} ({})", ds.doc.name, v.name);
|
|
doc.take_dirty();
|
|
s.docs.push(DocState::new(doc, None));
|
|
s.active = s.docs.len() - 1;
|
|
Ok(json!({"index": s.active}))
|
|
}
|
|
),
|
|
spec!("doc.switch", "Switch document", "{index}", false, |s, p| {
|
|
let i = req_index("doc.switch", p, "index")?;
|
|
if i >= s.docs.len() {
|
|
return Err(bad("doc.switch", "no such document"));
|
|
}
|
|
s.active = i;
|
|
ok()
|
|
}),
|
|
spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)),
|
|
spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)),
|
|
spec!("doc.list", "List documents", "{}", false, |s, _| {
|
|
Ok(Value::Array(
|
|
s.docs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
|
|
.collect(),
|
|
))
|
|
}),
|
|
spec!("doc.home", "Home", "{}", false, |s, _| {
|
|
s.request_ui("home", json!({}));
|
|
ok()
|
|
}),
|
|
]
|
|
}
|
|
|
|
/// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only;
|
|
/// empty for the other formats).
|
|
pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec<u8>, Vec<String>)> {
|
|
let nothing = || EngineError::Disabled("file.export".into(), "nothing to export".into());
|
|
if ids.is_empty() {
|
|
return Err(nothing());
|
|
}
|
|
let drawn = drawn_layers(doc, ids, setting);
|
|
if setting.format.is_vector() {
|
|
figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?;
|
|
}
|
|
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
|
|
// The layers' own area unless an option or a slice says otherwise.
|
|
let own_area = setting.contents_only
|
|
&& !setting.include_bounding_box
|
|
&& !ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
|
|
match setting.format {
|
|
// SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
|
|
ExportFormat::Svg => {
|
|
let opts = figcraft_svg::SvgOptions {
|
|
include_ids: setting.svg_id_attribute,
|
|
outline_text: setting.svg_outline_text,
|
|
simplify_stroke: setting.svg_simplify_stroke,
|
|
bounds: Some(area),
|
|
};
|
|
let svg = figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e {
|
|
figcraft_svg::SvgError::Empty => nothing(),
|
|
other => EngineError::Other(other.to_string()),
|
|
})?;
|
|
Ok((svg.into_bytes(), Vec::new()))
|
|
}
|
|
// Android colours are sRGB too.
|
|
ExportFormat::VectorDrawable => {
|
|
let compat = match setting.vd_compat {
|
|
VdCompat::BestEffort => figcraft_vectordrawable::Compat::BestEffort,
|
|
VdCompat::Exact => figcraft_vectordrawable::Compat::Exact,
|
|
VdCompat::Ignore => figcraft_vectordrawable::Compat::Ignore,
|
|
};
|
|
let opts = figcraft_vectordrawable::VdOptions { bounds: Some(area), include_names: setting.svg_id_attribute, compat };
|
|
let out = figcraft_vectordrawable::try_export(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e {
|
|
figcraft_vectordrawable::VdError::Empty => nothing(),
|
|
other => EngineError::Other(other.to_string()),
|
|
})?;
|
|
Ok((out.xml.into_bytes(), out.warnings))
|
|
}
|
|
ExportFormat::Pdf => {
|
|
let doc = &*doc.in_srgb();
|
|
// Frames export one page each; anything else shares one page.
|
|
let all_frames = own_area && ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
|
|
let groups: Vec<Vec<NodeId>> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![drawn] };
|
|
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
|
|
}
|
|
_ => {
|
|
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
|
|
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
|
Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error).
|
|
fn vd_compat_param(cmd: &str, p: &Value) -> Result<Option<VdCompat>> {
|
|
match get(p, "vdCompat") {
|
|
None | Some(Value::Null) => Ok(None),
|
|
Some(v) => v.as_str().and_then(VdCompat::from_key).map(Some).ok_or_else(|| bad(cmd, "vdCompat: bestEffort|exact|ignore")),
|
|
}
|
|
}
|
|
|
|
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? };
|
|
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
|
|
// A branch tab's edits are in its main file already (which then has unsaved changes).
|
|
if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
|
|
s.request_ui("dialog.unsaved", json!({"index": i}));
|
|
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
|
|
}
|
|
let uid = ds.uid;
|
|
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
|
s.remove_tabs(|d| d.uid == uid);
|
|
// Removing a tab before the active one shifts its index; keep the same document selected.
|
|
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
|
|
crate::branches::close_tabs(s, uid, None);
|
|
ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tab_order_tests;
|
|
|
|
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
|
|
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
|
|
let n = s.docs.len() as i64;
|
|
if n == 0 {
|
|
return Err(crate::EngineError::NoDocument);
|
|
}
|
|
s.active = (s.active as i64 + dir).rem_euclid(n) as usize;
|
|
Ok(json!({"index": s.active}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod rest_import_tests;
|
|
|
|
#[cfg(test)]
|
|
mod staging_write_tests;
|
|
|
|
#[cfg(test)]
|
|
mod zip_cap_tests;
|