mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 22:12:22 +00:00
* View: editable Markdown source view for .md files (#5826) Co-authored-by: oldgranola <marken.john206@gmail.com> * Address review feedback on Markdown source view - Fix view.markdownSourceText purity and input validation - Single-pass block line mapping in markdown parser - Reuse parsed documents and block line ranges on apply and leave - Bound memory usage with MAX_SOURCE_BYTES and clamp caret byte offsets - Isolate temp directories in tests and test caret validation - Preserve dirty state while markdown text edits are pending - Flush pending markdown text edits in swap_doc * Address CodeRabbit review feedback on Markdown source view - Exempt view.markdownSourceText in command refusal - Preserve original source line mapping when filtering link definitions - Map between egui char indices and byte-offset caret in history and undo * Remap block line numbers in a single linear pass * Refuse over-limit Markdown Source edits to prevent document truncation * Keep a Markdown file's own text when it is opened from handed-over bytes `file.open` kept a Markdown file's exact text only for path-backed opens, so a platform that hands the bytes over (data) lost front matter, comments and the author's line endings on the first save, and the rewritten flag could not say so. The kept text now comes from the same bytes `read_document` decodes, still bounded by MAX_SOURCE_BYTES through FileText::from_bytes; the path read stays for opens without data. --------- Co-authored-by: oldgranola <marken.john206@gmail.com> Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
26 files changed
+1766
-97
No files matched your search
@@ -209,6 +209,8 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
// A .markdown file is under Markdown's one entry in the list.
|
||||
.map(|e| if e == "markdown" { "md".to_string() } else { e })
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
@@ -301,6 +303,8 @@ mod tests {
|
||||
assert_eq!(save_exts("novel.docx")[0], "docx");
|
||||
assert_eq!(save_exts("report.odt")[0], "odt");
|
||||
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
|
||||
assert_eq!(save_exts("notes.md")[0], "md");
|
||||
assert_eq!(save_exts("notes.markdown")[0], "md");
|
||||
// A suggested name always has an extension; anything unreadable falls back to Word's.
|
||||
assert_eq!(save_exts("no extension")[0], "docx");
|
||||
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod insert;
|
||||
pub mod inspector;
|
||||
pub mod lists;
|
||||
pub mod mailings;
|
||||
pub mod md_source;
|
||||
pub mod moves;
|
||||
pub mod objects;
|
||||
pub mod options;
|
||||
@@ -73,6 +74,7 @@ pub fn registry() -> Registry {
|
||||
v.extend(lists::specs());
|
||||
v.extend(inspector::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(md_source::specs());
|
||||
v.extend(outline::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(equation::specs());
|
||||
|
||||
@@ -269,6 +269,10 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
super::view::restore_zoom(s, z);
|
||||
}
|
||||
s.path = Some(path.into());
|
||||
// A Markdown file's own text, for the view to show and Save to write back as it is.
|
||||
if super::md_source::is_markdown_file(std::path::Path::new(path)) {
|
||||
s.md_file = markdown_file_text(path, v);
|
||||
}
|
||||
// Like Word, a document opened with a password is saved with it again.
|
||||
if encrypted {
|
||||
s.password = password.map(crate::io::Password::new);
|
||||
@@ -278,6 +282,28 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)
|
||||
}
|
||||
|
||||
/// A Markdown file's own text as `file.open` finds it: the bytes the caller hands over (base64 in
|
||||
/// `data`, the same ones `read_document` decodes), else the head of the file at `path`.
|
||||
/// `FileText::from_bytes` keeps it only when it can be written back as it is.
|
||||
fn markdown_file_text(path: &str, v: &Value) -> super::md_source::FileText {
|
||||
if let Some(data) = p::str(v, "data") {
|
||||
// `read_document` has already refused a text it cannot decode.
|
||||
return super::insert::base64_decode(data).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default();
|
||||
}
|
||||
// Without `data`, the file's own bytes; not reachable on the web, where a file only arrives
|
||||
// as `data`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let max = u64::try_from(super::md_source::MAX_SOURCE_BYTES).unwrap_or(u64::MAX).saturating_add(1);
|
||||
crate::io::read_file_head(path, max).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
super::md_source::FileText::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
|
||||
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
|
||||
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
|
||||
@@ -364,6 +390,8 @@ fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"encrypted": s.password.is_some()}))
|
||||
}
|
||||
|
||||
/// Executes `file.save`: writes the document or text to disk, preserving exact file text for
|
||||
/// unedited Markdown files, and updating session path and dirty state.
|
||||
fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
|
||||
Some(p) => p,
|
||||
@@ -407,20 +435,32 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let password = s.password.as_ref().map(crate::io::Password::as_str);
|
||||
// A header/footer just made and left empty isn't saved (#960).
|
||||
let copy = super::insert::saved_copy(s);
|
||||
if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
|
||||
// A Markdown file's own text goes back as it is while it still says what the document holds;
|
||||
// once it doesn't (an edit in the formatted view), the document is written out again.
|
||||
let exact = super::md_source::text_to_save(s, &path, opts.markup);
|
||||
let rewritten = exact.is_none() && !s.md_file.is_empty() && super::md_source::is_markdown_file(&path);
|
||||
let written = match &exact {
|
||||
Some(text) => crate::io::save_text_path(&path, text),
|
||||
None => crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts),
|
||||
};
|
||||
if let Err(e) = written {
|
||||
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
|
||||
return Err(CmdError::Failed(e));
|
||||
}
|
||||
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
|
||||
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
|
||||
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
|
||||
// A Markdown file saved as another one loses nothing its own save wouldn't: it moves there.
|
||||
let own_file = s.path.as_deref() == Some(path.as_path());
|
||||
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
let markdown_to_markdown = super::md_source::is_markdown_file(&path) && s.path.as_deref().is_some_and(super::md_source::is_markdown_file);
|
||||
if own_file || markdown_to_markdown || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
s.relayout();
|
||||
// What the file holds now.
|
||||
s.md_file = super::md_source::FileText::saved(exact.filter(|_| super::md_source::is_markdown_file(&path)));
|
||||
}
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted, "rewritten": rewritten}))
|
||||
}
|
||||
|
||||
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
//! View › Markdown Source: a Markdown file's document as editable text.
|
||||
//!
|
||||
//! The document holds no Markdown while it is edited, so entering writes it out as text, and the
|
||||
//! text waits beside the document ([`MdSource`]) while it is edited. Leaving reads a changed text
|
||||
//! back into a new document; one Undo takes back the whole visit. A visit that changes nothing
|
||||
//! leaves the document as it was, whatever Markdown can't represent.
|
||||
//!
|
||||
//! While the text is open it is the document: commands that edit the document are refused, those
|
||||
//! that read it (Save, Export) first get the text applied, and those that change the view leave.
|
||||
//!
|
||||
//! The file's own text is kept beside the document too ([`FileText`]): while it still stands for
|
||||
//! the document, the view shows it and Save writes it, unchanged. An edit in the formatted view
|
||||
//! makes the document something only writing it out again can say.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
|
||||
use crate::doc_state::Selection;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The largest text the view takes, in bytes: its text box lays all of it out after each edit.
|
||||
pub const MAX_SOURCE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
const LABEL: &str = "Markdown Source";
|
||||
|
||||
/// The text of an open Markdown Source view.
|
||||
#[derive(Clone)]
|
||||
pub struct MdSource {
|
||||
/// What the view shows and edits.
|
||||
pub text: String,
|
||||
/// The caret in `text`, as a byte offset. The front end keeps it current.
|
||||
pub caret: usize,
|
||||
/// The front end still has to put its caret at `caret` and show it.
|
||||
pub place_caret: bool,
|
||||
/// The front end still has to reset its typing history from earlier visits.
|
||||
pub reset_undo: bool,
|
||||
/// Markdown can't represent all of the document: applying a changed text loses something.
|
||||
pub loses_content: bool,
|
||||
/// When the front end last applied the text while it was being typed, on its own clock.
|
||||
pub applied_at_ms: f64,
|
||||
/// The text on entry.
|
||||
entry_text: Arc<str>,
|
||||
/// The document on entry, which that text stands for: back to it, nothing is lost.
|
||||
entry_doc: Arc<Document>,
|
||||
/// The line each top-level block of `entry_doc` starts on.
|
||||
entry_lines: Vec<usize>,
|
||||
/// The text the document now holds.
|
||||
applied: Arc<str>,
|
||||
/// The visit has its undo step.
|
||||
has_step: bool,
|
||||
/// The undo mark recorded when the visit took its undo step, if one was taken.
|
||||
step: Option<u64>,
|
||||
/// The line each top-level block starts on, from the last parse.
|
||||
block_lines: Vec<usize>,
|
||||
}
|
||||
|
||||
impl MdSource {
|
||||
/// The text holds edits the document doesn't have yet.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.text != *self.applied
|
||||
}
|
||||
}
|
||||
|
||||
/// A Markdown file's own text, which keeps what the document can't (front matter, comments, a
|
||||
/// code block's language, the author's own spacing): the text the file was opened or last saved
|
||||
/// with, and the last text the view applied. Either stands for the document while reading it
|
||||
/// gives the document as it is, so an Undo back to either finds it again.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FileText {
|
||||
saved: Option<String>,
|
||||
latest: Option<String>,
|
||||
cached_exact: RefCell<Option<(u64, Option<String>)>>,
|
||||
}
|
||||
|
||||
impl FileText {
|
||||
/// A file's bytes as its text, if they can be written back as they are: valid UTF-8,
|
||||
/// not empty, and no longer than the view takes.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Self {
|
||||
let keep = !bytes.is_empty() && bytes.len() <= MAX_SOURCE_BYTES;
|
||||
let saved = keep.then(|| std::str::from_utf8(bytes).ok()).flatten().map(str::to_string);
|
||||
Self { saved, latest: None, cached_exact: RefCell::new(None) }
|
||||
}
|
||||
|
||||
/// The text just written to the file, which is the file's own text now.
|
||||
pub fn saved(text: Option<String>) -> Self {
|
||||
Self { saved: text, latest: None, cached_exact: RefCell::new(None) }
|
||||
}
|
||||
|
||||
/// No text is kept.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.saved.is_none() && self.latest.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept text that stands for the document as it is, if one does: the latest first.
|
||||
fn exact_text(s: &Session) -> Option<String> {
|
||||
if let Some((cached_rev, ref res)) = *s.md_file.cached_exact.borrow()
|
||||
&& cached_rev == s.rev()
|
||||
{
|
||||
return res.clone();
|
||||
}
|
||||
let kept = [s.md_file.latest.as_deref(), s.md_file.saved.as_deref()];
|
||||
let res = kept.into_iter().flatten().find(|text| parse(&s.doc, text).same_content(&s.doc)).map(str::to_string);
|
||||
*s.md_file.cached_exact.borrow_mut() = Some((s.rev(), res.clone()));
|
||||
res
|
||||
}
|
||||
|
||||
/// What Save writes to the Markdown file `path`, in place of the document written out again: the
|
||||
/// file's own text, while it stands for the document. Not for a Print Markup copy, which adds
|
||||
/// the comments.
|
||||
pub(crate) fn text_to_save(s: &Session, path: &std::path::Path, markup: bool) -> Option<String> {
|
||||
if markup || !is_markdown_file(path) {
|
||||
return None;
|
||||
}
|
||||
exact_text(s)
|
||||
}
|
||||
|
||||
/// Command specifications for Markdown Source view toggling and text inspection.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// Not `pure`: leaving edits the document. Entering changes nothing, so it adds no undo step.
|
||||
CommandSpec::new(TOGGLE, LABEL, "View › Views", toggle).params(r#"{"value"?: bool}"#).when(can_toggle),
|
||||
CommandSpec::new(TEXT, "Markdown Source Text", "View › Views", text)
|
||||
.params(r#"{"text"?: string (up to 2 MB), "caret"?: integer >= 0}"#)
|
||||
.when(|s| s.md_source.is_none().then_some("only in Markdown Source")),
|
||||
]
|
||||
}
|
||||
|
||||
const TOGGLE: &str = "view.markdownSource";
|
||||
const TEXT: &str = "view.markdownSourceText";
|
||||
/// The views that replace this one: they show the document, so the text goes into it first.
|
||||
const LEAVES: [&str; 5] = ["view.printLayout", "view.webLayout", "view.draft", "view.outline", "view.readMode"];
|
||||
|
||||
/// Whether a file's name says it is Markdown.
|
||||
pub fn is_markdown_file(path: &std::path::Path) -> bool {
|
||||
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
|
||||
}
|
||||
|
||||
/// Whether the current session's path points to a Markdown file.
|
||||
fn is_markdown_path(s: &Session) -> bool {
|
||||
s.path.as_deref().is_some_and(is_markdown_file)
|
||||
}
|
||||
|
||||
/// Checks whether the Markdown Source view can currently be toggled in `s`.
|
||||
fn can_toggle(s: &Session) -> Option<&'static str> {
|
||||
if s.md_source.is_some() {
|
||||
None
|
||||
} else if !is_markdown_path(s) {
|
||||
Some("only for Markdown (.md) files")
|
||||
} else if s.view.read_mode {
|
||||
Some("not in Read Mode")
|
||||
} else if s.doc.settings.track_changes || !s.doc.revisions.is_empty() {
|
||||
Some("not with tracked changes")
|
||||
} else if s.doc.settings.protection.is_some() {
|
||||
Some("not in a protected document")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles or sets the Markdown Source view state.
|
||||
fn toggle(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let on = p::bool(v, "value").unwrap_or(s.md_source.is_none());
|
||||
match (on, s.md_source.is_some()) {
|
||||
(true, false) => enter(s)?,
|
||||
(false, true) => leave(s),
|
||||
_ => {}
|
||||
}
|
||||
Ok(json!({"value": s.md_source.is_some()}))
|
||||
}
|
||||
|
||||
/// Gets or sets the Markdown Source text and caret position.
|
||||
fn text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let new_text = p::str(v, "text");
|
||||
if new_text.is_some_and(|t| t.len() > MAX_SOURCE_BYTES) {
|
||||
return Err(CmdError::Params(too_long()));
|
||||
}
|
||||
if let Some(cv) = v.get("caret").filter(|c| !c.is_null())
|
||||
&& cv.as_u64().is_none()
|
||||
{
|
||||
return Err(CmdError::Params("`caret` must be a non-negative integer".into()));
|
||||
}
|
||||
let Some(src) = s.md_source.as_mut() else { return Err(CmdError::Disabled("only in Markdown Source".into())) };
|
||||
let mut edited = false;
|
||||
if let Some(t) = new_text {
|
||||
edited = src.text != t;
|
||||
src.text = t.to_string();
|
||||
src.caret = src.text.floor_char_boundary(src.caret.min(src.text.len()));
|
||||
}
|
||||
if let Some(caret) = v.get("caret").and_then(Value::as_u64) {
|
||||
let caret = usize::try_from(caret).unwrap_or(usize::MAX).min(src.text.len());
|
||||
src.caret = src.text.floor_char_boundary(caret);
|
||||
src.place_caret = true;
|
||||
}
|
||||
if edited {
|
||||
src.place_caret = true;
|
||||
s.dirty = true;
|
||||
}
|
||||
let mut result = json!({"caret": src.caret, "losesContent": src.loses_content});
|
||||
if new_text.is_none() {
|
||||
result["text"] = json!(&src.text);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Error message returned when Markdown Source text exceeds [`MAX_SOURCE_BYTES`].
|
||||
fn too_long() -> String {
|
||||
format!("Markdown Source takes up to {} MB of text", MAX_SOURCE_BYTES / (1024 * 1024))
|
||||
}
|
||||
|
||||
/// Enters Markdown Source view, preparing source text and mapping caret position.
|
||||
fn enter(s: &mut Session) -> Result<(), CmdError> {
|
||||
// The file's own text when it still says what the document holds, so nothing is lost.
|
||||
let (text, loses_content, block_lines) = match exact_text(s) {
|
||||
Some(text) => {
|
||||
let (_, lines) = parse_with_lines(&s.doc, &text);
|
||||
(text, false, lines)
|
||||
}
|
||||
None => {
|
||||
let text = wordcraft_formats::markdown::export(&s.doc);
|
||||
if text.len() > MAX_SOURCE_BYTES {
|
||||
return Err(CmdError::Failed(too_long()));
|
||||
}
|
||||
let (doc, lines) = parse_with_lines(&s.doc, &text);
|
||||
let loses_content = !doc.same_content(&s.doc);
|
||||
(text, loses_content, lines)
|
||||
}
|
||||
};
|
||||
let block = if s.sel.focus.story == StoryRef::Body { s.sel.focus.path.0.first().copied().unwrap_or(0) as usize } else { 0 };
|
||||
let line = block_lines.get(block).copied().unwrap_or(0);
|
||||
let caret = line_start_byte(&text, line);
|
||||
let text_arc: Arc<str> = Arc::from(text.as_str());
|
||||
let entry_lines = block_lines.clone();
|
||||
s.md_source = Some(MdSource {
|
||||
caret,
|
||||
place_caret: true,
|
||||
reset_undo: true,
|
||||
loses_content,
|
||||
applied_at_ms: 0.0,
|
||||
entry_doc: Arc::new(s.doc.clone()),
|
||||
entry_lines,
|
||||
entry_text: text_arc.clone(),
|
||||
applied: text_arc,
|
||||
text,
|
||||
has_step: false,
|
||||
step: None,
|
||||
block_lines,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaves Markdown Source view, applying changes to the document model.
|
||||
fn leave(s: &mut Session) {
|
||||
// `Session::run` took a step for this command: the visit's first, or one too many.
|
||||
// If the toggle was joined to an earlier edit (`join_next_undo`), it created no checkpoint.
|
||||
let had_step = s.md_source.as_ref().is_some_and(|src| src.has_step);
|
||||
let step = s.md_source.as_ref().and_then(|src| src.step);
|
||||
let mark = s.undo_mark();
|
||||
let toggle_created_step = step.is_some_and(|step| mark > step);
|
||||
if toggle_created_step {
|
||||
s.drop_last_undo_step();
|
||||
}
|
||||
let mark_after = s.undo_mark();
|
||||
if let Some(src) = s.md_source.as_mut() {
|
||||
src.has_step = true;
|
||||
if src.step.is_none() {
|
||||
src.step = Some(mark_after);
|
||||
}
|
||||
}
|
||||
apply(s);
|
||||
if let Some(src) = s.md_source.take()
|
||||
&& (had_step || src.text != *src.entry_text)
|
||||
{
|
||||
let block = block_of_caret(&src.text, src.caret, &s.doc, &src.block_lines);
|
||||
s.sel = Selection::caret(block);
|
||||
}
|
||||
}
|
||||
|
||||
/// The text into the document, if it has edits the document lacks. Its first change in a visit
|
||||
/// takes the visit's undo step. Save, AutoSave and crash recovery see the text this way.
|
||||
pub fn apply(s: &mut Session) {
|
||||
let (text, entry_text, entry_doc, has_step, entry_lines) = {
|
||||
let Some(src) = s.md_source.as_ref().filter(|src| src.is_pending()) else { return };
|
||||
if src.text.len() > MAX_SOURCE_BYTES {
|
||||
return;
|
||||
}
|
||||
(src.text.clone(), src.entry_text.clone(), src.entry_doc.clone(), src.has_step, src.entry_lines.clone())
|
||||
};
|
||||
let (doc, lines) = if text == *entry_text { ((*entry_doc).clone(), entry_lines) } else { parse_with_lines(&s.doc, &text) };
|
||||
let step = if !has_step {
|
||||
s.checkpoint(LABEL);
|
||||
Some(s.undo_mark())
|
||||
} else {
|
||||
s.md_source.as_ref().and_then(|src| src.step)
|
||||
};
|
||||
s.doc = doc;
|
||||
s.note_body_replaced();
|
||||
s.math = None;
|
||||
s.touch();
|
||||
s.clamp_selection();
|
||||
s.md_file.latest = Some(text.clone());
|
||||
*s.md_file.cached_exact.borrow_mut() = Some((s.rev(), Some(text.clone())));
|
||||
if let Some(src) = s.md_source.as_mut() {
|
||||
src.applied = Arc::from(text.as_str());
|
||||
src.block_lines = lines;
|
||||
src.has_step = true;
|
||||
src.step = step;
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` as a document and block lines, with what isn't in Markdown from `current`: its page, settings,
|
||||
/// properties and language, as opening the file gave it.
|
||||
fn parse_with_lines(current: &Document, text: &str) -> (Document, Vec<usize>) {
|
||||
let (mut doc, lines) = wordcraft_formats::markdown::import_on_with_lines(text, ¤t.last_section);
|
||||
doc.ensure_nonempty();
|
||||
doc.settings = current.settings.clone();
|
||||
doc.core = current.core.clone();
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = current.styles.default_chr.lang.clone();
|
||||
(doc, lines)
|
||||
}
|
||||
|
||||
/// `text` as a document, with what isn't in Markdown from `current`: its page, settings,
|
||||
/// properties and language, as opening the file gave it.
|
||||
fn parse(current: &Document, text: &str) -> Document {
|
||||
parse_with_lines(current, text).0
|
||||
}
|
||||
|
||||
/// The byte offset where `line` starts, or the end of a text with fewer lines.
|
||||
fn line_start_byte(text: &str, line: usize) -> usize {
|
||||
if line == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut lines = 0;
|
||||
for (i, &b) in text.as_bytes().iter().enumerate() {
|
||||
if b == b'\n' {
|
||||
lines += 1;
|
||||
if lines == line {
|
||||
return i.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
/// The 0-based line that byte offset `byte` belongs to.
|
||||
fn line_of_byte(text: &str, byte: usize) -> usize {
|
||||
let slice = text.as_bytes().get(..byte).unwrap_or(text.as_bytes());
|
||||
slice.iter().filter(|&&b| b == b'\n').count()
|
||||
}
|
||||
|
||||
/// Where the caret at byte offset `caret` of `text` lands in `doc`: at the
|
||||
/// start of the top-level block its line belongs to.
|
||||
fn block_of_caret(text: &str, caret: usize, doc: &Document, block_lines: &[usize]) -> Pos {
|
||||
let caret_line = line_of_byte(text, caret);
|
||||
let top = if block_lines.is_empty() { 0 } else { block_lines.partition_point(|&l| l <= caret_line).saturating_sub(1) };
|
||||
let top = top.min(doc.body.len().saturating_sub(1));
|
||||
let top = u32::try_from(top).unwrap_or(0);
|
||||
let path = doc.para_paths(StoryRef::Body).into_iter().find(|path| path.0.first().is_some_and(|b| *b >= top));
|
||||
match path {
|
||||
Some(path) => Pos { story: StoryRef::Body, path, off: 0 },
|
||||
None => Pos { story: StoryRef::Body, path: Path::top(0), off: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the open text refuses command `id`, if it does.
|
||||
pub(crate) fn refusal(s: &Session, id: &str, mutates: bool) -> Option<&'static str> {
|
||||
(s.md_source.is_some()
|
||||
&& id != TOGGLE
|
||||
&& id != TEXT
|
||||
&& !LEAVES.contains(&id)
|
||||
&& (mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo" | "edit.repeat")))
|
||||
.then_some("leave Markdown Source first")
|
||||
}
|
||||
|
||||
/// What a command does while the text is open, before it runs (see the module's header).
|
||||
pub(crate) fn before_command(s: &mut Session, id: &str, mutates: bool) -> Result<(), CmdError> {
|
||||
if s.md_source.is_none() || id == TOGGLE || id == TEXT {
|
||||
return Ok(());
|
||||
}
|
||||
if LEAVES.contains(&id) {
|
||||
s.run(TOGGLE, &json!({"value": false}))?;
|
||||
} else if let Some(why) = refusal(s, id, mutates) {
|
||||
return Err(CmdError::Disabled(format!("{id}: {why}")));
|
||||
} else if !id.starts_with("caret.") && !id.starts_with("select.") && !id.starts_with("view.") {
|
||||
apply(s);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use wordcraft_layout::ViewMode;
|
||||
|
||||
const SAMPLE: &str = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\nLast **bold** paragraph.\n";
|
||||
|
||||
/// Creates a test session with a Markdown document and a dummy path.
|
||||
fn session(md: &str) -> Session {
|
||||
let mut s = Session::new(crate::io::open_bytes("a.md", md.as_bytes()).unwrap());
|
||||
s.path = Some(PathBuf::from("/tmp/wordcraft-md-source-test.md"));
|
||||
s.dirty = false;
|
||||
s
|
||||
}
|
||||
|
||||
/// Runs the Markdown Source text command with `text`.
|
||||
fn set_text(s: &mut Session, text: &str) {
|
||||
s.run(TEXT, &json!({"text": text})).unwrap();
|
||||
}
|
||||
|
||||
/// Extracts the plain body text of `s`.
|
||||
fn body_text(s: &Session) -> String {
|
||||
s.doc.plain_text(StoryRef::Body)
|
||||
}
|
||||
|
||||
/// Verifies entering and leaving without changes does not mutate document or add undo step.
|
||||
#[test]
|
||||
fn a_visit_that_changes_nothing_is_no_edit() {
|
||||
let mut s = session(SAMPLE);
|
||||
let before = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(s.md_source.is_some());
|
||||
assert!(!s.dirty, "entering is not an edit");
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(s.md_source.is_none());
|
||||
assert!(s.doc.same_content(&before));
|
||||
assert!(!s.dirty, "a visit without edits leaves no unsaved changes");
|
||||
assert_eq!(s.undo_depth(), depth, "and no undo step");
|
||||
}
|
||||
|
||||
/// Verifies edited source text becomes document upon leaving and one undo reverts the visit.
|
||||
#[test]
|
||||
fn an_edited_text_becomes_the_document_and_one_undo_takes_it_back() {
|
||||
let mut s = session(SAMPLE);
|
||||
let before = s.doc.clone();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let text = s.run(TEXT, &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(text.starts_with("# Title"), "{text}");
|
||||
set_text(&mut s, &text.replace("First paragraph.", "Changed paragraph."));
|
||||
assert!(s.dirty, "the edit is an unsaved change");
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(body_text(&s).contains("Changed paragraph."));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.same_content(&before), "one undo restores the document");
|
||||
assert!(s.can_redo());
|
||||
}
|
||||
|
||||
static TEST_DIR_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Generates a unique temporary directory for test file isolation.
|
||||
fn unique_temp_dir(tag: &str) -> PathBuf {
|
||||
let n = TEST_DIR_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
std::env::temp_dir().join(format!("wordcraft-md-{tag}-{}-{n}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
/// Verifies saving within source view writes text to disk while keeping view open and preserving undo.
|
||||
#[test]
|
||||
fn save_writes_the_text_and_the_visit_stays_one_undo_step() {
|
||||
let dir = unique_temp_dir("source-save");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("saved.md");
|
||||
let mut s = session(SAMPLE);
|
||||
s.path = Some(path.clone());
|
||||
let before = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
set_text(&mut s, "# Saved one\n");
|
||||
s.run("file.save", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("Saved one"));
|
||||
assert!(!s.dirty);
|
||||
assert!(s.md_source.is_some(), "saving keeps the view open");
|
||||
set_text(&mut s, "# Saved two\n");
|
||||
s.run("file.save", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("Saved two"));
|
||||
set_text(&mut s, "# Left three\n");
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(body_text(&s).contains("Left three"));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one step for the whole visit");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.same_content(&before));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Verifies returning to the original entry text restores formatting that Markdown cannot represent and maps the caret using entry block lines.
|
||||
#[test]
|
||||
fn going_back_to_the_entry_text_restores_what_markdown_cannot_hold() {
|
||||
let mut s = session(SAMPLE);
|
||||
std::sync::Arc::make_mut(&mut s.doc.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
let before = s.doc.clone();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(s.md_source.as_ref().unwrap().loses_content, "Markdown doesn't keep a changed style");
|
||||
let text = s.md_source.as_ref().unwrap().text.clone();
|
||||
set_text(&mut s, "# Other\n");
|
||||
apply(&mut s);
|
||||
assert!(!s.doc.same_content(&before));
|
||||
set_text(&mut s, &text);
|
||||
let last_off = text.find("Last ").unwrap_or(0);
|
||||
s.run(TEXT, &json!({"caret": last_off})).unwrap();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(s.doc.same_content(&before));
|
||||
assert_eq!(s.sel.focus.path.0.first().copied(), Some(5));
|
||||
}
|
||||
|
||||
/// Verifies that leaving Markdown Source with `join_next_undo` after an applied edit keeps the visit's undo checkpoint.
|
||||
#[test]
|
||||
fn leaving_joined_after_an_applied_edit_keeps_the_visits_undo_step() {
|
||||
let mut s = session(SAMPLE);
|
||||
let before = body_text(&s);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
set_text(&mut s, "# Intermediate\n");
|
||||
apply(&mut s);
|
||||
assert_eq!(s.undo_depth(), 1);
|
||||
set_text(&mut s, "# Final\n");
|
||||
s.join_next_undo();
|
||||
s.run(TOGGLE, &json!({"value": false})).unwrap();
|
||||
assert!(s.md_source.is_none());
|
||||
assert!(body_text(&s).contains("Final"));
|
||||
assert_eq!(s.undo_depth(), 1);
|
||||
assert!(s.can_undo());
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(body_text(&s), before);
|
||||
}
|
||||
|
||||
/// Verifies that opening an unedited Markdown file reports no content loss on entry.
|
||||
#[test]
|
||||
fn markdown_the_document_came_from_is_not_reported_as_lost() {
|
||||
let s = &mut session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(!s.md_source.as_ref().unwrap().loses_content);
|
||||
}
|
||||
|
||||
/// Verifies standard document editing commands are disabled while Markdown Source view is open.
|
||||
#[test]
|
||||
fn edits_are_refused_while_the_text_is_open_and_nothing_is_lost() {
|
||||
let mut s = session(SAMPLE);
|
||||
let before = s.doc.clone();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
set_text(&mut s, "# Kept\n");
|
||||
for id in ["format.bold", "edit.undo", "edit.redo"] {
|
||||
assert!(matches!(s.run(id, &json!({})), Err(CmdError::Disabled(_))), "{id}");
|
||||
}
|
||||
assert!(matches!(s.run("text.insert", &json!({"text": "x"})), Err(CmdError::Disabled(_))));
|
||||
assert!(s.doc.same_content(&before));
|
||||
assert_eq!(s.md_source.as_ref().unwrap().text, "# Kept\n");
|
||||
}
|
||||
|
||||
/// Verifies switching view mode leaves Markdown Source view and applies edits.
|
||||
#[test]
|
||||
fn changing_the_view_leaves_with_the_edits() {
|
||||
let mut s = session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
set_text(&mut s, "# Applied\n");
|
||||
s.run("view.webLayout", &json!({})).unwrap();
|
||||
assert!(s.md_source.is_none());
|
||||
assert!(body_text(&s).contains("Applied"));
|
||||
assert_eq!(s.view.mode, ViewMode::Web);
|
||||
}
|
||||
|
||||
/// Verifies only unprotected Markdown files without tracked changes can enter Markdown Source.
|
||||
#[test]
|
||||
fn only_markdown_files_without_tracking_or_protection_enter() {
|
||||
let mut s = Session::new(Document::from_text("Text"));
|
||||
assert!(s.run(TOGGLE, &json!({})).is_err(), "no file");
|
||||
s.path = Some(PathBuf::from("/tmp/a.docx"));
|
||||
assert!(s.run(TOGGLE, &json!({})).is_err(), "not Markdown");
|
||||
s.path = Some(PathBuf::from("/tmp/A.MARKDOWN"));
|
||||
s.doc.settings.track_changes = true;
|
||||
assert!(s.run(TOGGLE, &json!({})).is_err(), "tracking");
|
||||
s.doc.settings.track_changes = false;
|
||||
s.doc.settings.protection = Some("readOnly".into());
|
||||
assert!(s.run(TOGGLE, &json!({})).is_err(), "protected");
|
||||
s.doc.settings.protection = None;
|
||||
s.view.read_mode = true;
|
||||
assert!(s.run(TOGGLE, &json!({})).is_err(), "Read Mode");
|
||||
s.view.read_mode = false;
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies source text exceeding limit is refused with CmdError::Failed.
|
||||
#[test]
|
||||
fn a_text_too_long_is_refused() {
|
||||
let mut s = session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let long = "x".repeat(MAX_SOURCE_BYTES + 1);
|
||||
assert!(matches!(s.run(TEXT, &json!({"text": long})), Err(CmdError::Params(_))));
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
s.set_document(Document::from_text(&"x".repeat(MAX_SOURCE_BYTES + 1)));
|
||||
s.path = Some(PathBuf::from("/tmp/a.md"));
|
||||
assert!(matches!(s.run(TOGGLE, &json!({})), Err(CmdError::Failed(_))));
|
||||
assert!(s.md_source.is_none());
|
||||
}
|
||||
|
||||
/// Verifies caret stays synchronized with its paragraph when entering and leaving source view.
|
||||
#[test]
|
||||
fn the_caret_stays_with_its_paragraph_both_ways() {
|
||||
let mut s = session(SAMPLE);
|
||||
let target = s
|
||||
.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.into_iter()
|
||||
.find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last")))
|
||||
.unwrap();
|
||||
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target.clone(), off: 2 });
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let src = s.md_source.as_ref().unwrap();
|
||||
let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect();
|
||||
assert!(line.contains("Last"), "caret on {line:?} in {:?}", src.text);
|
||||
let changed = src.text.replace("First paragraph.", "First changed paragraph.");
|
||||
let caret = changed.find("Last").unwrap();
|
||||
s.run(TEXT, &json!({"text": changed, "caret": caret})).unwrap();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let para = s.doc.para(StoryRef::Body, &s.sel.focus.path).unwrap();
|
||||
assert!(para.plain_text().contains("Last"), "{:?}", para.plain_text());
|
||||
}
|
||||
|
||||
/// Verifies that the caret in the trailing paragraph appended after a final table maps to the end of the text.
|
||||
#[test]
|
||||
fn caret_in_appended_paragraph_after_final_table_stays_at_end() {
|
||||
let md = "| Col1 | Col2 |\n|---|---|\n| Val1 | Val2 |\n";
|
||||
let mut s = session(md);
|
||||
let trailing_para = s
|
||||
.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.into_iter()
|
||||
.find(|path| path.0.first() == Some(&1))
|
||||
.expect("document ending in a table must have an appended paragraph");
|
||||
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: trailing_para, off: 0 });
|
||||
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let src = s.md_source.as_ref().unwrap();
|
||||
assert_eq!(src.caret, src.text.len(), "caret must land after the table at the end of the text");
|
||||
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert_eq!(s.sel.focus.path.0.first(), Some(&1), "caret must stay in the appended paragraph after table");
|
||||
}
|
||||
|
||||
/// Verifies hostile carets and texts do not panic.
|
||||
#[test]
|
||||
fn hostile_carets_and_texts_do_not_panic() {
|
||||
let mut s = session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
for (text, caret) in [("", u64::MAX), ("é\n\n\n", 2), ("```\nopen fence", 9), ("|\n|-", 1), ("> > > >\n- - - -", 3)] {
|
||||
s.run(TEXT, &json!({"text": text, "caret": caret})).unwrap();
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
s.clamp_selection();
|
||||
assert!(s.doc.para(s.sel.focus.story, &s.sel.focus.path).is_some(), "{text:?}");
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies a parked document preserves its Markdown Source text.
|
||||
#[test]
|
||||
fn a_parked_document_keeps_its_text() {
|
||||
let mut s = session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
set_text(&mut s, "# Mine\n");
|
||||
let first = s.document_id();
|
||||
let _second = s.add_opened_document(Document::from_text("Other"));
|
||||
assert!(s.md_source.is_none(), "the other document has no text open");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let first_rec = s.recovery_docs().find(|r| r.id == first).unwrap();
|
||||
assert!(first_rec.doc.plain_text(StoryRef::Body).contains("Mine"), "pending text was applied before parking");
|
||||
}
|
||||
s.activate(first).unwrap();
|
||||
assert_eq!(s.md_source.as_ref().unwrap().text, "# Mine\n");
|
||||
}
|
||||
|
||||
/// What the document can't hold, all of which a save without formatted edits keeps.
|
||||
const OWN: &str = "---\ntitle: Notes\n---\n\nTitle\n=====\n\n<!-- a comment -->\n\n* star bullet\n* another\n\n- [ ] a task\n- [x] done\n\n```bash\necho hi\n```\n\nSome *emphasis* and a [link](https://example.com \"its title\").\nA wrapped\nline.\n\n\n\nLast paragraph.\n";
|
||||
|
||||
/// A scratch folder holding `name` with `text`, opened in a new session.
|
||||
fn open_file(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) {
|
||||
let dir = unique_temp_dir(&format!("own-{dir}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::new(Document::from_text(""));
|
||||
s.run("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
(s, path)
|
||||
}
|
||||
|
||||
/// Saves the session document via `file.save`.
|
||||
fn save(s: &mut Session) -> Value {
|
||||
s.run("file.save", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
/// A scratch folder holding `name`, opened from the bytes `file.open` is handed (`data`, as a
|
||||
/// platform hands a file over). The path holds other bytes, so a save that left them alone
|
||||
/// would show.
|
||||
fn open_handed_over(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) {
|
||||
let dir = unique_temp_dir(&format!("handed-{dir}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, b"not the opened text").unwrap();
|
||||
let mut s = Session::new(Document::from_text(""));
|
||||
let data = crate::cmd::insert::base64_encode(text);
|
||||
s.run("file.open", &json!({"path": path.to_string_lossy(), "data": data})).unwrap();
|
||||
(s, path)
|
||||
}
|
||||
|
||||
/// Verifies a Markdown file opened from handed-over bytes keeps its own text and saves it back
|
||||
/// byte-for-byte, as one opened from its path does.
|
||||
#[test]
|
||||
fn a_file_opened_from_handed_over_bytes_keeps_its_own_text() {
|
||||
for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n"))] {
|
||||
let (mut s, path) = open_handed_over(dir, "own.md", text.as_bytes());
|
||||
assert!(!s.md_file.is_empty(), "{dir}: the opened text must be kept");
|
||||
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), text.as_bytes(), "{dir}");
|
||||
done(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the temporary test folder created for `path`.
|
||||
fn done(path: &std::path::Path) {
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
/// Verifies unedited Markdown files are written back byte-for-byte upon save.
|
||||
#[test]
|
||||
fn a_file_saved_without_edits_is_written_back_byte_for_byte() {
|
||||
for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n")), ("bom", format!("\u{FEFF}{OWN}"))] {
|
||||
let (mut s, path) = open_file(dir, "own.md", text.as_bytes());
|
||||
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "{dir}");
|
||||
done(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies BOM-prefixed Markdown files retain original bytes when saved without edits.
|
||||
#[test]
|
||||
fn a_bom_prefixed_file_saved_without_edits_retains_its_original_bytes() {
|
||||
let bom = [b"\xEF\xBB\xBF".as_slice(), OWN.as_bytes()].concat();
|
||||
let (mut s, path) = open_file("bom-exact", "own.md", &bom);
|
||||
assert!(!s.md_file.is_empty(), "BOM file text must be kept");
|
||||
assert_eq!(save(&mut s)["rewritten"], false);
|
||||
let read = std::fs::read(&path).unwrap();
|
||||
assert_eq!(read, bom, "Saved bytes must match original BOM bytes byte-for-byte");
|
||||
done(&path);
|
||||
}
|
||||
|
||||
/// Verifies Markdown Source view displays the file's own unedited text.
|
||||
#[test]
|
||||
fn the_view_shows_the_files_own_text() {
|
||||
let (mut s, path) = open_file("view", "own.md", OWN.as_bytes());
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let src = s.md_source.as_ref().unwrap();
|
||||
assert_eq!(src.text, OWN);
|
||||
assert!(!src.loses_content);
|
||||
done(&path);
|
||||
}
|
||||
|
||||
/// Verifies edits made directly in Markdown Source view are saved as typed.
|
||||
#[test]
|
||||
fn edits_in_the_view_are_saved_as_typed() {
|
||||
let (mut s, path) = open_file("typed", "own.md", OWN.as_bytes());
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let typed = OWN.replace("Last paragraph.", "<!-- another -->\n\n* Last *changed* paragraph.");
|
||||
set_text(&mut s, &typed);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert_eq!(save(&mut s)["rewritten"], false);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), typed);
|
||||
// Saved while the view is open: the same.
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let again = typed.replace("another", "a third");
|
||||
set_text(&mut s, &again);
|
||||
assert_eq!(save(&mut s)["rewritten"], false);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), again);
|
||||
done(&path);
|
||||
}
|
||||
|
||||
/// Verifies formatted edits force re-exporting the document on save with a warning.
|
||||
#[test]
|
||||
fn an_edit_in_the_formatted_view_writes_the_document_out_again_once_with_a_warning() {
|
||||
let (mut s, path) = open_file("formatted", "own.md", OWN.as_bytes());
|
||||
s.run("text.insert", &json!({"text": "New "})).unwrap();
|
||||
assert_eq!(save(&mut s)["rewritten"], true);
|
||||
let written = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(written.contains("New "), "{written}");
|
||||
assert!(!written.contains("<!-- a comment -->"), "written out again: {written}");
|
||||
s.run("text.insert", &json!({"text": "Again "})).unwrap();
|
||||
assert_eq!(save(&mut s)["rewritten"], false, "warned once: the file holds the document's own text now");
|
||||
done(&path);
|
||||
}
|
||||
|
||||
/// Verifies undoing back to original kept text restores original formatting.
|
||||
#[test]
|
||||
fn undoing_back_to_a_kept_text_finds_it_again() {
|
||||
// A formatted edit undone.
|
||||
let (mut s, path) = open_file("undo", "own.md", OWN.as_bytes());
|
||||
s.run("text.insert", &json!({"text": "New "})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(save(&mut s)["rewritten"], false);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN);
|
||||
|
||||
// A visit to the view undone: back to the opened text, then redone: the typed one.
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let typed = OWN.replace("Last paragraph.", "Typed paragraph.");
|
||||
set_text(&mut s, &typed);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
save(&mut s);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(save(&mut s)["rewritten"], true, "the typed text was given up for the saved one");
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("Typed paragraph."));
|
||||
done(&path);
|
||||
}
|
||||
|
||||
/// Verifies files that cannot go back as they were are written out without rewriting warnings.
|
||||
#[test]
|
||||
fn files_that_cannot_go_back_as_they_were_are_written_out_without_a_warning() {
|
||||
let latin1 = b"Caf\xE9 au lait.\n".to_vec();
|
||||
for (dir, bytes) in [("latin1", latin1), ("empty", Vec::new())] {
|
||||
let (mut s, path) = open_file(dir, "own.md", &bytes);
|
||||
assert!(s.md_file.is_empty(), "{dir}");
|
||||
s.run("text.insert", &json!({"text": "New "})).unwrap();
|
||||
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
|
||||
done(&path);
|
||||
}
|
||||
// A Word document saved as Markdown is a copy written out as always.
|
||||
let mut s = Session::new(Document::from_text("Hello"));
|
||||
let dir = unique_temp_dir("own-docx");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
s.path = Some(dir.join("a.docx"));
|
||||
let r = s.run("file.saveAs", &json!({"path": dir.join("a.md").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["rewritten"], false);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Verifies markdown source text command sets place_caret flag on explicit carets and edits.
|
||||
#[test]
|
||||
fn markdown_source_text_command_sets_place_caret() {
|
||||
let mut s = session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
assert!(s.md_source.as_ref().unwrap().place_caret);
|
||||
s.md_source.as_mut().unwrap().place_caret = false;
|
||||
s.run(TEXT, &json!({"caret": 3})).unwrap();
|
||||
assert!(s.md_source.as_ref().unwrap().place_caret);
|
||||
s.md_source.as_mut().unwrap().place_caret = false;
|
||||
s.run(TEXT, &json!({"text": "# Changed\n"})).unwrap();
|
||||
assert!(s.md_source.as_ref().unwrap().place_caret);
|
||||
}
|
||||
|
||||
/// Verifies caret finds its matching line in file's own text.
|
||||
#[test]
|
||||
fn the_caret_finds_its_line_in_the_files_own_text() {
|
||||
let (mut s, path) = open_file("caret", "own.md", OWN.as_bytes());
|
||||
let target = s
|
||||
.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.into_iter()
|
||||
.find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last")))
|
||||
.unwrap();
|
||||
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target, off: 0 });
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
let src = s.md_source.as_ref().unwrap();
|
||||
let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect();
|
||||
assert_eq!(line, "Last paragraph.");
|
||||
done(&path);
|
||||
}
|
||||
|
||||
/// Verifies opening another document over session closes Markdown Source view.
|
||||
#[test]
|
||||
fn opening_over_the_document_closes_the_view() {
|
||||
let mut s = session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
s.set_document(Document::from_text("New"));
|
||||
assert!(s.md_source.is_none());
|
||||
}
|
||||
|
||||
/// Verifies invalid and negative carets are refused with CmdError::Params.
|
||||
#[test]
|
||||
fn invalid_and_negative_carets_are_refused() {
|
||||
let mut s = session(SAMPLE);
|
||||
s.run(TOGGLE, &json!({})).unwrap();
|
||||
for bad in [json!({"caret": -1}), json!({"caret": -100}), json!({"caret": 2.5}), json!({"caret": "three"})] {
|
||||
assert!(matches!(s.run(TEXT, &bad), Err(CmdError::Params(_))), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies markdown source text command is marked as mutating.
|
||||
#[test]
|
||||
fn markdown_source_text_is_not_marked_pure() {
|
||||
let s = session(SAMPLE);
|
||||
let spec = s.registry.get(TEXT).unwrap();
|
||||
assert!(spec.mutates, "view.markdownSourceText mutates session and dirty state");
|
||||
}
|
||||
}
|
||||
@@ -851,11 +851,25 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&
|
||||
safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Saves a document to `path` with optional password and export options on web targets.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> {
|
||||
Err(format!("{}: files are saved through the browser on the web", path.display()))
|
||||
}
|
||||
|
||||
/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> {
|
||||
crate::file_access::check_write(&path.to_string_lossy())?;
|
||||
safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`].
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> {
|
||||
Err(format!("{}: files are saved through the browser on the web", path.display()))
|
||||
}
|
||||
|
||||
/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the
|
||||
/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see
|
||||
/// [`wordcraft_formats::txt::decode`]).
|
||||
|
||||
@@ -164,6 +164,10 @@ pub struct Session {
|
||||
typing: Option<TypedWord>,
|
||||
/// Picture edits: edited media key → original media key (Reset Picture).
|
||||
pub originals: std::collections::HashMap<String, String>,
|
||||
/// View › Markdown Source: its text, while it is open.
|
||||
pub md_source: Option<crate::cmd::md_source::MdSource>,
|
||||
/// A Markdown file's own text, so Save can write it unchanged.
|
||||
pub md_file: crate::cmd::md_source::FileText,
|
||||
/// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap,
|
||||
/// so the next one replaces them instead of drawing over them (#2052).
|
||||
pub picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
|
||||
|
||||
@@ -70,6 +70,8 @@ pub struct DocState {
|
||||
pub(super) layout: LayoutSlots,
|
||||
pub(super) typing: Option<TypedWord>,
|
||||
pub(super) originals: std::collections::HashMap<String, String>,
|
||||
pub(super) md_source: Option<crate::cmd::md_source::MdSource>,
|
||||
pub(super) md_file: crate::cmd::md_source::FileText,
|
||||
pub(super) picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
|
||||
pub(super) versions: Vec<(String, String, Document)>,
|
||||
pub(super) autosave: bool,
|
||||
@@ -124,6 +126,8 @@ impl DocState {
|
||||
layout: Default::default(),
|
||||
typing: None,
|
||||
originals: Default::default(),
|
||||
md_source: None,
|
||||
md_file: Default::default(),
|
||||
picture_looks: Default::default(),
|
||||
versions: Vec::new(),
|
||||
autosave: true,
|
||||
@@ -187,6 +191,7 @@ fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitle
|
||||
impl Session {
|
||||
/// Swap the active document's state with `d`'s: `d` then holds the document that was active.
|
||||
fn swap_doc(&mut self, d: &mut DocState) {
|
||||
crate::cmd::md_source::apply(self);
|
||||
use std::mem::swap;
|
||||
// Every field is listed, so a new one doesn't build until someone decides whether each
|
||||
// open document has its own (then it goes in `DocState` and is swapped below) or the
|
||||
@@ -224,6 +229,8 @@ impl Session {
|
||||
layout: _,
|
||||
typing: _,
|
||||
originals: _,
|
||||
md_source: _,
|
||||
md_file: _,
|
||||
picture_looks: _,
|
||||
versions: _,
|
||||
autosave: _,
|
||||
@@ -318,6 +325,8 @@ impl Session {
|
||||
layout,
|
||||
typing,
|
||||
originals,
|
||||
md_source,
|
||||
md_file,
|
||||
picture_looks,
|
||||
versions,
|
||||
autosave,
|
||||
@@ -365,6 +374,8 @@ impl Session {
|
||||
swap(&mut self.layout, layout);
|
||||
swap(&mut self.typing, typing);
|
||||
swap(&mut self.originals, originals);
|
||||
swap(&mut self.md_source, md_source);
|
||||
swap(&mut self.md_file, md_file);
|
||||
swap(&mut self.picture_looks, picture_looks);
|
||||
swap(&mut self.versions, versions);
|
||||
swap(&mut self.autosave, autosave);
|
||||
@@ -389,6 +400,7 @@ impl Session {
|
||||
if id == self.document_id {
|
||||
return Ok(());
|
||||
}
|
||||
crate::cmd::md_source::apply(self);
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
@@ -469,7 +481,10 @@ impl Session {
|
||||
self.add(doc, 0)
|
||||
}
|
||||
|
||||
/// Adds `doc` to the session and makes it active, parking the previously active document
|
||||
/// and applying any pending Markdown Source text first.
|
||||
fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {
|
||||
crate::cmd::md_source::apply(self);
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
|
||||
@@ -26,6 +26,11 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
/// Take back the newest undo step: a command that joins an earlier step of its own.
|
||||
pub(crate) fn drop_last_undo_step(&mut self) {
|
||||
self.history.pop();
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
@@ -68,6 +68,8 @@ impl Session {
|
||||
layout,
|
||||
typing,
|
||||
originals,
|
||||
md_source,
|
||||
md_file,
|
||||
picture_looks,
|
||||
versions,
|
||||
autosave,
|
||||
@@ -136,6 +138,8 @@ impl Session {
|
||||
layout,
|
||||
typing,
|
||||
originals,
|
||||
md_source,
|
||||
md_file,
|
||||
picture_looks,
|
||||
last_command: None,
|
||||
recording: None,
|
||||
|
||||
@@ -46,13 +46,28 @@ impl Session {
|
||||
/// A command that changes nothing is not an edit: it adds no undo step, marks no
|
||||
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
|
||||
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
|
||||
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
|
||||
doc.same_content(&self.doc)
|
||||
&& *sel == self.sel
|
||||
&& *column == self.column
|
||||
&& *pending == self.pending
|
||||
&& !self.md_source.as_ref().is_some_and(|src| src.is_pending())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if self.md_source.is_some()
|
||||
&& let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates)
|
||||
{
|
||||
crate::cmd::md_source::before_command(self, id, mutates)?;
|
||||
}
|
||||
self.run_command(id, params)
|
||||
}
|
||||
|
||||
/// Dispatches `id` through the command registry with transactional rollback on failure,
|
||||
/// tracking history, undo checkpoints, and IME outcomes.
|
||||
fn run_command(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
|
||||
// The document the command starts in, and the first id a document it opens can have.
|
||||
let home = self.document_id;
|
||||
let mark = document_id_mark();
|
||||
@@ -160,7 +175,7 @@ impl Session {
|
||||
// Whether this command pushed an undo step (a no-op command's step is dropped below,
|
||||
// leaving its undo group without one).
|
||||
let mut pushed = false;
|
||||
if spec.mutates {
|
||||
if spec.mutates && spec.id != "view.markdownSourceText" {
|
||||
let label = if typing { "Typing" } else { spec.label };
|
||||
// A joined command merges into its group's step; a group whose first command
|
||||
// changed nothing has none, so the next command in it starts one (#4001).
|
||||
|
||||
@@ -7,6 +7,8 @@ impl Session {
|
||||
pub fn set_document(&mut self, doc: Document) {
|
||||
self.autocap = None;
|
||||
let replaced = self.document_id;
|
||||
self.md_source = None;
|
||||
self.md_file = Default::default();
|
||||
self.document_id = next_document_id();
|
||||
if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) {
|
||||
*at = self.document_id;
|
||||
@@ -110,6 +112,8 @@ impl Session {
|
||||
// The word being typed, like `column`: valid only while `sel` is where typing left it.
|
||||
typing: _,
|
||||
originals: _,
|
||||
md_source: _,
|
||||
md_file: _,
|
||||
picture_looks: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
@@ -253,7 +257,9 @@ impl Session {
|
||||
/// Restrict Editing refuses outright. `None` when it can run (or doesn't exist).
|
||||
pub fn why_disabled(&self, id: &str) -> Option<&'static str> {
|
||||
let spec = self.registry.get(id)?;
|
||||
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
|
||||
(spec.enabled)(self)
|
||||
.or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates))
|
||||
.or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
|
||||
}
|
||||
|
||||
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
|
||||
|
||||
@@ -5623,16 +5623,66 @@ fn saving_a_text_file_over_itself_saves_the_document() {
|
||||
assert!(!s.dirty, "{name}: saved by its own path");
|
||||
|
||||
// Plain text somewhere else is still a copy: the document keeps its file and its changes.
|
||||
// (Markdown saved as Markdown moves there: see the test below.)
|
||||
run(&mut s, "text.insert", json!({"text": "!"}));
|
||||
let copy = dir.join(format!("copy-{name}"));
|
||||
run(&mut s, "file.saveAs", json!({"path": copy.to_string_lossy()}));
|
||||
assert!(copy.exists());
|
||||
assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved");
|
||||
assert_eq!(s.path.as_deref(), Some(path.as_path()));
|
||||
if name == "note.txt" {
|
||||
assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved");
|
||||
assert_eq!(s.path.as_deref(), Some(path.as_path()));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A Markdown file saved as another Markdown file is that file from now on: the next save goes
|
||||
/// there and the first file stays as it was. Other formats keep the #2376 rule.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn markdown_saved_as_markdown_moves_to_the_new_file() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-engine-md-save-as-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let first = dir.join("a.md");
|
||||
std::fs::write(&first, b"First\n").unwrap();
|
||||
for name in ["b.md", "c.markdown", "D.MD"] {
|
||||
let mut s = s();
|
||||
std::fs::write(&first, b"First\n").unwrap();
|
||||
let second = dir.join(name);
|
||||
run(&mut s, "file.open", json!({"path": first.to_string_lossy()}));
|
||||
run(&mut s, "text.insert", json!({"text": "Hello "}));
|
||||
run(&mut s, "file.saveAs", json!({"path": second.to_string_lossy()}));
|
||||
assert!(!s.dirty, "{name}: saved");
|
||||
assert_eq!(s.path.as_deref(), Some(second.as_path()), "{name}");
|
||||
|
||||
run(&mut s, "text.insert", json!({"text": "Later "}));
|
||||
run(&mut s, "file.save", json!({}));
|
||||
assert!(!s.dirty, "{name}: saved again");
|
||||
assert!(std::fs::read_to_string(&second).unwrap().contains("Later"), "{name}: the next save goes to the new file");
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "First\n", "{name}: the first file is left alone");
|
||||
}
|
||||
|
||||
// Markdown to plain text is still a copy.
|
||||
let mut s = s();
|
||||
run(&mut s, "file.open", json!({"path": first.to_string_lossy()}));
|
||||
run(&mut s, "text.insert", json!({"text": "Hello "}));
|
||||
run(&mut s, "file.saveAs", json!({"path": dir.join("copy.txt").to_string_lossy()}));
|
||||
assert!(s.dirty, "a text copy leaves it unsaved");
|
||||
assert_eq!(s.path.as_deref(), Some(first.as_path()));
|
||||
|
||||
// So is plain text to Markdown.
|
||||
let note = dir.join("note.txt");
|
||||
std::fs::write(¬e, b"Note\n").unwrap();
|
||||
let mut text = self::s();
|
||||
run(&mut text, "file.open", json!({"path": note.to_string_lossy()}));
|
||||
run(&mut text, "text.insert", json!({"text": "Hello "}));
|
||||
run(&mut text, "file.saveAs", json!({"path": dir.join("copy.md").to_string_lossy()}));
|
||||
assert!(text.dirty, "a Markdown copy of a text file leaves it unsaved");
|
||||
assert_eq!(text.path.as_deref(), Some(note.as_path()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A save that fails to write (no permission, disk full, a bad path) leaves the document's own
|
||||
/// save stamps untouched: `stamp_save` runs before the fallible write, so a prior version of this
|
||||
/// left `modified`/`created`/`last_modified_by`/`revision`/`zoom` changed for a save that never
|
||||
|
||||
@@ -27,7 +27,7 @@ mod inlines;
|
||||
/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out.
|
||||
pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18;
|
||||
|
||||
pub use blocks::{Refs, import, import_on, parse};
|
||||
pub use blocks::{Refs, import, import_on, import_on_with_lines, parse, parse_with_lines};
|
||||
pub(crate) use export::escape;
|
||||
pub use export::export;
|
||||
#[cfg(test)]
|
||||
@@ -35,6 +35,6 @@ use export::{escape_line_start, inlines_md, table_md, write_blocks};
|
||||
pub use inlines::parse_inlines;
|
||||
|
||||
#[cfg(test)]
|
||||
use blocks::split_row;
|
||||
use blocks::{remap_original_lines, split_row};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -101,12 +101,18 @@ fn link_definition(line: &str) -> Option<(String, String)> {
|
||||
/// definition. A definition anywhere else stays in the document and reads as text, which is what
|
||||
/// it did before definitions were understood at all; deleting a line that was part of a
|
||||
/// paragraph would lose it silently.
|
||||
fn take_definitions(lines: &mut Vec<String>) -> Refs {
|
||||
///
|
||||
/// Returns the parsed references and the 0-based indexes of the removed lines.
|
||||
fn take_definitions(lines: &mut Vec<String>) -> (Refs, Vec<usize>) {
|
||||
let mut refs = Refs::new();
|
||||
let mut open: Option<(char, usize)> = None;
|
||||
// The start of the document begins a block.
|
||||
let mut starts_block = true;
|
||||
let mut removed = Vec::new();
|
||||
let mut at = 0usize;
|
||||
lines.retain(|line| {
|
||||
let this = at;
|
||||
at += 1;
|
||||
let t = line.trim_start_matches([' ', '\t']);
|
||||
// Four spaces of indentation is code, not a fence (`parse_blocks`), on both sides.
|
||||
let marker = (indent(line) < 4).then(|| fence(t)).flatten();
|
||||
@@ -130,26 +136,97 @@ fn take_definitions(lines: &mut Vec<String>) -> Refs {
|
||||
// The first definition of a label wins, as CommonMark says, and definitions may
|
||||
// follow one another.
|
||||
refs.entry(label).or_insert(dest.into());
|
||||
removed.push(this);
|
||||
return false;
|
||||
}
|
||||
starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t);
|
||||
true
|
||||
});
|
||||
refs
|
||||
(refs, removed)
|
||||
}
|
||||
|
||||
/// Maps monotonically non-decreasing line indexes in lines filtered of link definitions back to
|
||||
/// their original source lines in a single linear pass.
|
||||
pub(super) fn remap_original_lines(block_lines: &mut [usize], removed: &[usize]) {
|
||||
let mut rem_iter = removed.iter().copied().peekable();
|
||||
let mut shift = 0;
|
||||
for l in block_lines.iter_mut() {
|
||||
while let Some(&r) = rem_iter.peek() {
|
||||
if r <= *l + shift {
|
||||
shift += 1;
|
||||
rem_iter.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
*l += shift;
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for block parsing: depth, nesting, reference definitions, cell budget, and line recording.
|
||||
struct BlockCtx<'a> {
|
||||
depth: usize,
|
||||
level: u8,
|
||||
refs: &'a Refs,
|
||||
budget: &'a std::cell::Cell<usize>,
|
||||
lines_out: Option<&'a mut Vec<usize>>,
|
||||
}
|
||||
|
||||
impl BlockCtx<'_> {
|
||||
/// Records the starting line of a top-level block if line tracking is active.
|
||||
fn record_line(&mut self, line: usize) {
|
||||
if self.depth == 0
|
||||
&& let Some(lo) = self.lines_out.as_mut()
|
||||
{
|
||||
lo.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `count` blocks originating at `line` if line tracking is active.
|
||||
fn record_lines(&mut self, line: usize, count: usize) {
|
||||
if self.depth == 0
|
||||
&& let Some(lo) = self.lines_out.as_mut()
|
||||
{
|
||||
for _ in 0..count {
|
||||
lo.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Markdown text into the flow model.
|
||||
pub fn parse(text: &str) -> model::Flow {
|
||||
parse_with_lines(text).0
|
||||
}
|
||||
|
||||
/// [`parse`] returning the flow model and the 0-based starting line of each top-level block.
|
||||
pub fn parse_with_lines(text: &str) -> (model::Flow, Vec<usize>) {
|
||||
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
|
||||
let mut lines: Vec<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
|
||||
let refs = take_definitions(&mut lines);
|
||||
let (refs, removed) = take_definitions(&mut lines);
|
||||
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
|
||||
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
|
||||
let mut block_lines = Vec::new();
|
||||
let mut ctx = BlockCtx { depth: 0, level: 0, refs: &refs, budget: &budget, lines_out: Some(&mut block_lines) };
|
||||
let mut blocks = parse_blocks(&lines, &mut ctx);
|
||||
// A leading "Title" from front matter isn't supported; a first-level heading stays a heading.
|
||||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
block_lines.push(0);
|
||||
}
|
||||
model::Flow { blocks, meta: Default::default() }
|
||||
remap_original_lines(&mut block_lines, &removed);
|
||||
(model::Flow { blocks, meta: Default::default() }, block_lines)
|
||||
}
|
||||
|
||||
/// [`import_on`] returning the document and the 0-based starting line of each top-level block.
|
||||
pub fn import_on_with_lines(text: &str, page: &wordcraft_doc::SectionProps) -> (Document, Vec<usize>) {
|
||||
model::begin_import();
|
||||
let (flow, mut lines) = parse_with_lines(text);
|
||||
let doc = model::to_doc_in(&flow, page.clone());
|
||||
if doc.body.len() > lines.len() {
|
||||
let end_line = text.as_bytes().iter().filter(|&&b| b == b'\n').count();
|
||||
lines.resize(doc.body.len(), end_line);
|
||||
}
|
||||
(doc, lines)
|
||||
}
|
||||
|
||||
/// Leading tabs → spaces (tab stops of 4); the rest of the line is kept.
|
||||
@@ -369,18 +446,22 @@ fn interrupts(l: &str) -> bool {
|
||||
|| list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))
|
||||
}
|
||||
|
||||
fn flush_para(lines: &mut Vec<String>, out: &mut Vec<FBlock>, depth: usize, refs: &Refs) {
|
||||
/// Flushes accumulated paragraph text into `out`, recording its starting line if tracked.
|
||||
fn flush_para(lines: &mut Vec<String>, start_line: &mut Option<usize>, out: &mut Vec<FBlock>, ctx: &mut BlockCtx<'_>) {
|
||||
if lines.is_empty() {
|
||||
*start_line = None;
|
||||
return;
|
||||
}
|
||||
let line_no = start_line.take().unwrap_or(0);
|
||||
let text = join_lines(lines);
|
||||
lines.clear();
|
||||
let inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
|
||||
let inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs);
|
||||
if inlines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = Para { inlines, ..Default::default() };
|
||||
out.push(FBlock::Para(p));
|
||||
ctx.record_line(line_no);
|
||||
}
|
||||
|
||||
/// Paragraph lines → text with `\n` for hard breaks and spaces for soft ones.
|
||||
@@ -405,32 +486,83 @@ fn join_lines(lines: &[String]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Creates a single code block paragraph for an indented code line.
|
||||
fn code_para(line: &str) -> FBlock {
|
||||
let mut p = Para::new(Kind::Code);
|
||||
p.push_text(line, &Fmt::default());
|
||||
FBlock::Para(p)
|
||||
}
|
||||
|
||||
/// Parse block structure. `level` is the list nesting level for lists found here.
|
||||
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
|
||||
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
|
||||
/// Attempts to parse a table starting at `lines[*i]`. Returns `true` if a table was parsed.
|
||||
fn parse_table(lines: &[String], i: &mut usize, t: &str, ctx: &mut BlockCtx<'_>, out: &mut Vec<FBlock>) -> bool {
|
||||
let Some((aligns, width)) = lines.get(*i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) else { return false };
|
||||
let (head, head_width) = split_row(t, MAX_COLS);
|
||||
if head_width != width {
|
||||
return false;
|
||||
}
|
||||
let table_start = *i;
|
||||
let mut table = FTable::default();
|
||||
let cell = |s: &str, header: bool, a: Option<Align>| {
|
||||
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), ctx.depth, ctx.refs), ..Default::default() };
|
||||
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
|
||||
};
|
||||
let cols = aligns.len();
|
||||
let fits = |rows: usize| rows < MAX_ROWS && ctx.budget.get() >= cols;
|
||||
let mut cut = head_width > cols;
|
||||
if fits(0) {
|
||||
ctx.budget.set(ctx.budget.get() - cols);
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
*i += 2;
|
||||
while let Some(l) = lines.get(*i) {
|
||||
if is_blank(l) || !l.contains('|') || interrupts(l) {
|
||||
break;
|
||||
}
|
||||
if !table.rows.is_empty() && fits(table.rows.len()) {
|
||||
ctx.budget.set(ctx.budget.get() - cols);
|
||||
let (mut cells, _) = split_row(l, cols);
|
||||
cells.resize(cols, String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
*i += 1;
|
||||
}
|
||||
if cut {
|
||||
model::dropped(model::Dropped::TableCells);
|
||||
}
|
||||
if !table.rows.is_empty() {
|
||||
out.push(FBlock::Table(table));
|
||||
ctx.record_line(table_start);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Parse block structure.
|
||||
fn parse_blocks(lines: &[String], ctx: &mut BlockCtx<'_>) -> Vec<FBlock> {
|
||||
let mut out = Vec::new();
|
||||
let mut para: Vec<String> = Vec::new();
|
||||
let mut para_start = None;
|
||||
let mut i = 0;
|
||||
while let Some(line) = lines.get(i) {
|
||||
if is_blank(line) {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let ind = indent(line);
|
||||
if ind >= 4 {
|
||||
if !para.is_empty() {
|
||||
if para_start.is_none() {
|
||||
para_start = Some(i);
|
||||
}
|
||||
para.push(line.clone());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
parse_indented_code(lines, &mut i, &mut out);
|
||||
parse_indented_code(lines, &mut i, &mut out, ctx);
|
||||
continue;
|
||||
}
|
||||
let t = line.trim_start_matches([' ', '\t']);
|
||||
@@ -439,44 +571,50 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
|
||||
let u = t.trim_end();
|
||||
if !u.is_empty() && (u.chars().all(|c| c == '=') || (u.chars().all(|c| c == '-'))) {
|
||||
let lvl = if u.starts_with('=') { 1 } else { 2 };
|
||||
let line_no = para_start.take().unwrap_or(i);
|
||||
let text = join_lines(¶);
|
||||
para.clear();
|
||||
let mut p = Para::new(Kind::Heading(lvl));
|
||||
p.inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
|
||||
p.inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs);
|
||||
out.push(FBlock::Para(p));
|
||||
ctx.record_line(line_no);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some((n, body)) = atx_heading(t) {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
let mut p = Para::new(Kind::Heading(n));
|
||||
p.inlines = parse_inlines(body, &Fmt::default(), depth, refs);
|
||||
p.inlines = parse_inlines(body, &Fmt::default(), ctx.depth, ctx.refs);
|
||||
out.push(FBlock::Para(p));
|
||||
ctx.record_line(i);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if thematic_break(t) {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
out.push(FBlock::Para(Para::new(Kind::Rule)));
|
||||
ctx.record_line(i);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some((fc, fl)) = fence(t) {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
i += 1;
|
||||
while let Some(l) = lines.get(i) {
|
||||
let cur = i;
|
||||
i += 1;
|
||||
let lt = l.trim_start_matches([' ', '\t']);
|
||||
if indent(l) < 4 && fence(lt).is_some_and(|(c, n)| c == fc && n >= fl) && lt.trim_start_matches(fc).trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push(code_para(strip_indent(l, ind)));
|
||||
ctx.record_line(cur);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if t.starts_with("<!--") {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
while let Some(l) = lines.get(i) {
|
||||
i += 1;
|
||||
if l.contains("-->") {
|
||||
@@ -486,89 +624,58 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
|
||||
continue;
|
||||
}
|
||||
if t.starts_with('>') {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
let quote_start = i;
|
||||
let before = out.len();
|
||||
parse_quote(lines, &mut i, ctx, &mut out);
|
||||
ctx.record_lines(quote_start, out.len() - before);
|
||||
continue;
|
||||
}
|
||||
if para.is_empty()
|
||||
&& t.contains('|')
|
||||
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
|
||||
{
|
||||
let (head, head_width) = split_row(t, MAX_COLS);
|
||||
if head_width == width {
|
||||
let mut table = FTable::default();
|
||||
let cell = |s: &str, header: bool, a: Option<Align>| {
|
||||
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
|
||||
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
|
||||
};
|
||||
// The document keeps no more columns or rows than this, nor cells past its budget,
|
||||
// so those aren't built; their lines are still the table's.
|
||||
let cols = aligns.len();
|
||||
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
|
||||
let mut cut = head_width > cols;
|
||||
if fits(0) {
|
||||
budget.set(budget.get() - cols);
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
i += 2;
|
||||
while let Some(l) = lines.get(i) {
|
||||
if is_blank(l) || !l.contains('|') || interrupts(l) {
|
||||
break;
|
||||
}
|
||||
if !table.rows.is_empty() && fits(table.rows.len()) {
|
||||
budget.set(budget.get() - cols);
|
||||
let (mut cells, _) = split_row(l, cols);
|
||||
cells.resize(cols, String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if cut {
|
||||
model::dropped(model::Dropped::TableCells);
|
||||
}
|
||||
if !table.rows.is_empty() {
|
||||
out.push(FBlock::Table(table));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if para.is_empty() && t.contains('|') && parse_table(lines, &mut i, t, ctx, &mut out) {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = list_marker(t)
|
||||
&& (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))))
|
||||
{
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
i = parse_list(lines, i, depth, level, &mut out, refs, budget);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
i = parse_list(lines, i, ctx, &mut out);
|
||||
continue;
|
||||
}
|
||||
if para_start.is_none() {
|
||||
para_start = Some(i);
|
||||
}
|
||||
para.push(line.clone());
|
||||
i += 1;
|
||||
}
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
flush_para(&mut para, &mut para_start, &mut out, ctx);
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
|
||||
let mut code: Vec<&str> = Vec::new();
|
||||
/// Parses indented code block starting at `lines[*i]`.
|
||||
fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>, ctx: &mut BlockCtx<'_>) {
|
||||
let mut code: Vec<(usize, &str)> = Vec::new();
|
||||
while let Some(line) = lines.get(*i) {
|
||||
let cur = *i;
|
||||
if is_blank(line) {
|
||||
code.push("");
|
||||
code.push((cur, ""));
|
||||
} else if indent(line) >= 4 {
|
||||
code.push(strip_indent(line, 4));
|
||||
code.push((cur, strip_indent(line, 4)));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
*i += 1;
|
||||
}
|
||||
while code.last().is_some_and(|line| line.is_empty()) {
|
||||
while code.last().is_some_and(|(_, line)| line.is_empty()) {
|
||||
code.pop();
|
||||
}
|
||||
out.extend(code.into_iter().map(code_para));
|
||||
for (line_idx, text) in code {
|
||||
out.push(code_para(text));
|
||||
ctx.record_line(line_idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
|
||||
/// Parses block quote starting at `lines[*i]`.
|
||||
fn parse_quote(lines: &[String], i: &mut usize, ctx: &BlockCtx<'_>, out: &mut Vec<FBlock>) {
|
||||
let mut inner = Vec::new();
|
||||
while let Some(line) = lines.get(*i) {
|
||||
let trimmed = line.trim_start_matches([' ', '\t']);
|
||||
@@ -582,13 +689,14 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
|
||||
}
|
||||
*i += 1;
|
||||
}
|
||||
if depth >= MAX_NEST {
|
||||
if ctx.depth >= MAX_NEST {
|
||||
let mut p = Para::new(Kind::Quote);
|
||||
p.push_text(&inner.join(" "), &Fmt::default());
|
||||
out.push(FBlock::Para(p));
|
||||
return;
|
||||
}
|
||||
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
|
||||
let mut child = BlockCtx { depth: ctx.depth + 1, level: ctx.level, refs: ctx.refs, budget: ctx.budget, lines_out: None };
|
||||
for mut block in parse_blocks(&inner, &mut child) {
|
||||
if let FBlock::Para(p) = &mut block
|
||||
&& p.kind == Kind::Normal
|
||||
{
|
||||
@@ -598,6 +706,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes columns and byte count for spaces following a list item marker.
|
||||
fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
|
||||
let mut cols = 0;
|
||||
let mut bytes = 0;
|
||||
@@ -626,7 +735,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
|
||||
}
|
||||
|
||||
/// Parse a list starting at `lines[i]`; returns the index after it.
|
||||
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> usize {
|
||||
fn parse_list(lines: &[String], mut i: usize, ctx: &mut BlockCtx<'_>, out: &mut Vec<FBlock>) -> usize {
|
||||
let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 };
|
||||
while let Some(line) = lines.get(i) {
|
||||
let ind = indent(line);
|
||||
@@ -638,6 +747,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
|
||||
if m.ordered != first.ordered || m.ch != first.ch {
|
||||
break;
|
||||
}
|
||||
let item_start = i;
|
||||
let after = t.get(m.len..).unwrap_or("");
|
||||
let (spaces, skip) = if indent(after) > 4 {
|
||||
(1, 1)
|
||||
@@ -667,14 +777,17 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
|
||||
while item.len() > 1 && item.last().is_some_and(String::is_empty) {
|
||||
item.pop();
|
||||
}
|
||||
let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start, ..Default::default() };
|
||||
let inner = if depth >= MAX_NEST {
|
||||
let li = ListInfo { ordered: first.ordered, level: ctx.level.min(8), start: first.start, ..Default::default() };
|
||||
let inner = if ctx.depth >= MAX_NEST {
|
||||
let mut p = Para::default();
|
||||
p.push_text(&item.join(" "), &Fmt::default());
|
||||
vec![FBlock::Para(p)]
|
||||
} else {
|
||||
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget)
|
||||
let mut child =
|
||||
BlockCtx { depth: ctx.depth + 1, level: ctx.level.saturating_add(1).min(8), refs: ctx.refs, budget: ctx.budget, lines_out: None };
|
||||
parse_blocks(&item, &mut child)
|
||||
};
|
||||
let before_count = out.len();
|
||||
let mut placed = false;
|
||||
if inner.is_empty() {
|
||||
out.push(FBlock::Para(Para { list: Some(li), ..Default::default() }));
|
||||
@@ -712,6 +825,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.record_lines(item_start, out.len() - before_count);
|
||||
// Blank lines between items.
|
||||
while lines.get(i).is_some_and(|l| is_blank(l)) {
|
||||
let next_is_item = lines.get(i + 1).is_some_and(|l| {
|
||||
|
||||
@@ -776,6 +776,7 @@ fn exported_link_destinations_read_back_unchanged() {
|
||||
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
|
||||
}
|
||||
|
||||
/// Verifies that trailing escaped hashes on headings survive round-trip export.
|
||||
#[test]
|
||||
fn heading_trailing_hash_survives_round_trip() {
|
||||
let heading = |md: &str| {
|
||||
@@ -797,3 +798,52 @@ fn heading_trailing_hash_survives_round_trip() {
|
||||
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that block line mapping tracks source-line indexes accurately across definitions.
|
||||
#[test]
|
||||
fn block_line_mapping() {
|
||||
let md = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\n```\ncode\n```\n\nLast paragraph.\n";
|
||||
let (flow, lines) = parse_with_lines(md);
|
||||
assert_eq!(flow.blocks.len(), lines.len());
|
||||
let (doc, doc_lines) = import_on_with_lines(md, &wordcraft_doc::SectionProps::default());
|
||||
assert_eq!(doc.body.len(), doc_lines.len());
|
||||
assert_eq!(lines, doc_lines);
|
||||
assert_eq!(lines[0], 0);
|
||||
assert_eq!(lines[1], 2);
|
||||
assert_eq!(lines[2], 4);
|
||||
|
||||
// Reference definitions are removed from blocks, but block line numbers must match original source lines.
|
||||
let md_refs = "[link]: https://example.com\n# Title\n\n[other]: https://example.org\nParagraph with [link][].\n";
|
||||
let (flow, lines) = parse_with_lines(md_refs);
|
||||
assert_eq!(flow.blocks.len(), lines.len());
|
||||
assert_eq!(lines[0], 1, "heading on line 1 after definition on line 0");
|
||||
assert_eq!(lines[1], 4, "paragraph on line 4 after definition on line 3");
|
||||
}
|
||||
|
||||
/// Verifies linear remapping of original lines after removing definitions.
|
||||
#[test]
|
||||
fn remap_original_lines_cases() {
|
||||
let mut lines = vec![0, 1, 2, 3, 4];
|
||||
remap_original_lines(&mut lines, &[2, 5]);
|
||||
assert_eq!(lines, vec![0, 1, 3, 4, 6]);
|
||||
|
||||
let mut lines = vec![0, 1];
|
||||
remap_original_lines(&mut lines, &[1, 2, 3]);
|
||||
assert_eq!(lines, vec![0, 4]);
|
||||
|
||||
let mut lines = vec![0];
|
||||
remap_original_lines(&mut lines, &[0]);
|
||||
assert_eq!(lines, vec![1]);
|
||||
|
||||
let mut lines = vec![1, 1, 2];
|
||||
remap_original_lines(&mut lines, &[1]);
|
||||
assert_eq!(lines, vec![2, 2, 3]);
|
||||
|
||||
let mut lines = vec![0, 2];
|
||||
remap_original_lines(&mut lines, &[]);
|
||||
assert_eq!(lines, vec![0, 2]);
|
||||
|
||||
let mut empty: Vec<usize> = Vec::new();
|
||||
remap_original_lines(&mut empty, &[1, 2]);
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod pictures;
|
||||
mod prefs;
|
||||
|
||||
pub(crate) use commands::works_without_document;
|
||||
pub(crate) use frame::STATUS_MS;
|
||||
pub(crate) use input::control_click_as_secondary;
|
||||
|
||||
impl WordApp {
|
||||
|
||||
@@ -28,6 +28,10 @@ impl WordApp {
|
||||
/// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask
|
||||
/// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`).
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
// While Markdown Source is open, Undo and Redo work on its typing, as Ctrl+Z does there.
|
||||
if matches!(id, "edit.undo" | "edit.redo") && self.session.md_source.is_some() {
|
||||
return crate::md_source::undo(self, id == "edit.redo");
|
||||
}
|
||||
// Comment text still being typed is written first, so a save or export has it and a
|
||||
// close, New or Open counts it as a change (#1348). A draft the document refuses is not
|
||||
// part of it: saving and exporting go on without it (the refusal stays on the status
|
||||
@@ -256,6 +260,13 @@ impl WordApp {
|
||||
{
|
||||
self.autosave_path = self.session.path.clone();
|
||||
}
|
||||
// A Markdown file edited in the formatted view was written out again, not kept as it was.
|
||||
if let Ok(v) = &r
|
||||
&& matches!(id, "file.save" | "file.saveAs")
|
||||
&& v.get("rewritten").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
self.status_long(tl!("Saved. Edits in the formatted view meant writing the Markdown out again, so its own layout wasn't kept."));
|
||||
}
|
||||
// A renamed file is the same file: AutoSave keeps covering it, and Recent drops the old name.
|
||||
if r.is_ok()
|
||||
&& id == "file.rename"
|
||||
|
||||
@@ -297,16 +297,19 @@ impl WordApp {
|
||||
self.default_save_name()
|
||||
}
|
||||
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
|
||||
/// otherwise .docx. A document never saved and without a title of its own is named after its
|
||||
/// first words, as Word suggests them (#5793), instead of `Document1`.
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros) or,
|
||||
/// in a dialog, its own Markdown format (a browser's download keeps the whole document as
|
||||
/// .docx), otherwise .docx. A document never saved and without a title of its own is named after
|
||||
/// its first words, as Word suggests them (#5793), instead of `Document1`.
|
||||
pub(crate) fn default_save_name(&self) -> String {
|
||||
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
|
||||
let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty();
|
||||
let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t));
|
||||
let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem());
|
||||
let markdown =
|
||||
self.services.download.is_none() && self.session.path.as_deref().is_some_and(wordcraft_engine::cmd::md_source::is_markdown_file);
|
||||
match ext {
|
||||
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
|
||||
Some(e) if markdown || wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
|
||||
_ => format!("{stem}.docx"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ use super::{
|
||||
|
||||
use crate::i18n;
|
||||
|
||||
/// How long the status bar shows a message, in milliseconds.
|
||||
pub(crate) const STATUS_MS: f64 = 6000.0;
|
||||
/// How long [`WordApp::status_long`] shows one: a warning that is easy to miss in six seconds.
|
||||
const STATUS_LONG_MS: f64 = 20000.0;
|
||||
|
||||
impl WordApp {
|
||||
/// A file that wouldn't open (#1103): log it, and say on the status bar which file and why,
|
||||
/// whatever handed it over (the command line, a macOS open event, a drop). Returns the message.
|
||||
@@ -17,10 +22,17 @@ impl WordApp {
|
||||
msg
|
||||
}
|
||||
|
||||
/// Sets a temporary status message in the bottom status bar.
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.status_msg = Some((s.into(), now_ms()));
|
||||
}
|
||||
|
||||
/// A status message that stays [`STATUS_LONG_MS`] instead of the usual six seconds: the bar
|
||||
/// shows a message until six seconds after its time, so its time is set that much later.
|
||||
pub fn status_long(&mut self, s: impl Into<String>) {
|
||||
self.status_msg = Some((s.into(), now_ms() + STATUS_LONG_MS - STATUS_MS));
|
||||
}
|
||||
|
||||
/// Write the current preferences out through the host's hook, if it has one (#2716). The
|
||||
/// desktop app saves them at exit; a change that should survive a crash, like taking a
|
||||
/// document off the recent list, needs a save at the moment of the change. `None`: the host
|
||||
@@ -99,7 +111,11 @@ impl WordApp {
|
||||
}
|
||||
let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas };
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| {
|
||||
canvas::show(self, ui);
|
||||
if self.session.md_source.is_some() {
|
||||
crate::md_source::show(self, ui);
|
||||
} else {
|
||||
canvas::show(self, ui);
|
||||
}
|
||||
});
|
||||
if self.focus_ribbon && !moved(self) {
|
||||
chrome::focus_ribbon(self, ctx);
|
||||
|
||||
@@ -53,6 +53,8 @@ const QAT_MENU: [&str; 10] =
|
||||
/// Match the toolbar's enabled state for keyboard activation too.
|
||||
pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool {
|
||||
match id {
|
||||
// The Markdown Source pane's arrows undo its typing.
|
||||
"edit.undo" | "edit.redo" if app.session.md_source.is_some() => crate::md_source::can_undo(app, id == "edit.redo"),
|
||||
"edit.undo" => app.session.can_undo(),
|
||||
"edit.redo" => app.session.can_redo(),
|
||||
"file.save" => app.has_document(),
|
||||
@@ -82,6 +84,8 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
|
||||
}
|
||||
crate::widgets::command_menu(&resp, app, id);
|
||||
if id == "edit.undo" {
|
||||
// The list is the document's steps, not the Markdown Source pane's typing.
|
||||
let enabled = enabled && app.session.md_source.is_none();
|
||||
undo_list_button(ui, app, enabled);
|
||||
}
|
||||
}
|
||||
@@ -962,7 +966,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
if let Some((msg, at)) = &app.status_msg
|
||||
&& crate::now_ms() - at < 6000.0
|
||||
&& crate::now_ms() - at < crate::app::STATUS_MS
|
||||
{
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
|
||||
@@ -1034,6 +1034,15 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool {
|
||||
pen.line(&[(12.5, 15.5), (17.0, 15.5)]);
|
||||
}
|
||||
"draft" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]),
|
||||
// A heading mark, then lines of text.
|
||||
"markdownSource" => {
|
||||
pen.line_c(&[(4.0, 3.0), (4.0, 9.0)], a);
|
||||
pen.line_c(&[(7.5, 3.0), (7.5, 9.0)], a);
|
||||
pen.line_c(&[(2.5, 5.0), (9.0, 5.0)], a);
|
||||
pen.line_c(&[(2.5, 7.0), (9.0, 7.0)], a);
|
||||
pen.lines(3.0, 17.0, &[12.0, 15.0]);
|
||||
pen.lines(11.0, 17.0, &[4.0, 7.0]);
|
||||
}
|
||||
"focus" => {
|
||||
pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]);
|
||||
pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]);
|
||||
|
||||
@@ -374,6 +374,7 @@ fn controls(tab: &str) -> Vec<Control> {
|
||||
c("Read Mode", "view.readMode"),
|
||||
c("Print Layout", "view.printLayout"),
|
||||
c("Web Layout", "view.webLayout"),
|
||||
c("Markdown Source", "view.markdownSource"),
|
||||
c("Outline", "view.outline"),
|
||||
c("Draft", "view.draft"),
|
||||
c("Focus", "view.focus"),
|
||||
|
||||
@@ -54,6 +54,7 @@ pub mod keys;
|
||||
pub mod keytips;
|
||||
pub(crate) mod language_packs;
|
||||
mod local_time;
|
||||
pub mod md_source;
|
||||
pub mod mini_toolbar;
|
||||
pub mod objects;
|
||||
pub mod options;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! View › Markdown Source: the text pane that takes the page's place. The text itself and what it
|
||||
//! means for the document are the engine's (`wordcraft_engine::cmd::md_source`).
|
||||
|
||||
use egui::text::{CCursor, CCursorRange};
|
||||
use egui::{FontId, Key, KeyboardShortcut, Modifiers, Ui};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::clock::now_ms;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// How often a text with edits reaches the document while it is being typed, in milliseconds, so
|
||||
/// crash recovery has them.
|
||||
const APPLY_EVERY_MS: f64 = 5000.0;
|
||||
|
||||
/// Draw the pane in the place of the page.
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The page isn't drawn, so it can't have the keyboard.
|
||||
app.canvas.focused = false;
|
||||
app.canvas.want_focus = false;
|
||||
// A focused text box turns the global shortcuts off.
|
||||
if ui.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::S))) {
|
||||
let _ = app.run("file.save", json!({}));
|
||||
}
|
||||
let now = now_ms();
|
||||
if app.session.md_source.as_ref().is_some_and(|src| src.is_pending() && now - src.applied_at_ms > APPLY_EVERY_MS) {
|
||||
wordcraft_engine::cmd::md_source::apply(&mut app.session);
|
||||
if let Some(src) = app.session.md_source.as_mut() {
|
||||
src.applied_at_ms = now;
|
||||
}
|
||||
}
|
||||
let doc_id = app.session.document_id();
|
||||
let id = box_id(doc_id);
|
||||
let mut changed = false;
|
||||
let mut refused = false;
|
||||
{
|
||||
let Some(src) = app.session.md_source.as_mut() else { return };
|
||||
if src.reset_undo {
|
||||
src.reset_undo = false;
|
||||
reset_undo_history(ui.ctx(), doc_id);
|
||||
}
|
||||
if src.loses_content {
|
||||
egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(12, 6)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.label(tl!(
|
||||
"This document has content Markdown can't hold. Changing the text here removes it; one Undo afterwards brings it back."
|
||||
));
|
||||
});
|
||||
}
|
||||
let has_input = ui.input(|i| i.events.iter().any(|e| !matches!(e, egui::Event::PointerMoved(_))));
|
||||
let prev_text = if has_input { Some(src.text.clone()) } else { None };
|
||||
let prev_caret = src.caret;
|
||||
egui::ScrollArea::vertical().id_salt(id).auto_shrink(false).show(ui, |ui| {
|
||||
let size = ui.available_size();
|
||||
let mut out = egui::TextEdit::multiline(&mut src.text)
|
||||
.id(id)
|
||||
.hint_text(tl!("Markdown Source"))
|
||||
.font(FontId::monospace(14.0))
|
||||
.desired_width(f32::INFINITY)
|
||||
.min_size(size)
|
||||
.frame(egui::Frame::NONE.inner_margin(16))
|
||||
.text_color(t.text)
|
||||
.char_limit(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES)
|
||||
.lock_focus(true)
|
||||
.show(ui);
|
||||
changed = out.response.changed();
|
||||
if changed && src.text.len() > wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES {
|
||||
if let Some(prev) = prev_text {
|
||||
src.text = prev;
|
||||
src.caret = prev_caret;
|
||||
} else {
|
||||
let limit = src.text.floor_char_boundary(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES);
|
||||
src.text.truncate(limit);
|
||||
src.caret = src.caret.min(limit);
|
||||
}
|
||||
changed = false;
|
||||
refused = true;
|
||||
src.place_caret = true;
|
||||
}
|
||||
let caret_changed = if src.place_caret {
|
||||
src.place_caret = false;
|
||||
let char_idx = src.text.get(..src.caret).map_or(0, |s| s.chars().count());
|
||||
let caret = CCursor::new(char_idx);
|
||||
out.state.cursor.set_char_range(Some(CCursorRange::one(caret)));
|
||||
out.state.store(ui.ctx(), id);
|
||||
out.response.request_focus();
|
||||
let spot = out.galley.pos_from_cursor(caret).translate(out.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(spot, Some(egui::Align::Center));
|
||||
true
|
||||
} else if let Some(range) = out.cursor_range {
|
||||
let old_caret = src.caret;
|
||||
let char_idx: usize = range.primary.index.into();
|
||||
src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b);
|
||||
old_caret != src.caret
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if changed || caret_changed {
|
||||
invalidate_undo_cache(ui.ctx(), doc_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
if refused {
|
||||
app.status(tl!("Markdown Source takes up to 2 MB of text"));
|
||||
}
|
||||
if changed {
|
||||
app.session.dirty = true;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(APPLY_EVERY_MS / 1000.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The text box of a document's pane, found the same way by Undo and Redo as by the pane.
|
||||
fn box_id(document: u64) -> egui::Id {
|
||||
egui::Id::unique("md_source").with(document)
|
||||
}
|
||||
|
||||
/// The context storage key for caching the pane's undo/redo availability.
|
||||
fn undo_cache_id(document: u64) -> egui::Id {
|
||||
egui::Id::unique("md_source_undo_cache").with(document)
|
||||
}
|
||||
|
||||
/// Cached undo and redo capabilities for a document's Markdown Source text box.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct CachedUndoState {
|
||||
can_undo: bool,
|
||||
can_redo: bool,
|
||||
}
|
||||
|
||||
/// Invalidates the cached undo and redo capability for `document`.
|
||||
pub(crate) fn invalidate_undo_cache(ctx: &egui::Context, document: u64) {
|
||||
ctx.data_mut(|d| d.remove::<CachedUndoState>(undo_cache_id(document)));
|
||||
}
|
||||
|
||||
/// Clears the stored typing history for `document`'s Markdown Source text box and invalidates the cache.
|
||||
pub(crate) fn reset_undo_history(ctx: &egui::Context, document: u64) {
|
||||
let id = box_id(document);
|
||||
if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) {
|
||||
state.clear_undoer();
|
||||
state.store(ctx, id);
|
||||
}
|
||||
invalidate_undo_cache(ctx, document);
|
||||
}
|
||||
|
||||
/// A text box's state for its typing history: the selection and the text.
|
||||
type Typed = (CCursorRange, String);
|
||||
|
||||
/// The text box's typing history, with the state Undo and Redo start from.
|
||||
fn history(app: &WordApp) -> Option<(egui::Context, egui::text_edit::TextEditState, egui::util::undoer::Undoer<Typed>, Typed)> {
|
||||
let ctx = app.ctx.clone()?;
|
||||
let src = app.session.md_source.as_ref()?;
|
||||
let mut state = egui::text_edit::TextEditState::load(&ctx, box_id(app.session.document_id()))?;
|
||||
if src.reset_undo {
|
||||
state.clear_undoer();
|
||||
}
|
||||
let undoer = state.undoer();
|
||||
let caret_chars = src.text[..src.text.floor_char_boundary(src.caret)].chars().count();
|
||||
let range = state.cursor.char_range().unwrap_or(CCursorRange::one(CCursor::new(caret_chars)));
|
||||
Some((ctx, state, undoer, (range, src.text.clone())))
|
||||
}
|
||||
|
||||
/// Whether the pane's Undo (or Redo, with `redo`) has a step to take.
|
||||
pub fn can_undo(app: &WordApp, redo: bool) -> bool {
|
||||
let Some(ctx) = app.ctx.as_ref() else { return false };
|
||||
let doc_id = app.session.document_id();
|
||||
if app.session.md_source.as_ref().is_some_and(|s| s.reset_undo) {
|
||||
reset_undo_history(ctx, doc_id);
|
||||
}
|
||||
let cache_key = undo_cache_id(doc_id);
|
||||
if let Some(cached) = ctx.data(|d| d.get_temp::<CachedUndoState>(cache_key)) {
|
||||
return if redo { cached.can_redo } else { cached.can_undo };
|
||||
}
|
||||
let Some((ctx, _, undoer, now)) = history(app) else { return false };
|
||||
let can_u = undoer.has_undo(&now);
|
||||
let can_r = undoer.has_redo(&now);
|
||||
ctx.data_mut(|d| d.insert_temp(cache_key, CachedUndoState { can_undo: can_u, can_redo: can_r }));
|
||||
if redo { can_r } else { can_u }
|
||||
}
|
||||
|
||||
/// Undo (or Redo, with `redo`) typing in the pane, as Ctrl+Z (Ctrl+Y) does in it: the toolbar's
|
||||
/// arrows and the Undo and Redo commands work on the text while it is open, not on the document.
|
||||
pub fn undo(app: &mut WordApp, redo: bool) -> Result<serde_json::Value, String> {
|
||||
let Some((ctx, mut state, mut undoer, now)) = history(app) else { return Err(tl!("Nothing to undo").into()) };
|
||||
let step = if redo { undoer.redo(&now) } else { undoer.undo(&now) }.cloned();
|
||||
let Some((range, text)) = step else {
|
||||
return Err(if redo { tl!("Nothing to redo") } else { tl!("Nothing to undo") }.into());
|
||||
};
|
||||
state.cursor.set_char_range(Some(range));
|
||||
state.set_undoer(undoer);
|
||||
state.store(&ctx, box_id(app.session.document_id()));
|
||||
if let Some(src) = app.session.md_source.as_mut() {
|
||||
src.text = text;
|
||||
let char_idx: usize = range.primary.index.into();
|
||||
src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b);
|
||||
}
|
||||
invalidate_undo_cache(&ctx, app.session.document_id());
|
||||
app.session.dirty = true;
|
||||
ctx.request_repaint();
|
||||
Ok(json!({"text": true}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
/// Builds a test harness for Markdown Source UI integration tests.
|
||||
fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
)
|
||||
}
|
||||
|
||||
/// A saved Markdown file, with its folder for the test to remove.
|
||||
fn opened(name: &str) -> (WordApp, std::path::PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wordcraft-ui-md-source-{name}-{}-{}-{}",
|
||||
std::process::id(),
|
||||
id,
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("note.md");
|
||||
std::fs::write(&path, "# Heading\n\nSome text.\n").unwrap();
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
(a, dir)
|
||||
}
|
||||
|
||||
/// Verifies Markdown Source pane displays text and captures keyboard focus.
|
||||
#[test]
|
||||
fn the_pane_shows_the_text_and_takes_the_keyboard() {
|
||||
let (mut a, dir) = opened("keys");
|
||||
a.run("view.markdownSource", json!({})).unwrap();
|
||||
let mut h = harness(a);
|
||||
h.run();
|
||||
assert!(!h.state().canvas.focused, "the page isn't drawn");
|
||||
assert!(h.ctx.text_edit_focused(), "the text box has the keyboard");
|
||||
assert!(h.get_all_by_value("# Heading\n\nSome text.\n").next().is_some(), "the Markdown is shown");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies typing in source view marks document dirty and Ctrl+S saves it.
|
||||
#[test]
|
||||
fn typing_is_an_unsaved_change_and_ctrl_s_saves_it() {
|
||||
let (mut a, dir) = opened("save");
|
||||
a.run("view.markdownSource", json!({})).unwrap();
|
||||
let mut h = harness(a);
|
||||
h.run();
|
||||
h.state_mut().session.md_source.as_mut().unwrap().text.insert_str(0, "Typed. ");
|
||||
h.state_mut().session.dirty = true;
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||||
h.run();
|
||||
let saved = std::fs::read_to_string(dir.join("note.md")).unwrap();
|
||||
assert!(saved.contains("Typed."), "{saved}");
|
||||
assert!(!h.state().session.dirty);
|
||||
assert!(h.state().session.md_source.is_some(), "saving keeps the pane");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies QAT undo and redo arrows work for typing in the source pane.
|
||||
#[test]
|
||||
fn the_arrows_undo_and_redo_typing_in_the_pane() {
|
||||
let (mut a, dir) = opened("arrows");
|
||||
a.run("view.markdownSource", json!({})).unwrap();
|
||||
let mut h = harness(a);
|
||||
h.run();
|
||||
assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "nothing typed yet");
|
||||
h.event(egui::Event::Text("Typed. ".into()));
|
||||
h.run();
|
||||
let typed = h.state().session.md_source.as_ref().unwrap().text.clone();
|
||||
assert!(typed.contains("Typed. "), "{typed}");
|
||||
assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run();
|
||||
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, "# Heading\n\nSome text.\n", "Undo takes the typing back");
|
||||
assert!(h.state().session.md_source.is_some(), "and stays in the pane");
|
||||
assert!(crate::chrome::qat_enabled(h.state(), "edit.redo"));
|
||||
h.state_mut().run("edit.redo", json!({})).unwrap();
|
||||
h.run();
|
||||
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, typed, "Redo puts it back");
|
||||
assert!(h.state_mut().run("edit.redo", json!({})).is_err(), "nothing more to redo");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies rewrite warning stays displayed for twenty seconds on the status bar.
|
||||
#[test]
|
||||
fn the_rewrite_warning_stays_longer_than_other_messages() {
|
||||
let (mut a, dir) = opened("warning");
|
||||
a.run("text.insert", json!({"text": "Formatted edit. "})).unwrap();
|
||||
a.run("file.save", json!({})).unwrap();
|
||||
let (msg, at) = a.status_msg.clone().unwrap();
|
||||
assert!(msg.contains("writing the Markdown out again"), "{msg}");
|
||||
let left = at + crate::app::STATUS_MS - now_ms();
|
||||
assert!(left > 15_000.0, "shown for about 20 seconds, {left} ms left");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies formatting ribbon controls are greyed out while Markdown Source pane is active.
|
||||
#[test]
|
||||
fn the_ribbon_greys_out_edits_while_the_pane_is_open() {
|
||||
let (mut a, dir) = opened("grey");
|
||||
assert_eq!(a.session.why_disabled("format.bold"), None);
|
||||
a.run("view.markdownSource", json!({})).unwrap();
|
||||
assert_eq!(a.session.why_disabled("format.bold"), Some("leave Markdown Source first"));
|
||||
assert_eq!(a.session.why_disabled("view.markdownSource"), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies that typing or pasting beyond `MAX_SOURCE_BYTES` is refused, keeping the previous text and warning.
|
||||
#[test]
|
||||
fn text_exceeding_max_source_bytes_is_refused_and_warns() {
|
||||
let (mut a, dir) = opened("oversize");
|
||||
a.run("view.markdownSource", json!({})).unwrap();
|
||||
let limit = wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES;
|
||||
let half = limit / 2;
|
||||
let original = format!("START {} END", "é".repeat(half - 10));
|
||||
let original_len = original.len();
|
||||
a.session.md_source.as_mut().unwrap().text = original.clone();
|
||||
let mut h = harness(a);
|
||||
h.run();
|
||||
h.event(egui::Event::Text("é".repeat(100)));
|
||||
h.run();
|
||||
let current_text = h.state().session.md_source.as_ref().unwrap().text.clone();
|
||||
assert_eq!(current_text, original, "over-limit text must be refused without truncating document end");
|
||||
assert_eq!(current_text.len(), original_len);
|
||||
let status = h.state().status_msg.as_ref().map(|(s, _)| s.as_str());
|
||||
assert_eq!(status, Some("Markdown Source takes up to 2 MB of text"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies that pointer movement alone does not take a snapshot or alter Markdown Source state.
|
||||
#[test]
|
||||
fn pointer_movement_does_not_mutate_or_affect_markdown_source() {
|
||||
let (mut a, dir) = opened("pointer");
|
||||
a.run("view.markdownSource", json!({})).unwrap();
|
||||
let mut h = harness(a);
|
||||
h.run();
|
||||
let before_text = h.state().session.md_source.as_ref().unwrap().text.clone();
|
||||
h.event(egui::Event::PointerMoved(egui::pos2(100.0, 200.0)));
|
||||
h.run();
|
||||
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, before_text);
|
||||
assert!(!h.state().session.dirty);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Verifies that reopening Markdown Source after formatted edits resets undo history
|
||||
/// so that previous visit edits cannot be restored by Undo.
|
||||
#[test]
|
||||
fn reopening_pane_resets_undo_history_from_previous_visit() {
|
||||
let (mut a, dir) = opened("reopen_undo");
|
||||
a.run("view.markdownSource", json!({})).unwrap();
|
||||
let mut h = harness(a);
|
||||
h.run();
|
||||
h.event(egui::Event::Text("First visit. ".into()));
|
||||
h.run();
|
||||
assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo enabled after typing");
|
||||
|
||||
// Leave pane back to formatted view
|
||||
h.state_mut().run("view.markdownSource", json!({})).unwrap();
|
||||
h.run();
|
||||
assert!(h.state().session.md_source.is_none(), "left source view");
|
||||
|
||||
// Make formatted edits in document
|
||||
h.state_mut().run("text.insert", json!({"text": "Formatted edit. "})).unwrap();
|
||||
h.run();
|
||||
|
||||
// Reopen pane
|
||||
h.state_mut().run("view.markdownSource", json!({})).unwrap();
|
||||
h.run();
|
||||
|
||||
// Undo must NOT be enabled for the Markdown pane, and edit.undo must return error
|
||||
assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo must be reset on reopening pane");
|
||||
assert!(h.state_mut().run("edit.undo", json!({})).is_err(), "undo command must fail when nothing typed in current visit");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use super::{Ui, WordApp, big, big_toggle, group, json, menu_button, small, vec2}
|
||||
/// and Templates groups.
|
||||
pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
|
||||
let v = app.session.view.clone();
|
||||
let v_source = app.session.md_source.is_some();
|
||||
group(ui, "Views", None, app, |ui, app| {
|
||||
big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false);
|
||||
big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false);
|
||||
@@ -12,6 +13,8 @@ pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "outline", Some("Outline"), "Outline", crate::widgets::ButtonAction::new("view.outline", json!({}), false));
|
||||
small(ui, app, "draft", Some("Draft"), "Draft", crate::widgets::ButtonAction::new("view.draft", json!({}), false));
|
||||
let source = crate::widgets::ButtonAction::new("view.markdownSource", json!({}), v_source);
|
||||
small(ui, app, "markdownSource", Some("Markdown"), "Markdown Source", source);
|
||||
});
|
||||
});
|
||||
group(ui, "Immersive", None, app, |ui, app| {
|
||||
|
||||
@@ -3274,6 +3274,20 @@ fn window_close_save_writes_the_file_and_quits() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Save As suggests a Markdown file's own format, as it does a Word file's; anything else is .docx.
|
||||
#[test]
|
||||
fn save_as_suggests_a_markdown_files_own_format() {
|
||||
let dir = scratch("save-name");
|
||||
for (name, suggested) in [("notes.md", "notes.md"), ("notes.markdown", "notes.markdown"), ("notes.MD", "notes.MD"), ("notes.txt", "notes.docx")] {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, b"Hello\n").unwrap();
|
||||
let mut a = app();
|
||||
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(a.default_save_name(), suggested, "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #2376: a text file opened, edited and saved still asked to save on close, and Save in that
|
||||
/// prompt wrote the file but left the window open; only Don't Save closed it.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user