mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
refactor(engine): move lib.rs items to {command_sets,doc_state,error,history,interaction,prefs,sanity,session,session/documents,session/edit,session/execute,session/undo}.rs
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
/// A session with no document open, the default preferences and the Selection tool. It also
|
||||
/// installs the hooks the document crate calls back into: the loader and file reader of placed
|
||||
/// documents, and where an image's effects draw its fills and strokes, for clicks.
|
||||
pub fn new() -> Self {
|
||||
// Placed documents are read by the native format's loader.
|
||||
vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document);
|
||||
vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again);
|
||||
vectorcraft_doc::placed_document::set_read_check(|path| file_access::check_read(path).is_ok());
|
||||
// The background workers of placed documents read files in the scope that asked.
|
||||
vectorcraft_render::placed_document::set_scope_capture(|| {
|
||||
let scope = file_access::ScopeHandle::capture();
|
||||
Box::new(move |f: &mut dyn FnMut()| scope.run(f))
|
||||
});
|
||||
// Clicks find an image's own fills and strokes where their effects draw them.
|
||||
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
|
||||
// OpenType SVG glyphs are drawn as the SVG importer reads their documents.
|
||||
vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art);
|
||||
Self {
|
||||
docs: vec![],
|
||||
active: None,
|
||||
prefs: Prefs::default(),
|
||||
paint: PaintDefaults::default(),
|
||||
fill_active: true,
|
||||
clipboard: Clipboard::default(),
|
||||
journal: Journal::default(),
|
||||
tool: vectorcraft_tools::create("selection"),
|
||||
lender: None,
|
||||
last_selection_tool: None,
|
||||
last_view: ViewInfo::default(),
|
||||
preview_memo: Default::default(),
|
||||
depth: 0,
|
||||
tool_panic: None,
|
||||
tool_resets: 0,
|
||||
tool_set_since_reset: false,
|
||||
draw_mode: DrawMode::Normal,
|
||||
draw_inside: None,
|
||||
untitled_counter: 0,
|
||||
menu: Default::default(),
|
||||
gradient_stop: None,
|
||||
active_appearance_item: None,
|
||||
last_solid: Color::WHITE,
|
||||
last_gradient: GradientPaint::new(Default::default()),
|
||||
recent_colors: vec![],
|
||||
pending_paint: None,
|
||||
swatch_libraries: Default::default(),
|
||||
freeform_point: None,
|
||||
style_libraries: Default::default(),
|
||||
symbol_libraries: Default::default(),
|
||||
brush_libraries: Default::default(),
|
||||
libraries: Default::default(),
|
||||
pdf_preset_dir: None,
|
||||
profile_dir: None,
|
||||
plugin_dir: None,
|
||||
plugin_output: None,
|
||||
recent_urls: vec![],
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
note_depth: 1,
|
||||
journal_query: false,
|
||||
explicit_undo_group: None,
|
||||
batch_stash: None,
|
||||
batch_steps: None,
|
||||
place_hand_off: None,
|
||||
queue_hand_off: None,
|
||||
place_cursor_requests: false,
|
||||
package_hand_off: None,
|
||||
screens_hand_off: None,
|
||||
recovery: Default::default(),
|
||||
in_tool_actions: false,
|
||||
previewing: false,
|
||||
envelope_defaults: None,
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
pointer_held: false,
|
||||
ruler_guide: None,
|
||||
port_marks: Default::default(),
|
||||
font_search: None,
|
||||
link_search: None,
|
||||
search_rules: None,
|
||||
deferred_overlay_cache: None,
|
||||
search_threads: None,
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
global_edit_options: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn documents(&self) -> &[DocState] {
|
||||
&self.docs
|
||||
}
|
||||
|
||||
/// The open document with [`DocState::uid`] `uid` (it may have closed since it was looked up).
|
||||
pub fn document_mut(&mut self, uid: u64) -> Option<&mut DocState> {
|
||||
self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
}
|
||||
|
||||
pub fn active_index(&self) -> Option<usize> {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub fn active(&self) -> Option<&DocState> {
|
||||
self.active.and_then(|i| self.docs.get(i))
|
||||
}
|
||||
|
||||
pub fn active_mut(&mut self) -> Option<&mut DocState> {
|
||||
self.active.and_then(|i| self.docs.get_mut(i))
|
||||
}
|
||||
|
||||
pub fn doc(&self) -> Result<&DocState> {
|
||||
self.active().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
|
||||
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
|
||||
self.active_mut().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
|
||||
/// Finish the tool's pending work in the current document and give it fresh state, so nothing
|
||||
/// (e.g. uncommitted typing) leaks into the document we are switching to.
|
||||
fn reset_tool_for_doc_switch(&mut self) {
|
||||
if self.active.is_none() {
|
||||
return;
|
||||
}
|
||||
let view = self.last_view;
|
||||
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
|
||||
let _ = self.give_back_tool(view);
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
|
||||
let _ = self.apply_actions(acts);
|
||||
// A batch leaves its interaction open in the document it leaves, to keep or roll back with
|
||||
// the rest of the batch; anything else in progress (a drag) is cancelled.
|
||||
if self.batch_stash.is_none() {
|
||||
let _ = self.cancel_interaction();
|
||||
}
|
||||
self.keep_tool_settings();
|
||||
self.tool = self.make_tool(self.tool.id());
|
||||
self.tool_resets = self.tool_resets.wrapping_add(1);
|
||||
self.tool_set_since_reset = false;
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, index: usize) -> bool {
|
||||
if index < self.docs.len() {
|
||||
if self.active != Some(index) {
|
||||
self.reset_tool_for_doc_switch();
|
||||
}
|
||||
self.active = Some(index);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The name [`Session::next_untitled`] gives next (New Document's Name field).
|
||||
pub fn peek_untitled(&self) -> String {
|
||||
format!("Untitled-{}", self.untitled_counter + 1)
|
||||
}
|
||||
|
||||
pub fn next_untitled(&mut self) -> String {
|
||||
self.untitled_counter += 1;
|
||||
format!("Untitled-{}", self.untitled_counter)
|
||||
}
|
||||
|
||||
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
|
||||
/// selection boxes and hit testing would use the rough estimate until each text is edited, and
|
||||
/// what is fitted to type's bounds (an envelope warping it) would come out differently. That
|
||||
/// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too,
|
||||
/// not only in the layers (#1738).
|
||||
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
|
||||
// Inline graphics in text take their size from their symbols' art (not saved either).
|
||||
doc.resolve_inline_art();
|
||||
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
|
||||
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
|
||||
for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) {
|
||||
refresh_tree_text_bounds(tree);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
|
||||
/// an undo group with the begun group's name opens in it: here outside a command, and after the
|
||||
/// command that adds it otherwise ([`Session::execute`]).
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
// Every edit is checked against the document before it, so the first one is within the cap
|
||||
// (importers and the native reader have seen to theirs already, with a warning).
|
||||
doc.limit_depth();
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
// Guides from files saved before guides had layers go on a layer.
|
||||
doc.adopt_guides();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
self.reset_tool_for_doc_switch();
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.limit = self.prefs.history_states as usize;
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
if self.depth == 0
|
||||
&& let Some(label) = self.explicit_undo_group.clone()
|
||||
{
|
||||
self.begin_named_undo_group(label);
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Replace the document in tab `index` (File → Revert): new content, cleared history and
|
||||
/// selection, saved state. The tab keeps its place, path, format and view.
|
||||
pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool {
|
||||
if index >= self.docs.len() {
|
||||
return false;
|
||||
}
|
||||
doc.limit_depth();
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
doc.adopt_guides();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
if self.active == Some(index) {
|
||||
// Pending tool work (typing, a drag) belongs to the content being thrown away.
|
||||
self.reset_tool_for_doc_switch();
|
||||
}
|
||||
let old = &self.docs[index];
|
||||
let mut st = DocState::new(doc, old.path.clone());
|
||||
st.history.limit = old.history.limit;
|
||||
// Same open document (caches keyed by uid stay valid); a new revision redraws it.
|
||||
st.uid = old.uid;
|
||||
st.revision = old.revision + 1;
|
||||
st.format = old.format;
|
||||
st.save_options = old.save_options.clone();
|
||||
// The remembered artboard pick follows the artboards it named when the file read back has
|
||||
// the artboards the document had at the save, in the same order and with the same ids (a
|
||||
// native file keeps them; a PDF's pages get new ids in page order); otherwise it is left
|
||||
// out.
|
||||
if old.save_artboards.is_empty() || st.doc.artboards.iter().map(|a| a.id).eq(old.save_artboards.iter().copied()) {
|
||||
st.save_artboards = old.save_artboards.clone();
|
||||
} else {
|
||||
for k in cmd::fileio::ARTBOARD_PARAMS {
|
||||
st.save_options.remove(k);
|
||||
}
|
||||
}
|
||||
st.converted = old.converted;
|
||||
st.imported_from = old.imported_from.clone();
|
||||
st.import_losses = old.import_losses.clone();
|
||||
st.view = old.view.clone();
|
||||
st.layers_open = old.layers_open.clone();
|
||||
let old = std::mem::replace(&mut self.docs[index], st);
|
||||
if let Some(stash) = &mut self.batch_stash {
|
||||
stash.push(old);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn close_document(&mut self, index: usize) -> bool {
|
||||
if index >= self.docs.len() {
|
||||
return false;
|
||||
}
|
||||
self.reset_tool_for_doc_switch();
|
||||
// Closed (saved or discarded): nothing left to recover.
|
||||
let uid = self.docs[index].uid;
|
||||
cmd::recovery::forget(self, uid);
|
||||
let old = self.docs.remove(index);
|
||||
if let Some(stash) = &mut self.batch_stash {
|
||||
stash.push(old);
|
||||
}
|
||||
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
|
||||
true
|
||||
}
|
||||
|
||||
/// Commands with enablement (for menus, palette, MCP `list_commands`).
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the
|
||||
/// nodes on the way to such type are copied.
|
||||
fn refresh_tree_text_bounds(n: &mut Arc<vectorcraft_doc::Node>) {
|
||||
fn stale(n: &vectorcraft_doc::Node) -> bool {
|
||||
matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none())
|
||||
|| n.extras().mask.as_deref().is_some_and(|m| stale(&m.art))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| stale(c)))
|
||||
}
|
||||
if !stale(n) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if let NodeKind::Text(t) = &mut n.kind
|
||||
&& t.cached_bounds.is_none()
|
||||
{
|
||||
cmd::typecmd::refresh_bounds(t);
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
|
||||
refresh_tree_text_bounds(&mut m.art);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
refresh_tree_text_bounds(c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
/// Run `f` on a mutable copy of the active document. Outside an interaction this records one
|
||||
/// undo step labelled `label`; inside one it just mutates (the interaction commits later).
|
||||
pub fn edit<T>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>) -> Result<T> {
|
||||
// An edit made while typing (the Transform panel) is a step of its own: inside the typing
|
||||
// session, the next keystroke's preview would start again from before it (#1881).
|
||||
if !self.previewing && cmd::typing_in_progress(self) {
|
||||
cmd::settle_interaction(self)?;
|
||||
}
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
// The Global Edit members the edit reached besides the selection.
|
||||
let mut spread = vec![];
|
||||
// Objects attached to a path follow it, and dimensions follow the art they measure.
|
||||
let result = match f(doc, &mut st.selection).and_then(|v| {
|
||||
// Global Edit: the other members take what the edit changed about the one it edited.
|
||||
if let Some(ge) = &st.global_edit {
|
||||
spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]);
|
||||
}
|
||||
cmd::objects_on_path::refresh(&before, doc);
|
||||
cmd::dimension::refresh(&before, doc)?;
|
||||
Ok(v)
|
||||
}) {
|
||||
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
|
||||
Err(e) => Err(e),
|
||||
Ok(()) => {
|
||||
// Attached Shaper groups whose art was rebuilt go back on their path.
|
||||
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Inline graphics in text follow their symbols.
|
||||
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
|
||||
// Type in no thread numbers its list paragraphs from 1 (before the mask copies
|
||||
// its editing layer's art).
|
||||
cmd::threads::restart_unthreaded_counts(&mut st.doc);
|
||||
// Opacity-mask editing: the mask follows its art on the editing layer.
|
||||
if st.doc.mask_edit.is_some() {
|
||||
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
// Threaded text re-flows when any of its frames changed.
|
||||
if !st.doc.text_threads.is_empty() {
|
||||
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
// Asset Export: assets let go of deleted art.
|
||||
if !st.doc.assets.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_assets();
|
||||
}
|
||||
// Variables: bindings let go of deleted art.
|
||||
if !st.doc.variables.bindings.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_variable_bindings();
|
||||
}
|
||||
if st.doc.too_deep(Some(&before)) {
|
||||
// Deeper trees overflow the stack of what walks them.
|
||||
Err(EngineError::Other(TOO_DEEP.into()))
|
||||
} else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match result {
|
||||
Ok(v) => {
|
||||
st.selection.prune(&st.doc);
|
||||
// An edit that selects what it unlocks, shows or pastes leaves locked guides out,
|
||||
// and one that selects the slices it makes leaves them out while they are locked
|
||||
// or hidden.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
// The art on a locked artboard can't stay selected, however it got there, except
|
||||
// the new art this edit made and new art still selected.
|
||||
st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before));
|
||||
st.revision += 1;
|
||||
if st.interaction.is_none() {
|
||||
let selection = match st.history.undo.last() {
|
||||
Some(l) if *l.selection == before_sel => l.selection.clone(),
|
||||
_ => Arc::new(before_sel),
|
||||
};
|
||||
st.push_undo(label.to_string(), before, selection);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
st.doc = before;
|
||||
st.selection = before_sel;
|
||||
cmd::globaledit::follow_history(st);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Session::edit`] with the step named `template`, its `{name}` placeholder filled with `name`
|
||||
/// (a style's name). The undo step it records keeps the template and the name
|
||||
/// ([`HistoryEntry::named`]), and a frontend shows the template's translation with the name in
|
||||
/// it. An edit that records no step of its own (inside an interaction, or a later edit of an
|
||||
/// undo group) keeps neither, and neither does a step an undo group names.
|
||||
pub fn edit_named<T>(
|
||||
&mut self,
|
||||
template: &'static str,
|
||||
name: &str,
|
||||
built_in: bool,
|
||||
f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let named = NamedStep { template, name: name.to_string(), built_in };
|
||||
let step = named.label();
|
||||
// The step this edit records keeps the document as it is now.
|
||||
let now = Arc::as_ptr(&self.doc()?.doc);
|
||||
let v = self.edit(&step, f)?;
|
||||
if let Ok(st) = self.doc_mut()
|
||||
&& let Some(e) = st.history.undo.last_mut()
|
||||
&& Arc::as_ptr(&e.doc) == now
|
||||
&& e.label == step
|
||||
{
|
||||
e.named = Some(named);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Change only the selection (not an undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
let new_art = st.selection.new_art.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Lock Guides locks the paths made into guides too (#1534), and Lock Slices and Hide
|
||||
// Slices keep the slices out, whatever selects them.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
// The art on a locked artboard can't be selected, whatever selects it, except new art
|
||||
// that was selected and still is.
|
||||
st.protect_locked_artboards(&before, &new_art, None);
|
||||
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
|
||||
// the reference app: new art then goes beside it.
|
||||
if st.selection.objects != before
|
||||
&& let Some(layer) = st.selection.objects.last().and_then(|id| st.doc.layer_containing(*id)).filter(|l| st.doc.is_editable(*l))
|
||||
&& st.active_layer != Some(layer)
|
||||
{
|
||||
st.active_layer = Some(layer);
|
||||
st.layer_rows.clear();
|
||||
}
|
||||
st.revision += 1;
|
||||
// Puppet Warp pins belong to the art they were placed on: another selection starts afresh.
|
||||
if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) {
|
||||
cmd::distortcmds::drop_puppet_pins(st);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- interactions (live drags) ----------
|
||||
|
||||
pub(crate) fn clear_deferred_overlay_cache(&mut self) {
|
||||
self.deferred_overlay_cache = None;
|
||||
}
|
||||
|
||||
/// The interaction's latest preview command when the document is still its snapshot (deferred
|
||||
/// Real-time Drawing), regardless of the preference at commit or overlay time.
|
||||
pub(crate) fn unapplied_interaction_preview(&self) -> Option<(String, Value)> {
|
||||
self.active().and_then(|d| d.interaction.as_ref().filter(|it| std::sync::Arc::ptr_eq(&it.doc, &d.doc)).and_then(|it| it.preview.clone()))
|
||||
}
|
||||
|
||||
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
|
||||
// Typing in progress is kept first: a drag or a dialog's preview would otherwise start
|
||||
// from the snapshot taken before the typing, and drop it (#1881).
|
||||
if label != "Typing" && cmd::typing_in_progress(self) {
|
||||
cmd::settle_interaction(self)?;
|
||||
}
|
||||
self.clear_deferred_overlay_cache();
|
||||
let st = self.doc_mut()?;
|
||||
if st.interaction.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
st.interaction = Some(Interaction {
|
||||
label: label.to_string(),
|
||||
doc: st.doc.clone(),
|
||||
selection: Arc::new(st.selection.clone()),
|
||||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
layer_rows: st.layer_rows.clone(),
|
||||
variables_highlight: st.variables_highlight.clone(),
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
copies: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview).
|
||||
pub fn preview(&mut self, cmd: &str, params: &Value) -> Result<Value> {
|
||||
{
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
|
||||
// Undoing the previous preview counts as a change, so views redraw even when the command
|
||||
// below returns without an edit.
|
||||
let selection_changed = st.selection != *it.selection;
|
||||
if !Arc::ptr_eq(&st.doc, &it.doc) || selection_changed {
|
||||
st.revision += 1;
|
||||
}
|
||||
st.doc = it.doc.clone();
|
||||
if selection_changed {
|
||||
st.selection.clone_from(&it.selection);
|
||||
}
|
||||
// Guides locked and slices locked or hidden during the interaction stay deselected,
|
||||
// even when the preview fails.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
cmd::globaledit::follow_history(st);
|
||||
}
|
||||
// The previous preview's paint is undone with it.
|
||||
self.pending_paint = None;
|
||||
let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params).or_else(|| cmd::bounds_rewrite(self, cmd, params));
|
||||
let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p));
|
||||
let outer = std::mem::replace(&mut self.previewing, true);
|
||||
let r = self.execute(cmd, params);
|
||||
self.previewing = outer;
|
||||
let params = self.noted(params);
|
||||
let st = self.doc_mut()?;
|
||||
if let Some(it) = &mut st.interaction {
|
||||
// A failed preview leaves the document at the snapshot, so nothing is left to commit
|
||||
// (not even an earlier successful preview, which the reset above already undid).
|
||||
it.preview = r.is_ok().then(|| (cmd.to_string(), params));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub fn commit_interaction(&mut self) -> Result<()> {
|
||||
self.clear_deferred_overlay_cache();
|
||||
self.remember_pending_paint();
|
||||
let st = self.doc_mut()?;
|
||||
let Some(mut it) = st.interaction.take() else { return Ok(()) };
|
||||
let Some(preview) = it.preview.take() else { return Ok(()) };
|
||||
let perspective_again = it.perspective_again.take();
|
||||
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
// Alt pressed or released during a move drag turns it into a copy or back: name it for
|
||||
// what it did in the end.
|
||||
match (it.label.as_str(), copy) {
|
||||
("Move", true) => it.label = "Copy".into(),
|
||||
("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(),
|
||||
_ => {}
|
||||
}
|
||||
// A preview that left the document as it was records no Transform Again either (as
|
||||
// `keep_interaction` records no undo step): the last real transformation stands.
|
||||
let changed = !Arc::ptr_eq(&it.doc, &st.doc);
|
||||
st.keep_interaction(it);
|
||||
if preview.0 == "object.transform" {
|
||||
let m = cmd::matrix_param(&preview.1, "matrix");
|
||||
if let Some(m) = m {
|
||||
// The options are the last preview's params with the values it noted, without the
|
||||
// matrix and the ids.
|
||||
let mut options = preview.1.clone();
|
||||
if let Some(o) = options.as_object_mut() {
|
||||
o.remove("matrix");
|
||||
o.remove("ids");
|
||||
}
|
||||
st.last_transform = Some(LastTransform::Matrix(m, options));
|
||||
st.last_perspective = None;
|
||||
}
|
||||
} else if preview.0 == "object.transformEach" && changed {
|
||||
// Transform Again repeats it as it stood when OK was pressed.
|
||||
st.last_transform = Some(LastTransform::Each(preview.1.clone()));
|
||||
st.last_perspective = None;
|
||||
}
|
||||
if perspective_again.is_some() {
|
||||
st.last_perspective = perspective_again;
|
||||
}
|
||||
let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs());
|
||||
self.journal.push(preview);
|
||||
self.journal_in_undo_groups(doc, &grouped, journal);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember the paint a live preview applied (as its interaction is kept).
|
||||
pub(crate) fn remember_pending_paint(&mut self) {
|
||||
if let Some(p) = self.pending_paint.take() {
|
||||
self.remember_paint_now(&p);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_interaction(&mut self) -> Result<()> {
|
||||
self.clear_deferred_overlay_cache();
|
||||
self.pending_paint = None;
|
||||
let menu = self.menu.clone();
|
||||
let st = self.doc_mut()?;
|
||||
st.undo_interaction();
|
||||
// Guides locked and slices locked or hidden during the interaction stay deselected.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn in_interaction(&self) -> bool {
|
||||
self.active().is_some_and(|d| d.interaction.is_some())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
/// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back.
|
||||
fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec<NodeId>) -> std::result::Result<(), String> {
|
||||
let Some(d) = self.active_mut() else { return (spec.enabled)(self) };
|
||||
let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() });
|
||||
let r = (spec.enabled)(self);
|
||||
if let Some(d) = self.active_mut() {
|
||||
d.selection = old;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or
|
||||
/// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects.
|
||||
fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> {
|
||||
let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) };
|
||||
let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) };
|
||||
for value in given {
|
||||
let node = cmd::checked_id(self, value, id)?;
|
||||
if st.doc.node(node).is_some_and(|n| n.is_layer()) {
|
||||
return Err(if id.starts_with("object.arrange.") {
|
||||
cmd::bad(id, format!("id {value} is a layer, not an object; move layers with layer.move"))
|
||||
} else {
|
||||
cmd::bad(id, format!("id {value} is a layer, not an object"))
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
|
||||
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
self.check_params(id, params)?;
|
||||
// Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked
|
||||
// before anything else: a malformed one, or one naming no object, is an error under this
|
||||
// command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}`
|
||||
// without `effect` names the effect (its older key), not an object.
|
||||
let effect_named = id == "effect.apply"
|
||||
&& params.get("effect").is_none()
|
||||
&& params.get("ids").is_none_or(Value::is_null)
|
||||
&& params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$'));
|
||||
if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() {
|
||||
cmd::given_objects(self, params, id)?;
|
||||
}
|
||||
if SELECTS_GIVEN_IDS.contains(&id) {
|
||||
self.check_given_ids(id, params)?;
|
||||
}
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
let given = given_ids(spec.params, params);
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
|
||||
cmd::has_doc(self)
|
||||
} else if let Some(ids) = given.clone() {
|
||||
// Objects given as `ids` (or `id`) stand in for the selection: the command is enabled
|
||||
// as it is with them selected (#1575).
|
||||
self.enabled_with_selection(spec, ids)
|
||||
} else {
|
||||
(spec.enabled)(self)
|
||||
};
|
||||
if let Err(why) = enabled {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
// Only top-level commands are journaled (commands that call other commands would otherwise
|
||||
// be recorded twice and replay differently).
|
||||
if self.depth == 0 {
|
||||
self.journal_note.clear();
|
||||
self.note_depth = 1;
|
||||
self.journal_query = false;
|
||||
self.batch_stash = None;
|
||||
self.batch_steps = None;
|
||||
}
|
||||
// A selection-only command given `ids` (or `id`) selects them first, in the same undo step
|
||||
// (#2130); when the command then fails, the selection (and the current layer selecting
|
||||
// them made current) goes back to how it was, as when the ids are refused.
|
||||
let select_first = SELECTS_GIVEN_IDS
|
||||
.contains(&id)
|
||||
.then(|| {
|
||||
params.get("ids").filter(|v| !v.is_null()).cloned().or_else(|| given.map(|ids| ids.iter().map(|i| i.0).collect::<Vec<_>>().into()))
|
||||
})
|
||||
.flatten();
|
||||
let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer));
|
||||
let run = |s: &mut Session| {
|
||||
if let Some(ids) = &select_first {
|
||||
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
|
||||
}
|
||||
let r = (spec.run)(s, params);
|
||||
if r.is_err()
|
||||
&& let Some((uid, selection, layer)) = selected_before
|
||||
&& let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid)
|
||||
{
|
||||
st.selection = selection;
|
||||
st.active_layer = layer;
|
||||
st.revision += 1;
|
||||
}
|
||||
r
|
||||
};
|
||||
// The entries the command journals belong to the undo groups of the documents it edits, and to
|
||||
// the group of the document it starts in when it ends there too.
|
||||
let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs());
|
||||
let open = (self.depth == 0).then(|| self.docs.iter().map(|d| d.uid).collect::<Vec<_>>());
|
||||
let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) };
|
||||
let r = r?;
|
||||
if self.depth == 0 {
|
||||
self.inherit_new_art();
|
||||
}
|
||||
if let Some(open) = open {
|
||||
self.open_undo_groups_in_new_documents(&open);
|
||||
}
|
||||
let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query));
|
||||
if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) {
|
||||
let p = self.noted(params);
|
||||
self.journal.push((id.to_string(), p));
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.journal_in_undo_groups(doc, &grouped, journal);
|
||||
self.after_command();
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Is the interface in Japanese? Then new type starts with em box top-to-top leading and em
|
||||
/// box centre character alignment (#432).
|
||||
pub fn japanese_interface(&self) -> bool {
|
||||
self.ui_language.as_deref().unwrap_or(&self.prefs.interface_language).eq_ignore_ascii_case("ja")
|
||||
}
|
||||
|
||||
/// Record `key: value` in the running top-level command's journal entry (or its interaction's
|
||||
/// preview, or its step in a batch) unless its params give `key`: a value it resolved from the
|
||||
/// preferences or the clock.
|
||||
pub fn note_journal(&mut self, key: &str, value: Value) {
|
||||
if self.depth == self.note_depth {
|
||||
self.journal_note.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Journal the running query command when it is the top-level command: it changed the
|
||||
/// selection (`document.find {select: true}`), so an action recorded around it repeats that.
|
||||
pub(crate) fn journal_query(&mut self) {
|
||||
if self.depth == 1 {
|
||||
self.journal_query = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `id` as a step of the running command, which journals its steps (`command.batch`):
|
||||
/// → the step's result and its params with what it noted ([`Session::note_journal`]), for the
|
||||
/// step in the running command's journal entry.
|
||||
pub(crate) fn execute_step(&mut self, id: &str, params: &Value) -> Result<(Value, Value)> {
|
||||
let outer = (std::mem::take(&mut self.journal_note), self.note_depth);
|
||||
self.note_depth = self.depth + 1;
|
||||
let r = self.execute(id, params);
|
||||
let noted = self.noted(params);
|
||||
(self.journal_note, self.note_depth) = outer;
|
||||
Ok((r?, noted))
|
||||
}
|
||||
|
||||
/// The journal from entry `start` on, as an action records it: without the params that only
|
||||
/// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now.
|
||||
pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> {
|
||||
self.journal
|
||||
.since(start)
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|(id, mut p)| {
|
||||
strip_replay_only(&id, &mut p);
|
||||
(id, p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `params` with the noted values added (see [`Session::note_journal`]).
|
||||
pub(super) fn noted(&mut self, params: &Value) -> Value {
|
||||
let mut note = std::mem::take(&mut self.journal_note);
|
||||
match params {
|
||||
// The params' own values win.
|
||||
Value::Object(m) if !note.is_empty() => {
|
||||
note.extend(m.clone());
|
||||
Value::Object(note)
|
||||
}
|
||||
Value::Null if !note.is_empty() => Value::Object(note),
|
||||
_ => params.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_nested(&mut self, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
|
||||
self.depth += 1;
|
||||
let r = f(self);
|
||||
self.depth -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
/// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and the active
|
||||
/// document goes back to how it was before the command, instead of crashing the frontend.
|
||||
pub(crate) fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
|
||||
let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone()));
|
||||
let active = self.active;
|
||||
let r = match guard::catch_panic(|| self.run_nested(f)) {
|
||||
Ok(r) => r,
|
||||
Err(msg) => {
|
||||
self.depth = 0;
|
||||
self.journal_note.clear();
|
||||
if let Some((uid, doc, selection, interaction)) = snapshot
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
{
|
||||
st.doc = doc;
|
||||
st.selection = selection;
|
||||
st.interaction = interaction;
|
||||
cmd::globaledit::follow_history(st);
|
||||
st.revision += 1;
|
||||
}
|
||||
if active.is_some_and(|i| i < self.docs.len()) {
|
||||
self.active = active;
|
||||
}
|
||||
Err(EngineError::Internal { cmd: id.to_string(), msg })
|
||||
}
|
||||
};
|
||||
// Undo, Redo and a failed batch bring back documents with their selections: each document
|
||||
// gets its selection's new art on a locked artboard.
|
||||
for st in &mut self.docs {
|
||||
st.sync_new_art();
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
// ---------- undo groups (scrubbed numeric fields) ----------
|
||||
|
||||
/// Open an undo group in the active document: until [`Session::end_undo_group`], the edits
|
||||
/// made there (commands, committed interactions) are one undo step. A scrubbed numeric field
|
||||
/// applies each value it passes as its own command, as a typed value is applied; the drag is
|
||||
/// one step.
|
||||
pub fn begin_undo_group(&mut self) {
|
||||
self.begin_named_undo_group(None);
|
||||
}
|
||||
|
||||
/// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit).
|
||||
pub fn begin_named_undo_group(&mut self, label: Option<String>) {
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.undo_group.is_none()
|
||||
{
|
||||
st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label });
|
||||
}
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, open an undo group with the begun
|
||||
/// group's name in each document that the top-level command just run opened or created (its
|
||||
/// [`DocState::uid`] is not in `open`). The group takes the edits after that command: the
|
||||
/// command's own edits there stay its undo step, and a canceled group keeps them, as the journal
|
||||
/// entry that made the document stays.
|
||||
pub(super) fn open_undo_groups_in_new_documents(&mut self, open: &[u64]) {
|
||||
let Some(label) = &self.explicit_undo_group else { return };
|
||||
for st in &mut self.docs {
|
||||
if !open.contains(&st.uid) && st.undo_group.is_none() {
|
||||
st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label: label.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The documents with an undo group open ([`DocState::uid`]), each with the document it holds.
|
||||
pub(super) fn undo_group_docs(&self) -> Vec<(u64, *const Document)> {
|
||||
self.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, Arc::as_ptr(&st.doc))).collect()
|
||||
}
|
||||
|
||||
/// Note the journal entries from number `from` on in the undo groups they belong to: the group of
|
||||
/// each document in `grouped` ([`Session::undo_group_docs`] before the entries) that they changed,
|
||||
/// each group they opened and edited, and the group of the document with [`DocState::uid`] `doc`
|
||||
/// when they started in it and it is still the active one. A cancel drops them. The last entry of a command that changed the
|
||||
/// active document keeps the index of the document it left active. A `command.batch` entry also
|
||||
/// keeps, in each group, the steps that belong to it ([`Session::batch_steps`]).
|
||||
pub(super) fn journal_in_undo_groups(&mut self, doc: Option<u64>, grouped: &[(u64, *const Document)], from: usize) {
|
||||
let to = self.journal.len();
|
||||
let active = self.active().map(|d| d.uid);
|
||||
let switched = if active == doc { None } else { self.active };
|
||||
let batch = self.batch_steps.take().filter(|_| to > from && self.journal.get(to - 1).is_some_and(|(id, _)| id == "command.batch"));
|
||||
for st in &mut self.docs {
|
||||
let Some(g) = &mut st.undo_group else { continue };
|
||||
let started = doc == Some(st.uid) && active == doc;
|
||||
// A group the entries opened has an edit of theirs once it has a first edit.
|
||||
let edited = match grouped.iter().find(|&&(uid, _)| uid == st.uid) {
|
||||
Some(&(_, d)) => !std::ptr::eq(d, Arc::as_ptr(&st.doc)),
|
||||
None => g.first.is_some(),
|
||||
};
|
||||
if started || edited {
|
||||
// A committed interaction inside the command noted its entry already.
|
||||
let noted = g.journal.partition_point(|&(n, _)| n < from);
|
||||
g.journal.truncate(noted);
|
||||
g.journal.extend((from..to).map(|n| (n, switched.filter(|_| n + 1 == to))));
|
||||
g.steps.retain(|&(n, _)| n < from);
|
||||
if let Some(steps) = &batch {
|
||||
let own = steps.iter().enumerate().filter(|(_, (uids, _))| uids.contains(&st.uid));
|
||||
g.steps.push((to - 1, own.map(|(i, &(_, switched))| (i, switched)).collect()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are
|
||||
/// undone and dropped from the journal. A group begun with `edit.undoGroup.begin` ends too, so
|
||||
/// documents opened after this open no undo group.
|
||||
pub fn end_undo_group(&mut self, cancel: bool) {
|
||||
self.explicit_undo_group = None;
|
||||
self.close_undo_groups(cancel, None);
|
||||
}
|
||||
|
||||
/// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`, for the
|
||||
/// document with [`DocState::uid`] `only` alone when given. The groups that stay open follow the
|
||||
/// journal entries and batch steps of theirs that a cancel renumbers.
|
||||
fn close_undo_groups(&mut self, cancel: bool, only: Option<u64>) {
|
||||
let mut journal: Vec<(usize, Option<usize>)> = vec![];
|
||||
let mut steps: Vec<BatchSteps> = vec![];
|
||||
for st in &mut self.docs {
|
||||
if only.is_some_and(|uid| uid != st.uid) {
|
||||
continue;
|
||||
}
|
||||
let Some(g) = st.undo_group.take() else { continue };
|
||||
// Only while the group's step is still the newest one.
|
||||
if cancel
|
||||
&& let Some(first) = g.first
|
||||
&& st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, &first))
|
||||
&& let Some(e) = st.history.undo.pop()
|
||||
{
|
||||
st.doc = e.doc;
|
||||
st.selection = Arc::unwrap_or_clone(e.selection);
|
||||
deselect_hidden(&self.menu, &st.doc, &mut st.selection);
|
||||
st.sync_new_art();
|
||||
cmd::globaledit::follow_history(st);
|
||||
st.revision += 1;
|
||||
// The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile.
|
||||
journal.extend(g.journal);
|
||||
steps.extend(g.steps);
|
||||
}
|
||||
}
|
||||
// A batch that edited two of the documents is in both groups.
|
||||
journal.sort_unstable();
|
||||
journal.dedup_by_key(|&mut (n, _)| n);
|
||||
// A batch with steps outside the canceled groups keeps those steps.
|
||||
let mut rewritten: Vec<(usize, Vec<Option<usize>>)> = vec![];
|
||||
journal.retain(|&(n, _)| match self.keep_batch_steps(n, &steps) {
|
||||
Some(kept) => {
|
||||
rewritten.push((n, kept));
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
});
|
||||
// An entry that changed the active document becomes that change, so the entries after it run
|
||||
// in the same document when the journal is replayed.
|
||||
for &(n, index) in &journal {
|
||||
if let Some(index) = index {
|
||||
self.journal.replace(n, ("document.activate".to_string(), serde_json::json!({ "index": index })));
|
||||
}
|
||||
}
|
||||
let gone: Vec<usize> = journal.into_iter().filter(|(_, index)| index.is_none()).map(|(n, _)| n).collect();
|
||||
self.journal.remove(&gone);
|
||||
let below = |n: usize| gone.partition_point(|&m| m < n);
|
||||
for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) {
|
||||
g.journal.retain(|(n, _)| gone.binary_search(n).is_err());
|
||||
for (n, _) in &mut g.journal {
|
||||
*n -= below(*n);
|
||||
}
|
||||
g.steps.retain(|(n, _)| gone.binary_search(n).is_err());
|
||||
for (n, own) in &mut g.steps {
|
||||
if let Some((_, kept)) = rewritten.iter().find(|(r, _)| r == n) {
|
||||
own.retain_mut(|(i, _)| kept.get(*i).copied().flatten().map(|k| *i = k).is_some());
|
||||
}
|
||||
*n -= below(*n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When journal entry `n` is a `command.batch` with `steps` noted in canceled undo groups and
|
||||
/// other steps that are not `document.activate`: rewrite its `commands` without the noted steps
|
||||
/// (a noted step that changed the active document becomes that change) → each step's number in
|
||||
/// the rewritten `commands`, `None` for a noted step. `None` leaves the entry to be dropped as a
|
||||
/// whole.
|
||||
fn keep_batch_steps(&mut self, n: usize, steps: &[BatchSteps]) -> Option<Vec<Option<usize>>> {
|
||||
let noted: Vec<(usize, Option<usize>)> = steps.iter().filter(|(e, _)| *e == n).flat_map(|(_, s)| s.iter().copied()).collect();
|
||||
let (id, entry) = self.journal.get(n).filter(|(id, _)| id == "command.batch" && steps.iter().any(|(e, _)| *e == n))?;
|
||||
let commands = entry.get("commands").and_then(Value::as_array)?;
|
||||
let kept = |i: usize| !noted.iter().any(|&(s, _)| s == i);
|
||||
let activate = |c: &Value| c.get("command").and_then(Value::as_str) == Some("document.activate");
|
||||
if commands.iter().enumerate().all(|(i, c)| !kept(i) || activate(c)) {
|
||||
return None;
|
||||
}
|
||||
let (mut rewritten, mut numbers) = (Vec::with_capacity(commands.len()), Vec::with_capacity(commands.len()));
|
||||
for (i, c) in commands.iter().enumerate() {
|
||||
if kept(i) {
|
||||
numbers.push(Some(rewritten.len()));
|
||||
rewritten.push(c.clone());
|
||||
} else {
|
||||
numbers.push(None);
|
||||
if let Some(index) = noted.iter().find(|&&(s, _)| s == i).and_then(|&(_, index)| index) {
|
||||
rewritten.push(serde_json::json!({ "command": "document.activate", "params": { "index": index } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (id, mut entry) = (id.clone(), entry.clone());
|
||||
if let Some(o) = entry.as_object_mut() {
|
||||
o.insert("commands".into(), Value::Array(rewritten));
|
||||
}
|
||||
self.journal.replace(n, (id, entry));
|
||||
Some(numbers)
|
||||
}
|
||||
|
||||
/// [`Session::end_undo_group`] for the open document with [`DocState::uid`] `uid` only (it may
|
||||
/// have closed since): its open undo group closes, and undo groups open in other documents stay
|
||||
/// open.
|
||||
fn end_document_undo_group(&mut self, uid: u64, cancel: bool) {
|
||||
self.close_undo_groups(cancel, Some(uid));
|
||||
}
|
||||
|
||||
/// Make the edits that follow in the active document one undo step, until
|
||||
/// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it,
|
||||
/// so that the edits are part of its step. `None` without a document.
|
||||
pub fn begin_undo_scope(&mut self) -> Option<UndoScope> {
|
||||
let journal = self.journal.len();
|
||||
let st = self.active()?;
|
||||
let uid = st.uid;
|
||||
let joined = st.undo_group.as_ref().map(|g| (st.doc.clone(), st.selection.clone(), g.first.clone()));
|
||||
if joined.is_none() {
|
||||
self.begin_undo_group();
|
||||
}
|
||||
Some(UndoScope { uid, journal, joined })
|
||||
}
|
||||
|
||||
/// End `scope` in its own document, whichever document is active now; undo groups open in other
|
||||
/// documents stay open. The undo group the scope opened closes as [`Session::end_undo_group`]
|
||||
/// closes it. A scope that joined an open group leaves the group open; with `cancel`, it puts the
|
||||
/// document and its selection back as they were when the scope began and cuts the journal back to
|
||||
/// where the scope began (only while the group's step is still the newest one), and an undo step
|
||||
/// that the scope's first edit recorded goes too. The open undo groups drop the cut entries.
|
||||
pub fn end_undo_scope(&mut self, scope: UndoScope, cancel: bool) {
|
||||
let Some((doc, selection, first)) = scope.joined else {
|
||||
self.end_document_undo_group(scope.uid, cancel);
|
||||
return;
|
||||
};
|
||||
let menu = self.menu.clone();
|
||||
let Some(st) = self.document_mut(scope.uid) else { return };
|
||||
let Some(g) = &mut st.undo_group else { return };
|
||||
let newest = g.first.as_ref().is_some_and(|f| st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, f)));
|
||||
if !cancel || !newest {
|
||||
return;
|
||||
}
|
||||
match &first {
|
||||
// The scope's first edit recorded the group's step.
|
||||
None => {
|
||||
st.history.undo.pop();
|
||||
g.first = None;
|
||||
}
|
||||
Some(f) if g.first.as_ref().is_some_and(|now| Arc::ptr_eq(now, f)) => {}
|
||||
Some(_) => return,
|
||||
}
|
||||
st.doc = doc;
|
||||
st.selection = selection;
|
||||
cmd::globaledit::follow_history(st);
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
st.history.recost_last(&st.doc);
|
||||
st.revision += 1;
|
||||
self.journal.truncate(scope.journal);
|
||||
// The next commands take the cut entries' numbers, so no open undo group keeps them.
|
||||
for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) {
|
||||
g.journal.retain(|&(n, _)| n < scope.journal);
|
||||
g.steps.retain(|&(n, _)| n < scope.journal);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user