mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
* Draw Table and Eraser: draw tables and split or merge cells with a pen (#303) Insert › Tables › Draw Table and Table Layout › Draw › Draw Table / Eraser. table.draw takes a pen stroke in page points: a rectangle outside a table inserts a one-cell table of that size at the paragraph under its start; a straight vertical or horizontal line inside a table splits the cells it crosses (new grid column or row; untouched cells span it). table.eraser merges the two cells beside the border nearest a point. Each stroke is one undo step. In the app the buttons toggle pen and eraser modes with a live preview, a crosshair with the tool's icon, and Esc to stop; canvas clicks don't move the caret while a tool is on. Splitting through part of a vertically merged cell and erasing beside one are refused with a clear error. Catalog 384/430 -> 387/430. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307) Ink strokes are floating freeform shapes: a new Freeform geometry (paths in shape-local coordinates, opacity, and the pen that drew it) on InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one anchored to the paragraph under the first point (one undo step; points capped per stroke, strokes capped per page, NaN dropped); draw.erase / draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil, draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the session view state; review.hideInk toggles showing ink on screen. The canvas draws a live stroke while a pen is dragged and commits it on release; the eraser deletes strokes it passes over (one undo step per drag); Esc or Select goes back to selecting. Draw tab buttons show the tool in use, with colour and thickness menus; Review gets an Ink group with Hide Ink. Ink renders with round caps and joins on screen and in PDF; highlighters are translucent and behind the text. DOCX writes freeforms as wps:wsp with a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with simple custom-geometry freeforms from other files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #315 review fixes: eraser undo test, sanitized tap dots, custGeom path cap, ink docs - draw.eraser with a point erases through draw.erase: test that it is one undo step (undo/redo) and marks the document dirty. - Render and PDF: a single-point stroke's dot is drawn at the sanitized point, not the raw first one. - DOCX custGeom reading stops once MAX_PATHS paths (subpaths included) are collected, across a:path elements. - docs/file-format-parity.md: ink is saved as custom geometry, not InkML, so Word shows it as a freeform. - widgets test: theme::apply takes an Appearance (#317). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307) Ink strokes are floating freeform shapes: a new Freeform geometry (paths in shape-local coordinates, opacity, and the pen that drew it) on InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one anchored to the paragraph under the first point (one undo step; points capped per stroke, strokes capped per page, NaN dropped); draw.erase / draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil, draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the session view state; review.hideInk toggles showing ink on screen. The canvas draws a live stroke while a pen is dragged and commits it on release; the eraser deletes strokes it passes over (one undo step per drag); Esc or Select goes back to selecting. Draw tab buttons show the tool in use, with colour and thickness menus; Review gets an Ink group with Hide Ink. Ink renders with round caps and joins on screen and in PDF; highlighters are translucent and behind the text. DOCX writes freeforms as wps:wsp with a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with simple custom-geometry freeforms from other files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
785 lines
30 KiB
Rust
785 lines
30 KiB
Rust
//! The editing session: document, selection, undo/redo, layout, view state.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
use wordcraft_doc::edit::Fragment;
|
||
use wordcraft_doc::props::CharProps;
|
||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode};
|
||
|
||
use crate::{CmdError, Registry};
|
||
|
||
/// A selection: anchor (where it started) and focus (where the caret is).
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
pub struct Selection {
|
||
pub anchor: Pos,
|
||
pub focus: Pos,
|
||
}
|
||
|
||
impl Selection {
|
||
pub fn caret(p: Pos) -> Self {
|
||
Selection { anchor: p.clone(), focus: p }
|
||
}
|
||
pub fn is_collapsed(&self) -> bool {
|
||
self.anchor == self.focus
|
||
}
|
||
/// (start, end) in document order.
|
||
pub fn ordered(&self) -> (Pos, Pos) {
|
||
if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) }
|
||
}
|
||
}
|
||
|
||
/// A column (block) selection: the same x range on every line between two corners.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct ColumnBlock {
|
||
/// The stream selection the block was made with (its corners). The block is current only
|
||
/// while `Session::sel` still equals it, so any ordinary caret move or selection drops it.
|
||
pub sel: Selection,
|
||
/// Page x of the anchor and focus corners (points); the block spans the range between.
|
||
pub anchor_x: f32,
|
||
pub focus_x: f32,
|
||
/// One `(start, end)` per line, in document order, each inside one paragraph.
|
||
pub segments: Vec<(Pos, Pos)>,
|
||
}
|
||
|
||
/// View state that commands can change (ribbon View tab, status bar).
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ViewState {
|
||
pub zoom: f32,
|
||
pub mode: ViewMode,
|
||
pub read_mode: bool,
|
||
pub focus_mode: bool,
|
||
pub marks: bool,
|
||
pub ruler: bool,
|
||
/// View › Show › Gridlines: the drawing grid over the page's text area (on screen only).
|
||
pub gridlines: bool,
|
||
/// Table Layout › View Gridlines: outlines of table cells (on screen only); on by default, as in Word.
|
||
#[serde(default = "on")]
|
||
pub table_gridlines: bool,
|
||
pub nav_pane: bool,
|
||
pub styles_pane: bool,
|
||
/// Style Inspector pane (Home › Styles).
|
||
#[serde(default)]
|
||
pub style_inspector: bool,
|
||
pub comments_pane: bool,
|
||
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
|
||
#[serde(default)]
|
||
pub clipboard_pane: bool,
|
||
pub multi_page: bool,
|
||
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
|
||
pub fit: String,
|
||
pub web_width: f32,
|
||
pub dark_mode: bool,
|
||
pub show_markup: bool,
|
||
pub track_changes_pane: bool,
|
||
/// Check spelling and grammar as you type.
|
||
pub proofing: bool,
|
||
/// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document).
|
||
#[serde(default)]
|
||
pub hide_ink: bool,
|
||
/// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness.
|
||
#[serde(default)]
|
||
pub draw: crate::cmd::draw::DrawState,
|
||
}
|
||
|
||
fn on() -> bool {
|
||
true
|
||
}
|
||
|
||
impl Default for ViewState {
|
||
fn default() -> Self {
|
||
ViewState {
|
||
zoom: 1.0,
|
||
mode: ViewMode::Print,
|
||
read_mode: false,
|
||
focus_mode: false,
|
||
marks: false,
|
||
ruler: true,
|
||
gridlines: false,
|
||
table_gridlines: true,
|
||
nav_pane: false,
|
||
styles_pane: false,
|
||
style_inspector: false,
|
||
comments_pane: false,
|
||
clipboard_pane: false,
|
||
multi_page: false,
|
||
fit: String::new(),
|
||
web_width: 800.0,
|
||
dark_mode: false,
|
||
show_markup: true,
|
||
track_changes_pane: false,
|
||
proofing: true,
|
||
hide_ink: false,
|
||
draw: Default::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Find/replace state.
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct FindState {
|
||
pub query: String,
|
||
pub replace: String,
|
||
pub match_case: bool,
|
||
pub whole_word: bool,
|
||
pub regex: bool,
|
||
/// Matches from the last search: (start, end).
|
||
#[serde(skip)]
|
||
pub results: Vec<(Pos, Pos)>,
|
||
pub current: usize,
|
||
/// Reading Highlight: mark every match of `query` in the body.
|
||
pub highlight: bool,
|
||
/// Highlighted matches, for the document revision they were found in.
|
||
#[serde(skip)]
|
||
pub highlights: Option<(u64, Vec<(Pos, Pos)>)>,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct Undo {
|
||
label: String,
|
||
doc: Document,
|
||
sel: Selection,
|
||
}
|
||
|
||
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
|
||
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
|
||
/// open typing group, a pending [`Session::join_next_undo`] and the dirty flag. See
|
||
/// [`Session::edit_snapshot`].
|
||
pub struct EditSnapshot {
|
||
doc: Document,
|
||
sel: Selection,
|
||
history_len: usize,
|
||
/// `Session::undo_evicted` when the snapshot was taken.
|
||
evicted: u64,
|
||
/// The oldest undo steps, kept only when the stack is near its limit (the commands that
|
||
/// follow may push them out); at most [`SNAPSHOT_HEAD`].
|
||
head: Vec<Arc<Undo>>,
|
||
redo: Vec<Arc<Undo>>,
|
||
typing_open: bool,
|
||
join_next: bool,
|
||
dirty: bool,
|
||
}
|
||
|
||
/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`]
|
||
/// may push out of a full stack and still come back.
|
||
const SNAPSHOT_HEAD: usize = 4;
|
||
|
||
impl EditSnapshot {
|
||
/// The document when the snapshot was taken.
|
||
pub fn doc(&self) -> &Document {
|
||
&self.doc
|
||
}
|
||
/// The dirty flag then.
|
||
pub fn dirty(&self) -> bool {
|
||
self.dirty
|
||
}
|
||
/// The number of undo steps then.
|
||
pub fn undo_depth(&self) -> usize {
|
||
self.history_len
|
||
}
|
||
}
|
||
|
||
/// An editing session.
|
||
pub struct Session {
|
||
pub doc: Document,
|
||
pub sel: Selection,
|
||
pub view: ViewState,
|
||
/// Formatting picked with a collapsed caret, applied to the next typed text.
|
||
pub pending: Option<CharProps>,
|
||
pub path: Option<std::path::PathBuf>,
|
||
pub dirty: bool,
|
||
pub clipboard: Option<Fragment>,
|
||
/// Plain text mirror of the clipboard (for the system clipboard).
|
||
pub clipboard_text: String,
|
||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||
pub find: FindState,
|
||
/// Page x the caret tries to keep on Up/Down.
|
||
pub goal_x: Option<f32>,
|
||
/// Page the caret was last on (headers/footers repeat on many pages).
|
||
pub page_hint: usize,
|
||
pub registry: Arc<Registry>,
|
||
/// Author name for comments and tracked changes.
|
||
pub author: String,
|
||
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
|
||
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
|
||
/// Last message for the status bar / agents.
|
||
pub status: String,
|
||
/// Undo and redo steps. Shared so `run` can snapshot both stacks cheaply (a pointer per
|
||
/// step) and put them back exactly when a command fails.
|
||
history: Vec<Arc<Undo>>,
|
||
/// Undo steps dropped so far because of the undo limit.
|
||
undo_evicted: u64,
|
||
redo: Vec<Arc<Undo>>,
|
||
/// Typing is coalesced into one undo step until something else happens.
|
||
typing_open: bool,
|
||
/// The next mutating command joins the previous undo step (later frames of a drag).
|
||
join_next: bool,
|
||
rev: u64,
|
||
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
|
||
/// mail merge, recover), never on an edit.
|
||
document_id: u64,
|
||
cache: LayoutCache,
|
||
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool)>,
|
||
/// Picture edits: edited media key → original media key (Reset Picture).
|
||
pub originals: std::collections::HashMap<String, String>,
|
||
/// Last mutating command (Repeat).
|
||
pub last_command: Option<(String, Value)>,
|
||
/// Macro being recorded: (name, steps).
|
||
pub recording: Option<(String, Vec<(String, Value)>)>,
|
||
pub macros: std::collections::BTreeMap<String, Vec<(String, Value)>>,
|
||
pub autocorrect_on: bool,
|
||
pub autocorrect_user: Vec<(String, String)>,
|
||
/// Saved versions: (label, date, document).
|
||
pub versions: Vec<(String, String, Document)>,
|
||
/// Quick Parts / AutoText entries.
|
||
pub building_blocks: std::collections::BTreeMap<String, Fragment>,
|
||
pub autosave: bool,
|
||
/// Citation style: APA, MLA, Chicago, IEEE.
|
||
pub bib_style: String,
|
||
/// Mail merge data source and preview.
|
||
pub merge: crate::cmd::mailings::MergeState,
|
||
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
|
||
pub ui_requests: Vec<Value>,
|
||
/// Read Aloud player (Review › Speech).
|
||
pub read_aloud: crate::speech::ReadAloud,
|
||
/// Preferences the front end saves between runs.
|
||
pub prefs: Prefs,
|
||
/// Editing inside an equation: which one and the caret in it.
|
||
pub math: Option<MathEdit>,
|
||
/// Equations are typed in LaTeX rather than the linear format.
|
||
pub math_latex: bool,
|
||
/// Text typed into equations is normal (non-math) text.
|
||
pub math_normal_text: bool,
|
||
/// The password Word documents are saved with (File › Info › Protect Document › Encrypt with
|
||
/// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected
|
||
/// file that was opened, cleared when the document is replaced.
|
||
pub password: Option<crate::io::Password>,
|
||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||
pub also_selected: Vec<Pos>,
|
||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||
pub column: Option<ColumnBlock>,
|
||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||
pub column_mode: bool,
|
||
}
|
||
|
||
/// The equation being edited.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct MathEdit {
|
||
/// The equation object (its U+FFFC).
|
||
pub at: Pos,
|
||
/// The caret inside it.
|
||
pub pos: wordcraft_doc::math_edit::MathPos,
|
||
}
|
||
|
||
/// Editing preferences that persist between runs (the front end saves and restores them).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct Prefs {
|
||
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
|
||
pub count_notes: bool,
|
||
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
|
||
/// Word).
|
||
pub markup: wordcraft_layout::display::MarkupOptions,
|
||
}
|
||
|
||
impl Default for Prefs {
|
||
fn default() -> Self {
|
||
Prefs { count_notes: true, markup: Default::default() }
|
||
}
|
||
}
|
||
|
||
/// Maximum undo depth.
|
||
const MAX_UNDO: usize = 500;
|
||
|
||
impl Session {
|
||
pub fn new(doc: Document) -> Self {
|
||
let start = doc.start_of(StoryRef::Body);
|
||
Session {
|
||
doc,
|
||
sel: Selection::caret(start),
|
||
view: ViewState::default(),
|
||
pending: None,
|
||
path: None,
|
||
dirty: false,
|
||
clipboard: None,
|
||
clipboard_text: String::new(),
|
||
clip_history: Default::default(),
|
||
find: FindState::default(),
|
||
goal_x: None,
|
||
page_hint: 0,
|
||
registry: Arc::new(crate::cmd::registry()),
|
||
author: "WordCraft User".into(),
|
||
painter: None,
|
||
status: String::new(),
|
||
history: Vec::new(),
|
||
undo_evicted: 0,
|
||
redo: Vec::new(),
|
||
typing_open: false,
|
||
join_next: false,
|
||
rev: 1,
|
||
document_id: 1,
|
||
cache: LayoutCache::new(),
|
||
layout: None,
|
||
originals: Default::default(),
|
||
last_command: None,
|
||
recording: None,
|
||
macros: Default::default(),
|
||
autocorrect_on: true,
|
||
autocorrect_user: Vec::new(),
|
||
versions: Vec::new(),
|
||
building_blocks: Default::default(),
|
||
autosave: true,
|
||
bib_style: "APA".into(),
|
||
merge: Default::default(),
|
||
ui_requests: Vec::new(),
|
||
math: None,
|
||
math_latex: false,
|
||
math_normal_text: false,
|
||
also_selected: Vec::new(),
|
||
prefs: Prefs::default(),
|
||
read_aloud: Default::default(),
|
||
password: None,
|
||
column: None,
|
||
column_mode: false,
|
||
}
|
||
}
|
||
|
||
/// The line pieces of the current column selection, if there is one (the selection hasn't
|
||
/// moved since the block was made).
|
||
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
|
||
self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice())
|
||
}
|
||
|
||
/// Document revision (bumped by every change).
|
||
pub fn rev(&self) -> u64 {
|
||
self.rev
|
||
}
|
||
/// Which document is open (see `document_id`): lets a caller tell an edit from a replacement.
|
||
pub fn document_id(&self) -> u64 {
|
||
self.document_id
|
||
}
|
||
pub fn touch(&mut self) {
|
||
self.rev = self.rev.wrapping_add(1);
|
||
self.dirty = true;
|
||
}
|
||
|
||
/// Reading Highlight ranges for the current document (searched again after edits); empty when it's off.
|
||
pub fn find_highlights(&mut self) -> &[(Pos, Pos)] {
|
||
if !self.find.highlight {
|
||
return &[];
|
||
}
|
||
if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) {
|
||
let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default();
|
||
self.find.highlights = Some((self.rev, found));
|
||
}
|
||
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
|
||
}
|
||
|
||
/// The current layout (recomputed when the document or view changed).
|
||
pub fn layout(&mut self) -> Arc<DocLayout> {
|
||
let ww = self.view.web_width;
|
||
if let Some((r, w, m, l, pf)) = &self.layout
|
||
&& *r == self.rev
|
||
&& (*w == ww || self.view.mode == ViewMode::Print)
|
||
&& *m == self.view.mode
|
||
&& *pf == self.view.proofing
|
||
{
|
||
return l.clone();
|
||
}
|
||
let opts = LayoutOptions {
|
||
view: self.view.mode,
|
||
web_width: ww,
|
||
show_hidden: self.view.marks,
|
||
hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(),
|
||
proofing: self.view.proofing,
|
||
};
|
||
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
|
||
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing));
|
||
l
|
||
}
|
||
/// A layout for output (PDF, images, print): no proofing marks, print view.
|
||
pub fn export_layout(&self) -> Arc<DocLayout> {
|
||
let opts = LayoutOptions { view: ViewMode::Print, web_width: 0.0, show_hidden: false, hide_deleted: false, proofing: false };
|
||
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
|
||
}
|
||
|
||
/// Invalidate the cached layout (fonts changed etc.).
|
||
pub fn relayout(&mut self) {
|
||
self.layout = None;
|
||
self.cache.clear();
|
||
}
|
||
|
||
/// Snapshot for undo before a change.
|
||
pub fn checkpoint(&mut self, label: &str) {
|
||
if label == "Typing" && self.typing_open {
|
||
return;
|
||
}
|
||
self.typing_open = label == "Typing";
|
||
self.history.push(Arc::new(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone() }));
|
||
if self.history.len() > MAX_UNDO {
|
||
self.history.remove(0);
|
||
self.undo_evicted += 1;
|
||
}
|
||
self.redo.clear();
|
||
}
|
||
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
|
||
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
|
||
pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) {
|
||
self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel }));
|
||
if self.history.len() > MAX_UNDO {
|
||
self.history.remove(0);
|
||
self.undo_evicted += 1;
|
||
}
|
||
self.typing_open = false;
|
||
}
|
||
/// Make the next mutating command part of the previous undo step instead of a new one, so a
|
||
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
|
||
/// except the first; it is consumed by the next `run`.
|
||
pub fn join_next_undo(&mut self) {
|
||
self.join_next = true;
|
||
}
|
||
/// Stamp the document's save metadata (modified time, last modified by, revision; created
|
||
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
|
||
/// front end that writes the bytes itself (the web build's download, #262).
|
||
pub fn stamp_save(&mut self) {
|
||
let core = &mut self.doc.core;
|
||
core.modified = crate::cmd::now_iso();
|
||
if core.created.is_empty() {
|
||
core.created = core.modified.clone();
|
||
}
|
||
core.last_modified_by = self.author.clone();
|
||
core.revision = core.revision.saturating_add(1);
|
||
}
|
||
/// Close an open typing group (caret moved, other command).
|
||
pub fn close_typing(&mut self) {
|
||
self.typing_open = false;
|
||
}
|
||
pub fn can_undo(&self) -> bool {
|
||
!self.history.is_empty()
|
||
}
|
||
pub fn can_redo(&self) -> bool {
|
||
!self.redo.is_empty()
|
||
}
|
||
pub fn undo_label(&self) -> Option<&str> {
|
||
self.history.last().map(|u| u.label.as_str())
|
||
}
|
||
pub fn redo_label(&self) -> Option<&str> {
|
||
self.redo.last().map(|u| u.label.as_str())
|
||
}
|
||
/// The number of undo steps.
|
||
pub fn undo_depth(&self) -> usize {
|
||
self.history.len()
|
||
}
|
||
pub fn undo_labels(&self) -> Vec<String> {
|
||
self.history.iter().rev().map(|u| u.label.clone()).collect()
|
||
}
|
||
pub fn undo(&mut self) -> bool {
|
||
self.typing_open = false;
|
||
let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false };
|
||
let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) };
|
||
self.redo.push(Arc::new(cur));
|
||
self.touch();
|
||
true
|
||
}
|
||
pub fn redo(&mut self) -> bool {
|
||
self.typing_open = false;
|
||
let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false };
|
||
let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) };
|
||
self.history.push(Arc::new(cur));
|
||
self.touch();
|
||
true
|
||
}
|
||
/// Drop history (after open / new).
|
||
pub fn reset_history(&mut self) {
|
||
self.history.clear();
|
||
self.redo.clear();
|
||
self.typing_open = false;
|
||
}
|
||
|
||
/// Replace the document (open/new).
|
||
pub fn set_document(&mut self, doc: Document) {
|
||
self.document_id = self.document_id.wrapping_add(1);
|
||
self.doc = doc;
|
||
self.doc.ensure_nonempty();
|
||
self.password = None;
|
||
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
|
||
self.pending = None;
|
||
self.also_selected.clear();
|
||
self.column = None;
|
||
self.column_mode = false;
|
||
self.reset_history();
|
||
self.touch();
|
||
self.dirty = false;
|
||
self.relayout();
|
||
}
|
||
|
||
/// A snapshot to put back with [`Session::restore`], for a caller that runs a command and
|
||
/// may want to take it back without a trace (cheap: the document's blocks are shared).
|
||
///
|
||
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
|
||
/// the limit), `redo`, the open typing group, a pending `join_next_undo` and `dirty`. `rev`,
|
||
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
|
||
/// covered: a caller that lets the command change them (the view, the find state, the
|
||
/// clipboard, macros, the file path…) saves and puts them back itself.
|
||
///
|
||
/// `restore` is exact when the commands in between only add undo steps (every mutating
|
||
/// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full
|
||
/// stack). Taking steps off the stack (`edit.undo`) or clearing it (`file.new`,
|
||
/// `file.open`) in between is not covered.
|
||
///
|
||
/// The destructuring lists every field, so a new field does not build until someone decides
|
||
/// whether a snapshot must cover it.
|
||
pub fn edit_snapshot(&self) -> EditSnapshot {
|
||
let Session {
|
||
doc,
|
||
sel,
|
||
view: _,
|
||
pending: _,
|
||
path: _,
|
||
dirty,
|
||
clipboard: _,
|
||
clipboard_text: _,
|
||
clip_history: _,
|
||
find: _,
|
||
goal_x: _,
|
||
page_hint: _,
|
||
registry: _,
|
||
author: _,
|
||
painter: _,
|
||
status: _,
|
||
history,
|
||
undo_evicted,
|
||
redo,
|
||
typing_open,
|
||
join_next,
|
||
rev: _,
|
||
cache: _,
|
||
layout: _,
|
||
originals: _,
|
||
last_command: _,
|
||
recording: _,
|
||
macros: _,
|
||
autocorrect_on: _,
|
||
autocorrect_user: _,
|
||
versions: _,
|
||
building_blocks: _,
|
||
autosave: _,
|
||
bib_style: _,
|
||
merge: _,
|
||
ui_requests: _,
|
||
document_id: _,
|
||
read_aloud: _,
|
||
prefs: _,
|
||
// Equation editing mode, like `view`: not part of the document or its history.
|
||
math: _,
|
||
math_latex: _,
|
||
math_normal_text: _,
|
||
// The save password, like the file path: not an edit to the document.
|
||
password: _,
|
||
also_selected: _,
|
||
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
|
||
column: _,
|
||
column_mode: _,
|
||
} = self;
|
||
let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() };
|
||
EditSnapshot {
|
||
doc: doc.clone(),
|
||
sel: sel.clone(),
|
||
history_len: history.len(),
|
||
evicted: *undo_evicted,
|
||
head,
|
||
redo: redo.clone(),
|
||
typing_open: *typing_open,
|
||
join_next: *join_next,
|
||
dirty: *dirty,
|
||
}
|
||
}
|
||
|
||
/// Put a snapshot back: the commands run since leave no trace in the document, the
|
||
/// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout
|
||
/// is recomputed.
|
||
pub fn restore(&mut self, snap: EditSnapshot) {
|
||
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, dirty } = snap;
|
||
let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX);
|
||
if gone > 0 {
|
||
let back = gone.min(head.len());
|
||
self.history.splice(0..0, head.into_iter().take(back));
|
||
}
|
||
self.undo_evicted = evicted;
|
||
self.doc = doc;
|
||
self.sel = sel;
|
||
self.also_selected.clear();
|
||
self.history.truncate(history_len);
|
||
self.redo = redo;
|
||
self.typing_open = typing_open;
|
||
self.join_next = join_next;
|
||
self.touch();
|
||
self.dirty = dirty;
|
||
}
|
||
|
||
/// Make the selection valid for the current document.
|
||
pub fn clamp_selection(&mut self) {
|
||
self.sel.anchor = self.doc.clamp(&self.sel.anchor);
|
||
self.sel.focus = self.doc.clamp(&self.sel.focus);
|
||
// The equation being edited must still be there (undo, edits elsewhere).
|
||
if let Some(m) = &self.math {
|
||
let eq = self.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) {
|
||
Some(wordcraft_doc::InlineObject::Equation { math, .. }) => Some(wordcraft_doc::math_edit::clamp(&math.nodes, &m.pos)),
|
||
_ => None,
|
||
});
|
||
match eq {
|
||
Some(pos) => {
|
||
if let Some(m) = self.math.as_mut() {
|
||
m.pos = pos;
|
||
}
|
||
}
|
||
None => self.math = None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
|
||
/// control channel). Mutating commands are undoable; a failed command leaves the document as
|
||
/// it was; a panic inside a command becomes an error.
|
||
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
|
||
let join = std::mem::take(&mut self.join_next);
|
||
let reg = self.registry.clone();
|
||
let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) };
|
||
if let Some(why) = (spec.enabled)(self) {
|
||
return Err(CmdError::Disabled(format!("{id}: {why}")));
|
||
}
|
||
// Restrict Editing.
|
||
if spec.mutates
|
||
&& let Some(mode) = self.doc.settings.protection.clone()
|
||
{
|
||
let allowed = id.starts_with("review.restrict") || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo";
|
||
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
|
||
match mode.as_str() {
|
||
"readOnly" | "forms" if !allowed => return Err(CmdError::Disabled(format!("{id}: the document is protected (read only)"))),
|
||
"comments" if !allowed && !comment_ok => return Err(CmdError::Disabled(format!("{id}: only comments are allowed in this document"))),
|
||
"trackedChanges" => self.doc.settings.track_changes = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
// Macro recording and Repeat.
|
||
let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat")
|
||
&& (spec.mutates || id.starts_with("caret.") || id.starts_with("select."));
|
||
if record && let Some((_, steps)) = self.recording.as_mut() {
|
||
steps.push((id.to_string(), params.clone()));
|
||
}
|
||
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") {
|
||
self.last_command = Some((id.to_string(), params.clone()));
|
||
}
|
||
// Typing, caret movement and selection outside the equation commands leave the equation.
|
||
if self.math.is_some()
|
||
&& (id.starts_with("text.") || id.starts_with("caret.") || id.starts_with("select.") || id.starts_with("edit.paste"))
|
||
&& let Some(m) = self.math.take()
|
||
{
|
||
let off = m.at.off + wordcraft_doc::para::OBJ.len_utf8();
|
||
self.sel = Selection::caret(Pos { off, ..m.at });
|
||
}
|
||
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
|
||
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
|
||
let before_doc = if spec.mutates {
|
||
Some((
|
||
self.doc.clone(),
|
||
self.sel.clone(),
|
||
self.history.clone(),
|
||
self.redo.clone(),
|
||
self.typing_open,
|
||
self.undo_evicted,
|
||
self.column.clone(),
|
||
))
|
||
} else {
|
||
None
|
||
};
|
||
let typing = spec.id == "text.insert" || spec.id == "equation.type";
|
||
if spec.mutates && !typing {
|
||
self.typing_open = false;
|
||
}
|
||
if spec.mutates {
|
||
let label = if typing { "Typing" } else { spec.label };
|
||
if !join || self.history.is_empty() {
|
||
self.checkpoint(label);
|
||
}
|
||
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
|
||
// Non-mutating commands other than caret movement keep the typing group.
|
||
} else if spec.id.starts_with("caret.") {
|
||
self.typing_open = false;
|
||
}
|
||
let sel_before = self.sel.clone();
|
||
let run = spec.run;
|
||
let mutates = spec.mutates;
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||
let result = match result {
|
||
Ok(r) => r,
|
||
Err(p) => {
|
||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
|
||
}
|
||
};
|
||
match &result {
|
||
Ok(_) => {
|
||
if spec.mutates {
|
||
self.touch();
|
||
self.doc.ensure_nonempty();
|
||
self.doc.prune_text_boxes();
|
||
}
|
||
self.clamp_selection();
|
||
// Extra selected objects last until something else is edited or selected.
|
||
if !matches!(id, "select.addObject" | "arrange.ungroup") && (spec.mutates || self.sel != sel_before) {
|
||
self.also_selected.clear();
|
||
}
|
||
}
|
||
Err(e) => {
|
||
if let Some((d, s, h, r, t, ev, c)) = before_doc {
|
||
self.doc = d;
|
||
self.sel = s;
|
||
self.history = h;
|
||
self.redo = r;
|
||
self.typing_open = t;
|
||
// The steps the command pushed out are back, so they no longer count as gone.
|
||
self.undo_evicted = ev;
|
||
self.column = c;
|
||
}
|
||
self.status = e.to_string();
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
/// The paragraph path the caret is in.
|
||
pub fn caret_path(&self) -> &Path {
|
||
&self.sel.focus.path
|
||
}
|
||
pub fn story(&self) -> StoryRef {
|
||
self.sel.focus.story
|
||
}
|
||
|
||
/// Formatting typing would use at the caret.
|
||
pub fn typing_props(&self) -> CharProps {
|
||
if let Some(p) = &self.pending {
|
||
return p.clone();
|
||
}
|
||
let f = &self.sel.focus;
|
||
self.doc.para_at(f).map(|p| p.props_at(f.off).clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]).
|
||
pub fn word_count(&self) -> usize {
|
||
if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() }
|
||
}
|
||
|
||
/// Selected plain text.
|
||
pub fn selected_text(&self) -> String {
|
||
let (a, b) = self.sel.ordered();
|
||
self.doc.copy_range(&a, &b).plain_text()
|
||
}
|
||
}
|