Skip to main content

text_document/
document.rs

1//! TextDocument implementation.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use crate::{DocumentError, Result};
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as BASE64;
10
11use crate::{
12    DjotExportOptions, DjotImportOptions, MarkdownExportOptions, PlainTextExportOptions,
13    ResourceType, TextDirection, WrapMode,
14};
15use frontend::commands::{
16    block_commands, document_commands, document_inspection_commands, document_io_commands,
17    document_search_commands, frame_commands, resource_commands, table_cell_commands,
18    table_commands, undo_redo_commands,
19};
20
21use crate::HtmlExportOptions;
22use crate::convert::{self, to_i64, to_usize};
23use crate::cursor::TextCursor;
24use crate::events::{self, DocumentEvent, Subscription};
25use crate::flow::FormatChangeKind;
26use crate::inner::TextDocumentInner;
27use crate::operation::{
28    DjotImportResult, DocxExportResult, EpubExportResult, HtmlImportResult, MarkdownImportResult,
29    OdtExportResult, Operation, PdfExportResult,
30};
31use crate::{BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions, ReplaceRange};
32
33/// A rich text document.
34///
35/// Owns the backend (database, event hub, undo/redo manager) and provides
36/// document-level operations. All cursor-based editing goes through
37/// [`TextCursor`], obtained via [`cursor()`](TextDocument::cursor) or
38/// [`cursor_at()`](TextDocument::cursor_at).
39///
40/// Internally uses `Arc<Mutex<...>>` so that multiple [`TextCursor`]s can
41/// coexist and edit concurrently. Cloning a `TextDocument` creates a new
42/// handle to the **same** underlying document (like Qt's implicit sharing).
43#[derive(Clone)]
44pub struct TextDocument {
45    pub(crate) inner: Arc<Mutex<TextDocumentInner>>,
46}
47
48/// Test-only accessor for the underlying rope-backed store. Not part
49/// of the stable public API.
50impl TextDocument {
51    #[doc(hidden)]
52    pub fn rope_store_for_test(&self) -> std::sync::Arc<common::database::Store> {
53        let inner = self.inner.lock();
54        std::sync::Arc::clone(inner.ctx.db_context.get_store())
55    }
56}
57
58impl TextDocument {
59    // ── Construction ──────────────────────────────────────────
60
61    /// Create a new, empty document.
62    ///
63    /// # Panics
64    ///
65    /// Panics if the database context cannot be created (e.g. filesystem error).
66    /// Use [`TextDocument::try_new`] for a fallible alternative.
67    pub fn new() -> Self {
68        Self::try_new().expect("failed to initialize document")
69    }
70
71    /// Create a new, empty document, returning an error on failure.
72    pub fn try_new() -> Result<Self> {
73        let ctx = frontend::AppContext::new();
74        let doc_inner = TextDocumentInner::initialize(ctx)?;
75        let inner = Arc::new(Mutex::new(doc_inner));
76
77        // Bridge backend long-operation events to public DocumentEvent.
78        Self::subscribe_long_operation_events(&inner);
79
80        Ok(Self { inner })
81    }
82
83    /// Create a document inside a shared [`DocumentBackend`].
84    ///
85    /// The document keeps its own store and its own undo stack, because undo
86    /// snapshots and restores a whole store and two documents sharing one would
87    /// roll each other back. What it shares is the event hub, the single thread
88    /// draining it, and the long-operation manager.
89    ///
90    /// Use this wherever a host opens many documents at once. Each
91    /// [`TextDocument::new`] starts an OS thread of its own, so a manuscript
92    /// stream over a book-length project starts one per scene.
93    ///
94    /// The backend must outlive every document built in it: dropping it stops
95    /// the pump. Holding a [`DocumentBackend`] clone beside the documents is
96    /// enough, and it is what the documents themselves do.
97    ///
98    /// [`DocumentBackend`]: crate::DocumentBackend
99    pub fn new_in(backend: &crate::DocumentBackend) -> Self {
100        Self::try_new_in(backend).expect("failed to initialize document")
101    }
102
103    /// [`new_in`](Self::new_in), returning an error instead of panicking.
104    pub fn try_new_in(backend: &crate::DocumentBackend) -> Result<Self> {
105        let doc_inner = TextDocumentInner::initialize_in(backend)?;
106        let inner = Arc::new(Mutex::new(doc_inner));
107        Self::subscribe_long_operation_events(&inner);
108        Ok(Self { inner })
109    }
110
111    /// Subscribe to backend long-operation events and bridge them to DocumentEvent.
112    fn subscribe_long_operation_events(inner: &Arc<Mutex<TextDocumentInner>>) {
113        use frontend::common::event::{LongOperationEvent as LOE, Origin};
114
115        let weak = Arc::downgrade(inner);
116        let mut locked = inner.lock();
117        // In a shared backend the subscriptions go on the backend's client,
118        // because that is the one with a thread behind it. The document's own
119        // client exists but was never started: a second drain on one hub would
120        // compete for each event, and flume hands an event to exactly one
121        // receiver, so half of them would reach the wrong document.
122        let client = match &locked.backend {
123            Some(backend) => backend.client().clone(),
124            None => locked.event_client.clone(),
125        };
126
127        // Progress
128        let w = weak.clone();
129        let progress_tok = client.subscribe(Origin::LongOperation(LOE::Progress), move |event| {
130            if let Some(inner) = w.upgrade() {
131                let (op_id, percent, message) = parse_progress_data(&event.data);
132                let mut inner = inner.lock();
133                if !inner.owns_operation(&op_id) {
134                    return;
135                }
136                inner.queue_event(DocumentEvent::LongOperationProgress {
137                    operation_id: op_id,
138                    percent,
139                    message,
140                });
141            }
142        });
143
144        // Completed
145        let w = weak.clone();
146        let completed_tok = client.subscribe(Origin::LongOperation(LOE::Completed), move |event| {
147            if let Some(inner) = w.upgrade() {
148                let op_id = parse_id_data(&event.data);
149                let mut inner = inner.lock();
150                if !inner.owns_operation(&op_id) {
151                    return;
152                }
153                inner.own_operations.remove(&op_id);
154                inner.queue_event(DocumentEvent::DocumentReset);
155                inner.check_block_count_changed();
156                inner.reset_cached_child_order();
157                inner.queue_event(DocumentEvent::LongOperationFinished {
158                    operation_id: op_id,
159                    success: true,
160                    error: None,
161                });
162            }
163        });
164
165        // Cancelled
166        let w = weak.clone();
167        let cancelled_tok = client.subscribe(Origin::LongOperation(LOE::Cancelled), move |event| {
168            if let Some(inner) = w.upgrade() {
169                let op_id = parse_id_data(&event.data);
170                let mut inner = inner.lock();
171                if !inner.owns_operation(&op_id) {
172                    return;
173                }
174                inner.own_operations.remove(&op_id);
175                inner.queue_event(DocumentEvent::LongOperationFinished {
176                    operation_id: op_id,
177                    success: false,
178                    error: Some("cancelled".into()),
179                });
180            }
181        });
182
183        // Failed
184        let failed_tok = client.subscribe(Origin::LongOperation(LOE::Failed), move |event| {
185            if let Some(inner) = weak.upgrade() {
186                let (op_id, error) = parse_failed_data(&event.data);
187                let mut inner = inner.lock();
188                if !inner.owns_operation(&op_id) {
189                    return;
190                }
191                inner.own_operations.remove(&op_id);
192                inner.queue_event(DocumentEvent::LongOperationFinished {
193                    operation_id: op_id,
194                    success: false,
195                    error: Some(error),
196                });
197            }
198        });
199
200        locked.long_op_subscriptions.extend([
201            progress_tok,
202            completed_tok,
203            cancelled_tok,
204            failed_tok,
205        ]);
206    }
207
208    // ── Whole-document content ────────────────────────────────
209
210    /// Replace the entire document with plain text. Clears undo history.
211    pub fn set_plain_text(&self, text: &str) -> Result<()> {
212        let queued = {
213            let mut inner = self.inner.lock();
214            let dto = frontend::document_io::ImportPlainTextDto {
215                plain_text: text.into(),
216            };
217            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
218            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
219            inner.invalidate_text_cache();
220            inner.rehighlight_all();
221            inner.queue_event(DocumentEvent::DocumentReset);
222            inner.check_block_count_changed();
223            inner.reset_cached_child_order();
224            inner.queue_event(DocumentEvent::UndoRedoChanged {
225                can_undo: false,
226                can_redo: false,
227            });
228            inner.take_queued_events()
229        };
230        crate::inner::dispatch_queued_events(queued);
231        Ok(())
232    }
233
234    /// Export the entire document as plain text, in reading order.
235    ///
236    /// This is the **human-readable** view: prose only. Embedded objects (a table) contribute
237    /// their content but not the `U+FFFC` anchor the document holds where they sit — which is
238    /// what you want for a `cat`-style export, and is why the crate's fast path bails the
239    /// moment a table exists.
240    ///
241    /// **Do not compute offsets from this string.** It is deliberately not
242    /// character-for-character the text a search runs against: that text carries the object
243    /// anchors, so a position taken here is short by two characters per preceding table. For
244    /// an addressable view — one whose offsets [`find_all`](Self::find_all),
245    /// [`replace_text`](Self::replace_text), a block's
246    /// [`position()`](crate::TextBlock::position) and a cursor all agree with — use
247    /// [`to_addressable_text`](Self::to_addressable_text) on a live document, or
248    /// [`djot_to_plain_text`](crate::djot_to_plain_text) when all you hold is Djot source.
249    ///
250    /// The two are allowed to differ in that one respect and no other; in particular they
251    /// agree on **order**. They did not always: this export used to hoist every blockquote's
252    /// prose to the end of the document (`"> a0\n\na"` came back as `"a\na0"`), because it
253    /// concatenated frames in creation order instead of sorting all blocks by
254    /// `document_position`. See `plain_text_order_tests`.
255    pub fn to_plain_text(&self) -> Result<String> {
256        let mut inner = self.inner.lock();
257        Ok(inner.plain_text()?.to_string())
258    }
259
260    /// [`to_plain_text`](Self::to_plain_text) for writing an actual `.txt` file: quoted
261    /// blocks are indented four spaces per blockquote level, so an epigraph or a block
262    /// quotation still reads as set-off matter in a format with no markup to say so.
263    ///
264    /// **Not** interchangeable with [`to_plain_text`](Self::to_plain_text), and not cached.
265    /// That one is pinned to the document's addressable text — the text
266    /// [`find_all`](Self::find_all) and [`replace_text`](Self::replace_text) compute
267    /// offsets against — in everything but the object anchors, so indenting it would shift
268    /// every offset inside a quote and desynchronise search from the document. Use this
269    /// only for output nobody addresses back into the document.
270    pub fn to_plain_text_indented(&self) -> Result<String> {
271        let inner = self.inner.lock();
272        let dto = document_io_commands::export_plain_text_indented(&inner.ctx)?;
273        Ok(dto.plain_text)
274    }
275
276    /// [`to_plain_text`](Self::to_plain_text) with every presentation option chosen
277    /// explicitly — quoted-block indentation, and a `U+000C` form feed before each block
278    /// that asks to start a new page.
279    ///
280    /// Subject to the same warning as [`to_plain_text_indented`](Self::to_plain_text_indented):
281    /// anything other than [`PlainTextExportOptions::addressable`] shifts offsets, so this is
282    /// for files being written out, never for text anyone addresses back into the document.
283    pub fn to_plain_text_with(&self, options: PlainTextExportOptions) -> Result<String> {
284        let inner = self.inner.lock();
285        let dto = document_io_commands::export_plain_text_with(&inner.ctx, options)?;
286        Ok(dto.plain_text)
287    }
288
289    /// The document's **addressable text**: the exact string every offset this document
290    /// deals out is an index into.
291    ///
292    /// One char space runs through the whole API — [`find_all`](Self::find_all) match
293    /// positions, [`replace_ranges`](Self::replace_ranges) ranges, a block's
294    /// [`position()`](crate::TextBlock::position), a cursor, an editor widget's selection.
295    /// This is the string that space addresses, character for character: an embedded
296    /// table occupies its `U+FFFC` [`TABLE_ANCHOR`](crate::TABLE_ANCHOR) here (plus its
297    /// `\n` separator), exactly as the document holds it.
298    ///
299    /// Use it whenever a document offset and a document string travel together — capturing
300    /// the quoted text under a selection, pairing block starts with the text they index,
301    /// slicing context around a search hit. Pairing an offset with
302    /// [`to_plain_text`](Self::to_plain_text) instead is the classic form of this bug: that
303    /// is the human-readable **export**, it omits the anchors, and every offset after a
304    /// table lands two characters off in it.
305    ///
306    /// Built by the same code path [`find_all`](Self::find_all) uses to build the text it
307    /// searches, so the two cannot diverge. For the same view of bare Djot source — no live
308    /// document at hand — use [`djot_to_plain_text`](crate::djot_to_plain_text), which is
309    /// pinned to produce this very string for the same content. Not cached; it is a fresh
310    /// read of the document each call.
311    pub fn to_addressable_text(&self) -> Result<String> {
312        let inner = self.inner.lock();
313        let dto = document_search_commands::addressable_text(&inner.ctx)?;
314        Ok(dto.text)
315    }
316
317    /// Replace the entire document with Markdown. Clears undo history.
318    ///
319    /// This is a **long operation**. Returns a typed [`Operation`] handle.
320    pub fn set_markdown(&self, markdown: &str) -> Result<Operation<MarkdownImportResult>> {
321        let mut inner = self.inner.lock();
322        inner.invalidate_text_cache();
323        let dto = frontend::document_io::ImportMarkdownDto {
324            markdown_text: markdown.into(),
325        };
326        let op_id = document_io_commands::import_markdown(&inner.ctx, &dto)?;
327        inner.own_operations.insert(op_id.clone());
328        Ok(Operation::new(
329            op_id,
330            &inner.ctx,
331            Box::new(|ctx, id| {
332                document_io_commands::get_import_markdown_result(ctx, id)
333                    .ok()
334                    .flatten()
335                    .map(|r| {
336                        Ok(MarkdownImportResult {
337                            block_count: to_usize(r.block_count),
338                        })
339                    })
340            }),
341        ))
342    }
343
344    /// Export the entire document as Markdown.
345    pub fn to_markdown(&self) -> Result<String> {
346        let inner = self.inner.lock();
347        let dto = document_io_commands::export_markdown(&inner.ctx)?;
348        Ok(dto.markdown_text)
349    }
350
351    /// [`to_markdown`](Self::to_markdown) with the presentation opt-ins — today, whether a
352    /// block that asks to start a new page gets a raw-HTML page break emitted above it.
353    /// Off by default, because raw HTML is not Markdown.
354    pub fn to_markdown_with(&self, options: MarkdownExportOptions) -> Result<String> {
355        let inner = self.inner.lock();
356        let dto = document_io_commands::export_markdown_with(&inner.ctx, options)?;
357        Ok(dto.markdown_text)
358    }
359
360    /// Replace the entire document with djot markup. Clears undo history.
361    ///
362    /// This is a **long operation**. Returns a typed [`Operation`] handle.
363    pub fn set_djot(&self, djot: &str) -> Result<Operation<DjotImportResult>> {
364        self.set_djot_with_options(djot, DjotImportOptions::default())
365    }
366
367    /// Replace the entire document with djot markup, selecting which optional
368    /// block attributes (alignment, line height, direction, non-breakable
369    /// lines, background color) are applied via `options`. Clears undo history.
370    ///
371    /// This is a **long operation**. Returns a typed [`Operation`] handle.
372    pub fn set_djot_with_options(
373        &self,
374        djot: &str,
375        options: DjotImportOptions,
376    ) -> Result<Operation<DjotImportResult>> {
377        let mut inner = self.inner.lock();
378        inner.invalidate_text_cache();
379        let dto = frontend::document_io::ImportDjotDto {
380            djot_text: djot.into(),
381            options,
382        };
383        let op_id = document_io_commands::import_djot(&inner.ctx, &dto)?;
384        inner.own_operations.insert(op_id.clone());
385        Ok(Operation::new(
386            op_id,
387            &inner.ctx,
388            Box::new(|ctx, id| {
389                document_io_commands::get_import_djot_result(ctx, id)
390                    .ok()
391                    .flatten()
392                    .map(|r| {
393                        Ok(DjotImportResult {
394                            block_count: to_usize(r.block_count),
395                        })
396                    })
397            }),
398        ))
399    }
400
401    /// Replace the entire document with djot markup, **synchronously**, on the
402    /// calling thread. Clears undo history.
403    ///
404    /// This is the right call for *loading* a document's initial content — the
405    /// case where the caller is going to block for the result anyway.
406    /// [`set_djot`](Self::set_djot) starts a long operation: it spawns a thread,
407    /// and the caller then blocks in [`Operation::wait`] until that thread
408    /// publishes. That round trip is pure overhead when there is no frame loop to
409    /// keep responsive, and it does not shrink with the input — an *empty*
410    /// document costs the same thread spawn and hand-off as a full one. Loading N
411    /// documents in a loop paid it N times.
412    ///
413    /// Prefer [`set_djot`](Self::set_djot) when the import is genuinely long and
414    /// the caller must stay responsive (it reports progress and can be
415    /// cancelled); prefer this when the caller just wants the content in.
416    ///
417    /// Observationally equivalent to `set_djot(..).wait()` — same import, same
418    /// `DocumentReset`, same cache/block bookkeeping — except that, having no
419    /// operation, it emits no `LongOperation*` events and cannot be cancelled.
420    pub fn set_djot_sync(&self, djot: &str) -> Result<DjotImportResult> {
421        self.set_djot_sync_with_options(djot, DjotImportOptions::default())
422    }
423
424    /// As [`set_djot_sync`](Self::set_djot_sync), selecting which optional block
425    /// attributes are applied via `options`.
426    pub fn set_djot_sync_with_options(
427        &self,
428        djot: &str,
429        options: DjotImportOptions,
430    ) -> Result<DjotImportResult> {
431        let (queued, block_count) = {
432            let mut inner = self.inner.lock();
433            inner.invalidate_text_cache();
434            let dto = frontend::document_io::ImportDjotDto {
435                djot_text: djot.into(),
436                options,
437            };
438            let result = document_io_commands::import_djot_sync(&inner.ctx, &dto)?;
439            // The same settling the async path performs when its operation
440            // completes (see `subscribe_long_operation_events`), done inline here
441            // because there is no completion event to hang it off.
442            inner.queue_event(DocumentEvent::DocumentReset);
443            inner.check_block_count_changed();
444            inner.reset_cached_child_order();
445            (inner.take_queued_events(), result.block_count)
446        };
447        // Dispatch outside the lock — a subscriber is free to call back in.
448        crate::inner::dispatch_queued_events(queued);
449        Ok(DjotImportResult {
450            block_count: to_usize(block_count),
451        })
452    }
453
454    /// Export the entire document as djot markup.
455    pub fn to_djot(&self) -> Result<String> {
456        self.to_djot_with_options(DjotExportOptions::default())
457    }
458
459    /// Export the entire document as djot markup, selecting which optional block
460    /// attributes (alignment, line height, direction, non-breakable lines,
461    /// background color) are emitted via `options`.
462    pub fn to_djot_with_options(&self, options: DjotExportOptions) -> Result<String> {
463        let inner = self.inner.lock();
464        let dto = document_io_commands::export_djot(&inner.ctx, &options)?;
465        Ok(dto.djot_text)
466    }
467
468    /// Replace the entire document with HTML. Clears undo history.
469    ///
470    /// This is a **long operation**. Returns a typed [`Operation`] handle.
471    pub fn set_html(&self, html: &str) -> Result<Operation<HtmlImportResult>> {
472        let mut inner = self.inner.lock();
473        inner.invalidate_text_cache();
474        let dto = frontend::document_io::ImportHtmlDto {
475            html_text: html.into(),
476        };
477        let op_id = document_io_commands::import_html(&inner.ctx, &dto)?;
478        inner.own_operations.insert(op_id.clone());
479        Ok(Operation::new(
480            op_id,
481            &inner.ctx,
482            Box::new(|ctx, id| {
483                document_io_commands::get_import_html_result(ctx, id)
484                    .ok()
485                    .flatten()
486                    .map(|r| {
487                        Ok(HtmlImportResult {
488                            block_count: to_usize(r.block_count),
489                        })
490                    })
491            }),
492        ))
493    }
494
495    /// Export the entire document as HTML.
496    ///
497    /// Inline images keep whatever `src` the document stores; placing the files
498    /// those point at is the caller's business. Use
499    /// [`to_html_with_options`](Self::to_html_with_options) to inline them
500    /// instead, or to drop them.
501    pub fn to_html(&self) -> Result<String> {
502        let inner = self.inner.lock();
503        let dto = document_io_commands::export_html(&inner.ctx)?;
504        Ok(dto.html_text)
505    }
506
507    /// Export as HTML, choosing how inline images are represented.
508    pub fn to_html_with_options(&self, options: HtmlExportOptions) -> Result<String> {
509        let inner = self.inner.lock();
510        let dto = document_io_commands::export_html_with_options(&inner.ctx, options)?;
511        Ok(dto.html_text)
512    }
513
514    /// Export the entire document as LaTeX.
515    ///
516    /// Images are emitted as `\includegraphics{src}`, which LaTeX resolves
517    /// against the filesystem at compile time — so the caller is responsible for
518    /// placing those files beside the `.tex`. Use
519    /// [`to_latex_with_options`](Self::to_latex_with_options) to drop them
520    /// instead.
521    pub fn to_latex(&self, document_class: &str, include_preamble: bool) -> Result<String> {
522        self.to_latex_with_options(crate::LatexExportOptions {
523            document_class: document_class.into(),
524            include_preamble,
525            omit_images: false,
526        })
527    }
528
529    /// As [`to_latex`](Self::to_latex), but taking the full
530    /// [`LatexExportOptions`](crate::LatexExportOptions) — the same document class and preamble
531    /// knobs `to_latex` takes positionally, plus the choice of dropping inline images instead of
532    /// emitting `\includegraphics{…}` for them.
533    pub fn to_latex_with_options(&self, options: crate::LatexExportOptions) -> Result<String> {
534        let inner = self.inner.lock();
535        let dto = frontend::document_io::ExportLatexDto { options };
536        let result = document_io_commands::export_latex(&inner.ctx, &dto)?;
537        Ok(result.latex_text)
538    }
539
540    /// Export the entire document as DOCX to a file path.
541    ///
542    /// This is a **long operation**. Returns a typed [`Operation`] handle.
543    pub fn to_docx(&self, output_path: &str) -> Result<Operation<DocxExportResult>> {
544        self.to_docx_with_options(output_path, crate::DocxExportOptions::default())
545    }
546
547    /// As [`to_docx`](Self::to_docx), but with page geometry + base typography overrides — a
548    /// *manuscript* style (page size, margins, body font, line spacing, first-line indent,
549    /// alignment, and an optional page-number header). Per-block RTL is emitted automatically
550    /// from each block's own direction, independent of these options.
551    pub fn to_docx_with_options(
552        &self,
553        output_path: &str,
554        options: crate::DocxExportOptions,
555    ) -> Result<Operation<DocxExportResult>> {
556        let mut inner = self.inner.lock();
557        let dto = frontend::document_io::ExportDocxDto {
558            output_path: output_path.into(),
559            options,
560        };
561        let op_id = document_io_commands::export_docx(&inner.ctx, &dto)?;
562        inner.own_operations.insert(op_id.clone());
563        Ok(Operation::new(
564            op_id,
565            &inner.ctx,
566            Box::new(|ctx, id| {
567                document_io_commands::get_export_docx_result(ctx, id)
568                    .ok()
569                    .flatten()
570                    .map(|r| {
571                        Ok(DocxExportResult {
572                            file_path: r.file_path,
573                            paragraph_count: to_usize(r.paragraph_count),
574                        })
575                    })
576            }),
577        ))
578    }
579
580    /// Export the entire document as an EPUB 3 file to a file path.
581    ///
582    /// This is a **long operation**. Returns a typed [`Operation`] handle.
583    pub fn to_epub(&self, output_path: &str) -> Result<Operation<EpubExportResult>> {
584        self.to_epub_with_options(output_path, crate::EpubExportOptions::default())
585    }
586
587    /// As [`to_epub`](Self::to_epub), but with book-level metadata (title, author, language,
588    /// reading direction). The document is split into chapters at the shallowest heading level
589    /// present (e.g. every top-level `# Chapter` heading) — see
590    /// [`EpubExportOptions`](crate::EpubExportOptions) for details.
591    pub fn to_epub_with_options(
592        &self,
593        output_path: &str,
594        options: crate::EpubExportOptions,
595    ) -> Result<Operation<EpubExportResult>> {
596        let mut inner = self.inner.lock();
597        let dto = frontend::document_io::ExportEpubDto {
598            output_path: output_path.into(),
599            options,
600        };
601        let op_id = document_io_commands::export_epub(&inner.ctx, &dto)?;
602        inner.own_operations.insert(op_id.clone());
603        Ok(Operation::new(
604            op_id,
605            &inner.ctx,
606            Box::new(|ctx, id| {
607                document_io_commands::get_export_epub_result(ctx, id)
608                    .ok()
609                    .flatten()
610                    .map(|r| {
611                        Ok(EpubExportResult {
612                            file_path: r.file_path,
613                            chapter_count: to_usize(r.chapter_count),
614                        })
615                    })
616            }),
617        ))
618    }
619
620    /// Export the entire document as ODT (OpenDocument Text) to a file path.
621    ///
622    /// This is a **long operation**. Returns a typed [`Operation`] handle.
623    pub fn to_odt(&self, output_path: &str) -> Result<Operation<OdtExportResult>> {
624        self.to_odt_with_options(output_path, crate::OdtExportOptions::default())
625    }
626
627    /// As [`to_odt`](Self::to_odt), but with page geometry + base typography overrides — the ODT
628    /// analog of [`to_docx_with_options`](Self::to_docx_with_options), same units and same
629    /// per-block-RTL-is-automatic behaviour (see [`OdtExportOptions`](crate::OdtExportOptions)'s
630    /// own doc comment).
631    pub fn to_odt_with_options(
632        &self,
633        output_path: &str,
634        options: crate::OdtExportOptions,
635    ) -> Result<Operation<OdtExportResult>> {
636        let mut inner = self.inner.lock();
637        let dto = frontend::document_io::ExportOdtDto {
638            output_path: output_path.into(),
639            options,
640        };
641        let op_id = document_io_commands::export_odt(&inner.ctx, &dto)?;
642        inner.own_operations.insert(op_id.clone());
643        Ok(Operation::new(
644            op_id,
645            &inner.ctx,
646            Box::new(|ctx, id| {
647                document_io_commands::get_export_odt_result(ctx, id)
648                    .ok()
649                    .flatten()
650                    .map(|r| {
651                        Ok(OdtExportResult {
652                            file_path: r.file_path,
653                            paragraph_count: to_usize(r.paragraph_count),
654                        })
655                    })
656            }),
657        ))
658    }
659
660    /// Export the entire document as a PDF file, using the given options (page geometry,
661    /// typography, embedded font bytes, base language/direction).
662    ///
663    /// This is a **long operation**. Returns a typed [`Operation`] handle.
664    ///
665    /// Requires the `pdf` cargo feature on `text-document` (which forwards to `frontend`'s and
666    /// `document_io`'s own `pdf` features). If it was not enabled at compile time, this returns
667    /// `Err(DocumentError::Unsupported(..))` immediately rather than attempting the export — no
668    /// `#[cfg]` is needed at the call site either way.
669    pub fn to_pdf(
670        &self,
671        output_path: &str,
672        options: crate::PdfExportOptions,
673    ) -> Result<Operation<PdfExportResult>> {
674        self.to_pdf_with_options(output_path, options)
675    }
676
677    /// As [`to_pdf`](Self::to_pdf) — the two are identical; `to_pdf` is the plain entry point,
678    /// `to_pdf_with_options` exists (like [`to_docx_with_options`](Self::to_docx_with_options)
679    /// and [`to_epub_with_options`](Self::to_epub_with_options)) so the naming stays consistent
680    /// across the three file-based exporters, all of which take a mandatory options struct.
681    #[cfg(feature = "pdf")]
682    pub fn to_pdf_with_options(
683        &self,
684        output_path: &str,
685        options: crate::PdfExportOptions,
686    ) -> Result<Operation<PdfExportResult>> {
687        let inner = self.inner.lock();
688        let dto = frontend::document_io::ExportPdfDto {
689            output_path: output_path.into(),
690            options,
691        };
692        let op_id = document_io_commands::export_pdf(&inner.ctx, &dto)?;
693        inner.own_operations.insert(op_id.clone());
694        Ok(Operation::new(
695            op_id,
696            &inner.ctx,
697            Box::new(|ctx, id| {
698                document_io_commands::get_export_pdf_result(ctx, id)
699                    .ok()
700                    .flatten()
701                    .map(|r| {
702                        Ok(PdfExportResult {
703                            file_path: r.file_path,
704                            page_count: to_usize(r.page_count),
705                        })
706                    })
707            }),
708        ))
709    }
710
711    /// As [`to_pdf`](Self::to_pdf), when the `pdf` cargo feature was not enabled at compile
712    /// time — returns [`DocumentError::Unsupported`] immediately, without starting an operation
713    /// or touching the backend at all.
714    #[cfg(not(feature = "pdf"))]
715    pub fn to_pdf_with_options(
716        &self,
717        _output_path: &str,
718        _options: crate::PdfExportOptions,
719    ) -> Result<Operation<PdfExportResult>> {
720        Err(DocumentError::Unsupported(
721            "PDF export requires the `pdf` cargo feature on the `text-document` crate".into(),
722        ))
723    }
724
725    /// Clear all document content and reset to an empty state.
726    pub fn clear(&self) -> Result<()> {
727        let queued = {
728            let mut inner = self.inner.lock();
729            let dto = frontend::document_io::ImportPlainTextDto {
730                plain_text: String::new(),
731            };
732            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
733            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
734            inner.invalidate_text_cache();
735            inner.rehighlight_all();
736            inner.queue_event(DocumentEvent::DocumentReset);
737            inner.check_block_count_changed();
738            inner.reset_cached_child_order();
739            inner.queue_event(DocumentEvent::UndoRedoChanged {
740                can_undo: false,
741                can_redo: false,
742            });
743            inner.take_queued_events()
744        };
745        crate::inner::dispatch_queued_events(queued);
746        Ok(())
747    }
748
749    // ── Cursor factory ───────────────────────────────────────
750
751    /// Create a cursor at position 0.
752    pub fn cursor(&self) -> TextCursor {
753        self.cursor_at(0)
754    }
755
756    /// Create a cursor at the given position. If `position` falls
757    /// inside an extended grapheme cluster (decomposed accents, ZWJ
758    /// emoji, skin-tone sequences, flag pairs), the cursor snaps
759    /// forward to the end of the containing cluster so subsequent
760    /// `NextCharacter`/`PreviousCharacter` round-trips remain identity.
761    pub fn cursor_at(&self, position: usize) -> TextCursor {
762        let data = {
763            let mut inner = self.inner.lock();
764            inner.register_cursor(position)
765        };
766        let cursor = TextCursor {
767            doc: self.inner.clone(),
768            data,
769        };
770        cursor.snap_position_to_grapheme_boundary();
771        cursor
772    }
773
774    // ── Document queries ─────────────────────────────────────
775
776    /// Get document statistics. O(1) — reads cached values.
777    pub fn stats(&self) -> DocumentStats {
778        let inner = self.inner.lock();
779        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
780            .expect("get_document_stats should not fail");
781        DocumentStats::from(&dto)
782    }
783
784    /// Tell the document what each footnote label should print.
785    ///
786    /// Presentation only: never stored, never exported, never part of the text. A
787    /// reference occupies one character whatever its marker says.
788    ///
789    /// Set it when the numbers are a fact about something larger than this
790    /// document — a host compiling one chapter of a book knows the chapter's notes
791    /// continue a sequence this document cannot see. Leave it unset and the
792    /// document numbers its own references in reading order, which is right when
793    /// the document *is* the whole text.
794    /// Storing the map is only half of it: a marker is **shaped text**, so a
795    /// document already laid out keeps drawing the old one until something tells
796    /// it to reshape. Nothing else will — the map is presentation state and
797    /// changing it edits no block, so it emits no edit event of its own. Without
798    /// the notification below, a host that numbers a note the instant it is
799    /// created watches the raw label sit in the writer's prose until an unrelated
800    /// keystroke happens to force a relayout.
801    ///
802    /// `FormatChanged` over the whole document rather than a paint-only event:
803    /// the marker's width changes with its text (`9` and `10` are not the same
804    /// size), so the line has to be reshaped, not recoloured. Guarded on the map
805    /// actually differing, because a host pushes this on every refresh and a
806    /// full relayout per keystroke is not a thing to do by accident.
807    pub fn set_footnote_markers(&self, markers: std::collections::HashMap<String, String>) {
808        let queued = {
809            let mut inner = self.inner.lock();
810            {
811                let store = inner.ctx.db_context.get_store();
812                let mut current = store.footnote_markers.write();
813                if *current == markers {
814                    return;
815                }
816                *current = markers;
817            }
818            inner.queue_event(DocumentEvent::FormatChanged {
819                position: 0,
820                length: 0,
821                kind: crate::flow::FormatChangeKind::Character,
822            });
823            inner.take_queued_events()
824        };
825        crate::inner::dispatch_queued_events(queued);
826    }
827
828    /// Every footnote reference in the document, as `(position, label)`, in
829    /// reading order.
830    ///
831    /// The seam a host uses to tie its own note storage to the prose: Skribisto
832    /// keeps note bodies in its store, so what it needs from the document is
833    /// only *where* the references are and *which* note each names.
834    ///
835    /// Positions are document-absolute character offsets — the same space a
836    /// cursor and a search hit use — so a caller can go straight from a caret to
837    /// the note under it without a second lookup.
838    pub fn footnote_references(&self) -> Vec<(usize, String)> {
839        let inner = self.inner.lock();
840        let store = inner.ctx.db_context.get_store();
841
842        let refs = store.block_footnote_refs.read();
843        if refs.is_empty() {
844            return Vec::new();
845        }
846
847        // Block order, then byte order within a block — the order they are read.
848        let mut blocks: Vec<(i64, u64)> = store
849            .blocks
850            .read()
851            .values()
852            .map(|b| (b.document_position, b.id))
853            .collect();
854        blocks.sort_unstable();
855
856        let mut out = Vec::new();
857        for (position, block_id) in blocks {
858            let Some(anchors) = refs.get(&block_id) else {
859                continue;
860            };
861            let Some(block) = store.blocks.read().get(&block_id).cloned() else {
862                continue;
863            };
864            let text =
865                frontend::common::database::rope_helpers::block_content_via_store(&block, store);
866            let mut ordered: Vec<_> = anchors.iter().collect();
867            ordered.sort_by_key(|a| a.byte_offset);
868            for anchor in ordered {
869                // Byte offset within the block → character offset within the
870                // document. The two differ the moment the block holds anything
871                // outside ASCII, which for prose is immediately.
872                let chars_before = text
873                    .get(..anchor.byte_offset as usize)
874                    .map(|s| s.chars().count())
875                    .unwrap_or(0);
876                out.push((position as usize + chars_before, anchor.label.clone()));
877            }
878        }
879        out
880    }
881
882    /// The label of the footnote reference at `position`, if one sits there.
883    ///
884    /// What "the caret is on a footnote" means, for a host wiring a two-way
885    /// selection between its notes list and the prose.
886    pub fn footnote_reference_at(&self, position: usize) -> Option<String> {
887        self.footnote_references()
888            .into_iter()
889            .find(|(at, _)| *at == position)
890            .map(|(_, label)| label)
891    }
892
893    /// Whether this document was built inside a [`DocumentBackend`], rather than
894    /// standing alone with an event hub and a drain thread of its own.
895    ///
896    /// The question a host asks of its own wiring. Documents it means to keep for
897    /// the life of a project — a comment body, a footnote body, one per row of a
898    /// stream — belong in a shared backend, and one built with
899    /// [`TextDocument::new`] instead is indistinguishable in use while costing an
900    /// OS thread that will never have anything to deliver. Nothing else reports
901    /// that, so nothing else can test for it.
902    ///
903    /// [`DocumentBackend`]: crate::DocumentBackend
904    pub fn shares_a_backend(&self) -> bool {
905        self.inner.lock().backend.is_some()
906    }
907
908    /// Get the total character count. One entity read, and no document walk.
909    ///
910    /// It reads the count the `Document` entity carries, through
911    /// `crate::inner::document_counts`. It used to go through
912    /// `get_document_stats`, which returns the same number and then walks every
913    /// block materialising its text for the word count in the same DTO — so this
914    /// call, which a host may make once per widget per layout pass, cost a
915    /// complete scan of the document. `stats()` still pays that walk, and should:
916    /// it is the caller that asked for the word count.
917    pub fn character_count(&self) -> usize {
918        let inner = self.inner.lock();
919        crate::inner::document_counts(&inner).map_or(0, |(chars, _)| chars)
920    }
921
922    /// Get the number of blocks (paragraphs). One entity read, and no document
923    /// walk. See [`character_count`](Self::character_count) for what that
924    /// replaced.
925    pub fn block_count(&self) -> usize {
926        let inner = self.inner.lock();
927        crate::inner::document_counts(&inner).map_or(0, |(_, blocks)| blocks)
928    }
929
930    /// Returns true if the document has no text content.
931    pub fn is_empty(&self) -> bool {
932        self.character_count() == 0
933    }
934
935    /// Get text at a position for a given length.
936    pub fn text_at(&self, position: usize, length: usize) -> Result<String> {
937        let inner = self.inner.lock();
938        let dto = frontend::document_inspection::GetTextAtPositionDto {
939            position: to_i64(position),
940            length: to_i64(length),
941        };
942        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
943        Ok(result.text)
944    }
945
946    /// Find the inline segment containing `position` and return its
947    /// stable element id (synthesized from `(block_id, byte_start)`
948    /// via [`common::format_runs::synth_element_id`]) together with the
949    /// segment's absolute start position and the character offset of
950    /// `position` within the segment. Used by accessibility layers to
951    /// convert a document-absolute character position into the
952    /// `(element_id, character_index_in_run)` coordinate space
953    /// AccessKit's `TextPosition` expects.
954    ///
955    /// Returns `None` when the position is outside the document.
956    /// Returns the element at position `position - 1` when `position`
957    /// falls exactly on an element boundary, matching the "cursor
958    /// belongs to the preceding element at a boundary" convention
959    /// used throughout text-document.
960    pub fn find_element_at_position(&self, position: usize) -> Option<(u64, usize, usize)> {
961        // Caret semantics, per the boundary convention documented just above: with the
962        // character-index `block_at`, a position at the end of a paragraph resolved to the
963        // *next* block and the `checked_sub` below then failed, so the last element of every
964        // paragraph was unreachable.
965        let block_info = self.block_at_caret(position).ok()?;
966        let block_start = block_info.start;
967        let offset_in_block = position.checked_sub(block_start)?;
968        let block = crate::text_block::TextBlock {
969            doc: std::sync::Arc::clone(&self.inner),
970            block_id: block_info.block_id,
971        };
972        let frags = block.fragments();
973        // Walk fragments; match the fragment that contains
974        // `offset_in_block`. For a boundary position shared with the
975        // next fragment, prefer the preceding fragment (boundary
976        // belongs to the end of the previous element).
977        let mut last_text: Option<(u64, usize, usize, usize)> = None; // (id, abs_start, frag_offset, frag_length)
978        for frag in &frags {
979            match frag {
980                crate::flow::FragmentContent::Text {
981                    offset,
982                    length,
983                    element_id,
984                    ..
985                } => {
986                    let frag_start = *offset;
987                    let frag_end = frag_start + *length;
988                    if offset_in_block >= frag_start && offset_in_block < frag_end {
989                        let abs_start = block_start + frag_start;
990                        let offset_within = offset_in_block - frag_start;
991                        return Some((*element_id, abs_start, offset_within));
992                    }
993                    // Record as a candidate for the "end-of-element"
994                    // boundary fallback (offset_in_block == frag_end).
995                    if offset_in_block == frag_end {
996                        last_text =
997                            Some((*element_id, block_start + frag_start, frag_start, *length));
998                    }
999                }
1000                // Both objects occupy exactly one position and answer for it
1001                // whole — there is no offset *inside* either to report.
1002                crate::flow::FragmentContent::Image {
1003                    offset, element_id, ..
1004                }
1005                | crate::flow::FragmentContent::FootnoteReference {
1006                    offset, element_id, ..
1007                } => {
1008                    if offset_in_block == *offset {
1009                        return Some((*element_id, block_start + offset, 0));
1010                    }
1011                }
1012            }
1013        }
1014        // Boundary fallback: position was at the end of the last text
1015        // fragment we saw.
1016        last_text.map(|(id, abs_start, _, length)| (id, abs_start, length))
1017    }
1018
1019    /// Get info about the block at a position. O(log n).
1020    ///
1021    /// `position` is read as a **character index**, so the inter-block separator belongs to
1022    /// the block that *follows* it: in `"abc\ndef"`, position 3 is the newline and reports the
1023    /// second block. For a **caret** offset — where 3 means "after the c", the last place the
1024    /// caret can sit in the first paragraph — use [`block_at_caret`](Self::block_at_caret).
1025    pub fn block_at(&self, position: usize) -> Result<BlockInfo> {
1026        let inner = self.inner.lock();
1027        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1028            position: to_i64(position),
1029        };
1030        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1031        Ok(BlockInfo::from(&result))
1032    }
1033
1034    /// The block a **caret** at `position` sits in. O(log n).
1035    ///
1036    /// Differs from [`block_at`](Self::block_at) at exactly one place: the end of a paragraph.
1037    /// A character index and a caret offset disagree there — the character at that index is the
1038    /// separator, which belongs to the next block, but a caret there is still in the paragraph
1039    /// it just finished typing. `block_at` answers the first question (and callers that walk
1040    /// text depend on it — moving the caret across a separator, reading the character under an
1041    /// offset); this answers the second.
1042    ///
1043    /// Ask this one whenever the position came from a cursor. Asking `block_at` instead is why
1044    /// the caret-band highlight lit the *next* paragraph the moment the caret reached the end of
1045    /// one.
1046    pub fn block_at_caret(&self, position: usize) -> Result<BlockInfo> {
1047        let inner = self.inner.lock();
1048        let info = crate::inner::block_at_caret_dto(&inner.ctx, position)?;
1049        Ok(BlockInfo::from(&info))
1050    }
1051
1052    /// The sentence containing `position`, as absolute char offsets `(start, end)` — the
1053    /// granularity between [`word`](TextCursor::select) and [`block_at`](Self::block_at).
1054    ///
1055    /// `content_locale` is a BCP-47-ish tag (`"en"`, `"en-US"`, `"pt_BR"`) naming the language
1056    /// the text is written in. It selects the sentence tailoring for that language —
1057    /// abbreviations that do not end a sentence, French spaced guillemets, the Greek question
1058    /// mark. Pass it **fresh on every call**, like [`FindOptions::language`](crate::FindOptions):
1059    /// only the caller knows what language the text is in, and it is not document state.
1060    /// `None`, or a language with no tailoring, falls back to plain UAX #29.
1061    ///
1062    /// A sentence never crosses a block: a paragraph break always ends one. Returns `None` when
1063    /// the block holds no sentence to point at (empty, or whitespace only). Trailing whitespace
1064    /// is trimmed off the end, so the range covers the sentence and not the gap after it.
1065    ///
1066    /// The trailing edge is inclusive of the caret: a `position` at the very end of the block
1067    /// resolves to the last sentence of *that* block rather than to the first sentence of the
1068    /// next one. `position` is a caret offset, so the block is resolved with
1069    /// [`block_at_caret`](Self::block_at_caret) and not with the character-index
1070    /// [`block_at`](Self::block_at).
1071    pub fn sentence_at(
1072        &self,
1073        position: usize,
1074        content_locale: Option<&str>,
1075    ) -> Option<(usize, usize)> {
1076        // Resolved before the lock: `block_at_caret` takes it itself.
1077        let block = self.block_at_caret(position).ok()?;
1078        let inner = self.inner.lock();
1079        let block_start = block.start;
1080        let block_length = block.length;
1081        if block_length == 0 {
1082            return None;
1083        }
1084        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
1085            position: to_i64(block_start),
1086            length: to_i64(block_length),
1087        };
1088        let text = document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto)
1089            .ok()?
1090            .text;
1091        drop(inner);
1092
1093        let offset = position.saturating_sub(block_start);
1094        let (start, end) =
1095            frontend::common::parser_tools::sentence_bounds(&text, offset, content_locale)?;
1096        Some((block_start + start, block_start + end))
1097    }
1098
1099    /// Get the block format at a position.
1100    ///
1101    /// `position` is read with **caret** semantics ([`block_at_caret`](Self::block_at_caret)):
1102    /// at the end of a paragraph this reports that paragraph's format, not the next one's.
1103    /// Formatting queries are asked about a cursor, never about a character index.
1104    pub fn block_format_at(&self, position: usize) -> Result<BlockFormat> {
1105        let inner = self.inner.lock();
1106        let block_info = crate::inner::block_at_caret_dto(&inner.ctx, position)?;
1107        let block_id = block_info.block_id;
1108        let block_id = block_id as u64;
1109        let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
1110            .ok_or_else(|| DocumentError::NotFound("block not found".into()))?;
1111        Ok(BlockFormat::from(&block_dto))
1112    }
1113
1114    // ── Flow traversal (layout engine API) ─────────────────
1115
1116    /// Walk the main frame's visual flow in document order.
1117    ///
1118    /// Returns the top-level flow elements — blocks, tables, and
1119    /// sub-frames — in the order defined by the main frame's
1120    /// `child_order`. Table cell contents are NOT included here;
1121    /// access them through [`TextTableCell::blocks()`](crate::TextTableCell::blocks).
1122    ///
1123    /// This is the primary entry point for layout initialization.
1124    pub fn flow(&self) -> Vec<crate::flow::FlowElement> {
1125        let inner = self.inner.lock();
1126        let main_frame_id = get_main_frame_id(&inner);
1127        crate::text_frame::build_flow_elements(&inner, &self.inner, main_frame_id)
1128    }
1129
1130    /// Get a read-only handle to a block by its entity ID.
1131    ///
1132    /// Entity IDs are stable across insertions and deletions.
1133    /// Returns `None` if no block with this ID exists.
1134    pub fn block_by_id(&self, block_id: usize) -> Option<crate::text_block::TextBlock> {
1135        let inner = self.inner.lock();
1136        let exists = frontend::commands::block_commands::get_block(&inner.ctx, &(block_id as u64))
1137            .ok()
1138            .flatten()
1139            .is_some();
1140
1141        if exists {
1142            Some(crate::text_block::TextBlock {
1143                doc: self.inner.clone(),
1144                block_id,
1145            })
1146        } else {
1147            None
1148        }
1149    }
1150
1151    /// Build a single `BlockSnapshot` for the block at the given position.
1152    ///
1153    /// This is O(k) where k = format runs + image anchors in that block,
1154    /// compared to `snapshot_flow()` which is O(n) over the entire document.
1155    /// Use for incremental layout updates after single-block edits.
1156    pub fn snapshot_block_at_position(
1157        &self,
1158        position: usize,
1159    ) -> Option<crate::flow::BlockSnapshot> {
1160        self.snapshot_block_at_position_masked(position, &crate::highlight::HighlightMask::all())
1161    }
1162
1163    /// Like [`snapshot_block_at_position`](Self::snapshot_block_at_position)
1164    /// but with **no highlights applied** — base fragments and empty
1165    /// `paint_highlights`, regardless of the active sessions. Used by the
1166    /// incremental relayout path of a view that has opted out of highlights.
1167    pub fn snapshot_block_at_position_without_highlights(
1168        &self,
1169        position: usize,
1170    ) -> Option<crate::flow::BlockSnapshot> {
1171        self.snapshot_block_at_position_masked(position, &crate::highlight::HighlightMask::none())
1172    }
1173
1174    /// Like [`snapshot_block_at_position`](Self::snapshot_block_at_position) but rendering
1175    /// only the sessions `mask` admits — the per-view incremental path (two panes over one
1176    /// document can carry different find sessions). `all()` = the plain method; `none()` = the
1177    /// without-highlights method.
1178    pub fn snapshot_block_at_position_masked(
1179        &self,
1180        position: usize,
1181        mask: &crate::highlight::HighlightMask,
1182    ) -> Option<crate::flow::BlockSnapshot> {
1183        let inner = self.inner.lock();
1184        // Effective kind resolved once here (the join over the mask's sessions), then threaded
1185        // down with the mask itself.
1186        let hl = crate::highlight::SnapshotHighlights {
1187            kind: inner.highlights.effective_kind(mask),
1188            mask,
1189            suppress_paint: false,
1190        };
1191        let main_frame_id = get_main_frame_id(&inner);
1192        let store = inner.ctx.db_context.get_store();
1193
1194        // Rope-authoritative fast path. When every block is mirrored to the
1195        // rope (now true with tables — see `rope_positions_match_flow`), the
1196        // rope IS the position space the snapshot reports in, so we must also
1197        // *locate* the block via the rope. Walking a hand-rolled `running_pos`
1198        // here instead would search in the old cells-inline-no-sentinel space
1199        // and then report the rope position — an off-by-the-sentinel mismatch
1200        // for any block after a table.
1201        if common::database::rope_helpers::rope_positions_match_flow(store)
1202            && let Some((block_id, _, _)) =
1203                common::database::rope_helpers::find_block_at_char_position(store, position as i64)
1204        {
1205            return crate::text_block::build_block_snapshot(&inner, block_id, hl);
1206        }
1207
1208        // Collect all block IDs in document order, traversing into nested frames
1209        let ordered_block_ids = collect_frame_block_ids(&inner, main_frame_id)?;
1210
1211        // Walk blocks computing positions on the fly
1212        let pos = position as i64;
1213        let mut running_pos: i64 = 0;
1214        for &block_id in &ordered_block_ids {
1215            let block_dto = block_commands::get_block(&inner.ctx, &block_id)
1216                .ok()
1217                .flatten()?;
1218            let entity: common::entities::Block = block_dto.clone().into();
1219            let block_end =
1220                running_pos + common::database::rope_helpers::block_char_length(&entity, store);
1221            if pos >= running_pos && pos <= block_end {
1222                return crate::text_block::build_block_snapshot_with_position(
1223                    &inner,
1224                    block_id,
1225                    Some(running_pos as usize),
1226                    hl,
1227                );
1228            }
1229            running_pos = block_end + 1;
1230        }
1231
1232        // Fallback to last block
1233        if let Some(&last_id) = ordered_block_ids.last() {
1234            return crate::text_block::build_block_snapshot(&inner, last_id, hl);
1235        }
1236        None
1237    }
1238
1239    /// Get a read-only handle to the block containing the given
1240    /// character position. Returns `None` if position is out of range.
1241    pub fn block_at_position(&self, position: usize) -> Option<crate::text_block::TextBlock> {
1242        let inner = self.inner.lock();
1243        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1244            position: to_i64(position),
1245        };
1246        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1247        Some(crate::text_block::TextBlock {
1248            doc: self.inner.clone(),
1249            block_id: result.block_id as usize,
1250        })
1251    }
1252
1253    /// Get a read-only handle to a block by its 0-indexed global
1254    /// block number.
1255    ///
1256    /// **O(n)**: requires scanning all blocks sorted by
1257    /// `document_position` to find the nth one. Prefer
1258    /// [`block_at_position()`](TextDocument::block_at_position) or
1259    /// [`block_by_id()`](TextDocument::block_by_id) in
1260    /// performance-sensitive paths.
1261    pub fn block_by_number(&self, block_number: usize) -> Option<crate::text_block::TextBlock> {
1262        let inner = self.inner.lock();
1263        let all_blocks = frontend::commands::block_commands::get_all_block(&inner.ctx).ok()?;
1264        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1265        let store = inner.ctx.db_context.get_store();
1266        crate::inner::refresh_block_positions(&mut sorted, store);
1267        sorted.sort_by_key(|b| b.document_position);
1268
1269        sorted
1270            .get(block_number)
1271            .map(|b| crate::text_block::TextBlock {
1272                doc: self.inner.clone(),
1273                block_id: b.id as usize,
1274            })
1275    }
1276
1277    /// All blocks in the document, sorted by `document_position`. **O(n)**.
1278    ///
1279    /// Returns blocks from all frames, including those inside table cells.
1280    /// This is the efficient way to iterate all blocks — avoids the O(n^2)
1281    /// cost of calling `block_by_number(i)` in a loop.
1282    pub fn blocks(&self) -> Vec<crate::text_block::TextBlock> {
1283        let inner = self.inner.lock();
1284        let all_blocks =
1285            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1286        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1287        let store = inner.ctx.db_context.get_store();
1288        crate::inner::refresh_block_positions(&mut sorted, store);
1289        sorted.sort_by_key(|b| b.document_position);
1290        sorted
1291            .iter()
1292            .map(|b| crate::text_block::TextBlock {
1293                doc: self.inner.clone(),
1294                block_id: b.id as usize,
1295            })
1296            .collect()
1297    }
1298
1299    /// All blocks whose character range intersects `[position, position + length)`.
1300    ///
1301    /// **O(n)**: scans all blocks once. Returns them sorted by `document_position`.
1302    /// A block intersects if its range `[block.position, block.position + block.length)`
1303    /// overlaps the query range. An empty query range (`length == 0`) returns the
1304    /// block containing that position, if any.
1305    pub fn blocks_in_range(
1306        &self,
1307        position: usize,
1308        length: usize,
1309    ) -> Vec<crate::text_block::TextBlock> {
1310        let inner = self.inner.lock();
1311        let all_blocks =
1312            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1313        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1314        let store = inner.ctx.db_context.get_store();
1315        crate::inner::refresh_block_positions(&mut sorted, store);
1316        sorted.sort_by_key(|b| b.document_position);
1317
1318        let range_start = position;
1319        let range_end = position + length;
1320        sorted
1321            .iter()
1322            .filter(|b| {
1323                let block_start = b.document_position.max(0) as usize;
1324                let entity: common::entities::Block = (*b).clone().into();
1325                let block_end = block_start
1326                    + common::database::rope_helpers::block_char_length(&entity, store).max(0)
1327                        as usize;
1328                // Overlap check: block intersects [range_start, range_end)
1329                if length == 0 {
1330                    // Point query: block contains the position
1331                    range_start >= block_start && range_start < block_end
1332                } else {
1333                    block_start < range_end && block_end > range_start
1334                }
1335            })
1336            .map(|b| crate::text_block::TextBlock {
1337                doc: self.inner.clone(),
1338                block_id: b.id as usize,
1339            })
1340            .collect()
1341    }
1342
1343    /// Snapshot the entire main flow in a single lock acquisition.
1344    ///
1345    /// Returns a [`FlowSnapshot`](crate::FlowSnapshot) containing snapshots
1346    /// for every element in the flow.
1347    pub fn snapshot_flow(&self) -> crate::flow::FlowSnapshot {
1348        self.snapshot_flow_masked(&crate::highlight::HighlightMask::all())
1349    }
1350
1351    /// Snapshot the entire main flow with **no highlights applied** — base
1352    /// fragments and empty `paint_highlights` on every block, regardless of
1353    /// the active sessions.
1354    ///
1355    /// This is the per-view opt-out: a read-only viewer that should stay
1356    /// free of search / spell / syntax highlighting pulls *this* snapshot
1357    /// instead of [`snapshot_flow`](Self::snapshot_flow). Because suppression
1358    /// happens at build time, it works for metric-affecting sessions too
1359    /// (whose highlights are otherwise merged into `fragments` irreversibly).
1360    pub fn snapshot_flow_without_highlights(&self) -> crate::flow::FlowSnapshot {
1361        self.snapshot_flow_masked(&crate::highlight::HighlightMask::none())
1362    }
1363
1364    /// Snapshot the entire main flow rendering only the sessions `mask` admits.
1365    ///
1366    /// The generalization of the plain / without-highlights pair: `all()` shows every session,
1367    /// `none()` shows none, and `only([...])` shows a chosen set — which is how two panes over
1368    /// one shared document carry different find sessions. The effective
1369    /// `HighlighterKind` is resolved **once here**, at the snapshot root,
1370    /// and threaded down, so a view showing only paint-only sessions never pays the reshape
1371    /// path for a metric session it does not show.
1372    pub fn snapshot_flow_masked(
1373        &self,
1374        mask: &crate::highlight::HighlightMask,
1375    ) -> crate::flow::FlowSnapshot {
1376        let inner = self.inner.lock();
1377        let main_frame_id = get_main_frame_id(&inner);
1378        let hl = crate::highlight::SnapshotHighlights {
1379            kind: inner.highlights.effective_kind(mask),
1380            mask,
1381            suppress_paint: false,
1382        };
1383        let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id, hl);
1384        crate::flow::FlowSnapshot { elements }
1385    }
1386
1387    /// Snapshot the main flow like [`snapshot_flow_masked`](Self::snapshot_flow_masked),
1388    /// but **without computing the paint-only overlay** (`paint_highlights` is
1389    /// empty on every block). Fragments are identical — metric sessions still
1390    /// split them — so a consumer that reads only the fragments and their
1391    /// geometry gets the exact same tree, minus the `extract_paint_spans` work.
1392    ///
1393    /// This is the accessibility path's snapshot: the AT tree reads fragments,
1394    /// never the paint overlay, so paying to compute a per-block paint span for
1395    /// each of a spell-checker's tens of thousands of ranges is pure waste (it
1396    /// dominated the a11y rebuild on a large mis-dictionaried document). Render
1397    /// and layout keep using [`snapshot_flow_masked`](Self::snapshot_flow_masked),
1398    /// which they must — they draw the overlay.
1399    pub fn snapshot_flow_masked_no_paint(
1400        &self,
1401        mask: &crate::highlight::HighlightMask,
1402    ) -> crate::flow::FlowSnapshot {
1403        let inner = self.inner.lock();
1404        let main_frame_id = get_main_frame_id(&inner);
1405        let hl = crate::highlight::SnapshotHighlights {
1406            kind: inner.highlights.effective_kind(mask),
1407            mask,
1408            suppress_paint: true,
1409        };
1410        let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id, hl);
1411        crate::flow::FlowSnapshot { elements }
1412    }
1413
1414    // ── Search ───────────────────────────────────────────────
1415
1416    /// Find the next (or previous) occurrence. Returns `None` if not found.
1417    pub fn find(
1418        &self,
1419        query: &str,
1420        from: usize,
1421        options: &FindOptions,
1422    ) -> Result<Option<FindMatch>> {
1423        let inner = self.inner.lock();
1424        let dto = options.to_find_text_dto(query, from);
1425        let result = document_search_commands::find_text(&inner.ctx, &dto)?;
1426        Ok(convert::find_result_to_match(&result))
1427    }
1428
1429    /// Find all occurrences.
1430    pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
1431        let inner = self.inner.lock();
1432        let dto = options.to_find_all_dto(query);
1433        let result = document_search_commands::find_all(&inner.ctx, &dto)?;
1434        Ok(convert::find_all_to_matches(&result))
1435    }
1436
1437    /// Replace occurrences. Returns the number of replacements. Undoable.
1438    ///
1439    /// `options` carries both how to find the text and — via
1440    /// [`crate::ReplaceOptions::format_policy`] — what the replacement wears where it
1441    /// overwrites formatted prose. The default drops the formatting under the replaced
1442    /// range, which is fine for plain text and destructive for a rename that lands on a
1443    /// partly-bold name; pass a different policy when that matters.
1444    pub fn replace_text(
1445        &self,
1446        query: &str,
1447        replacement: &str,
1448        replace_all: bool,
1449        options: &crate::ReplaceOptions,
1450    ) -> Result<usize> {
1451        let (count, queued) = {
1452            let mut inner = self.inner.lock();
1453            let dto = options.to_replace_dto(query, replacement, replace_all);
1454            let result =
1455                document_search_commands::replace_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1456            let count = to_usize(result.replacements_count);
1457            inner.invalidate_text_cache();
1458            if count > 0 {
1459                inner.modified = true;
1460                inner.rehighlight_all();
1461                // Replacements are scattered across the document — we can't
1462                // provide a single position/chars delta. Signal "content changed
1463                // from position 0, affecting `count` sites" so the consumer
1464                // knows to re-read.
1465                inner.queue_event(DocumentEvent::ContentsChanged {
1466                    position: 0,
1467                    chars_removed: 0,
1468                    chars_added: 0,
1469                    blocks_affected: count,
1470                });
1471                inner.check_block_count_changed();
1472                inner.check_flow_changed();
1473                let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1474                let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1475                inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1476            }
1477            (count, inner.take_queued_events())
1478        };
1479        crate::inner::dispatch_queued_events(queued);
1480        Ok(count)
1481    }
1482
1483    /// Replace an explicit set of ranges, each with **its own** replacement text. Undoable
1484    /// as one action, however many ranges it touches.
1485    ///
1486    /// [`replace_text`](Self::replace_text) can only put the same string at every match.
1487    /// This is for the case where the caller decides *per occurrence* — a reviewed bulk
1488    /// rename where some occurrences are unticked, or one that preserves the case it found
1489    /// (`AURÉLIEN` → `AURÉLIAN`, not `aurélian`).
1490    ///
1491    /// ⚠ **Do not build the ranges with a separate `find_all` call.** The document can move
1492    /// between the two, and the ranges then address text that is no longer there — which
1493    /// does not fail, it rewrites *the wrong words*. Use
1494    /// [`find_and_replace`](Self::find_and_replace), which does both under one lock.
1495    ///
1496    /// Ranges that straddle a block boundary, or that overlap one another, are **skipped**;
1497    /// the returned count reflects only what was actually applied.
1498    pub fn replace_ranges(
1499        &self,
1500        ranges: &[ReplaceRange],
1501        options: &crate::ReplaceOptions,
1502    ) -> Result<usize> {
1503        let (count, queued) = {
1504            let mut inner = self.inner.lock();
1505            let count = Self::replace_ranges_locked(&mut inner, ranges, options)?;
1506            (count, inner.take_queued_events())
1507        };
1508        crate::inner::dispatch_queued_events(queued);
1509        Ok(count)
1510    }
1511
1512    /// Find every match of `query` and let `decide` choose what each becomes — **atomically**.
1513    ///
1514    /// `decide` is handed the matched text and the index of the match, and returns the
1515    /// replacement, or `None` to leave that occurrence alone. So a rename that preserves case
1516    /// and skips the occurrences a writer unticked is one call:
1517    ///
1518    /// ```no_run
1519    /// # use text_document::{TextDocument, FindOptions, ReplaceOptions};
1520    /// # let doc = TextDocument::new();
1521    /// # let excluded: Vec<usize> = vec![];
1522    /// doc.find_and_replace("Aurélien", &ReplaceOptions::new(FindOptions::default()), |matched, i| {
1523    ///     if excluded.contains(&i) {
1524    ///         return None; // the writer unticked this one
1525    ///     }
1526    ///     Some(if matched.chars().all(char::is_uppercase) { "AURÉLIAN".into() } else { "Aurélian".into() })
1527    /// })?;
1528    /// # Ok::<(), text_document::DocumentError>(())
1529    /// ```
1530    ///
1531    /// **The scan and the splice happen under one lock**, which is the whole point. Calling
1532    /// `find_all` and then `replace_ranges` would drop the lock in between, and the document
1533    /// can be edited there — after which every range addresses text that has moved. That does
1534    /// not raise an error; it silently rewrites the wrong words. The document mutex is not
1535    /// reentrant, so composing the two public methods cannot close the gap; only doing both
1536    /// inside one can.
1537    pub fn find_and_replace(
1538        &self,
1539        query: &str,
1540        options: &crate::ReplaceOptions,
1541        mut decide: impl FnMut(&str, usize) -> Option<String>,
1542    ) -> Result<usize> {
1543        let (count, queued) = {
1544            let mut inner = self.inner.lock();
1545
1546            // Scan. The matched TEXT comes back with the offsets, sliced by the use case from
1547            // the very text it searched — deliberately, so this never has to slice a
1548            // whole-document string of its own. The only one reachable here is
1549            // `to_plain_text`, which is the human-readable view and carries no `U+FFFC` anchor
1550            // for an embedded table; slicing it with these offsets would be wrong by two
1551            // characters per preceding table, and the rename would rewrite the wrong words.
1552            let found = {
1553                let dto = options.find.to_find_all_dto(query);
1554                document_search_commands::find_all(&inner.ctx, &dto)?
1555            };
1556
1557            // …decide, against the document as it is RIGHT NOW…
1558            let mut ranges: Vec<ReplaceRange> = Vec::new();
1559            for (i, ((&position, &length), matched)) in found
1560                .positions
1561                .iter()
1562                .zip(found.lengths.iter())
1563                .zip(found.matched_texts.iter())
1564                .enumerate()
1565            {
1566                if let Some(replacement) = decide(matched, i) {
1567                    ranges.push(ReplaceRange {
1568                        position: to_usize(position),
1569                        length: to_usize(length),
1570                        replacement,
1571                    });
1572                }
1573            }
1574
1575            // …and splice — all without ever letting go of the lock.
1576            let count = if ranges.is_empty() {
1577                0
1578            } else {
1579                Self::replace_ranges_locked(&mut inner, &ranges, options)?
1580            };
1581            (count, inner.take_queued_events())
1582        };
1583        crate::inner::dispatch_queued_events(queued);
1584        Ok(count)
1585    }
1586
1587    /// The splice, with the lock already held. Shared by [`Self::replace_ranges`] and
1588    /// [`Self::find_and_replace`] so the second cannot drift from the first.
1589    fn replace_ranges_locked(
1590        inner: &mut crate::inner::TextDocumentInner,
1591        ranges: &[ReplaceRange],
1592        options: &crate::ReplaceOptions,
1593    ) -> Result<usize> {
1594        let dto = options.to_replace_ranges_dto(ranges);
1595        let result =
1596            document_search_commands::replace_ranges(&inner.ctx, Some(inner.stack_id), &dto)?;
1597        let count = to_usize(result.replacements_count);
1598
1599        inner.invalidate_text_cache();
1600        if count > 0 {
1601            inner.modified = true;
1602            inner.rehighlight_all();
1603            inner.queue_event(DocumentEvent::ContentsChanged {
1604                position: 0,
1605                chars_removed: 0,
1606                chars_added: 0,
1607                blocks_affected: count,
1608            });
1609            inner.check_block_count_changed();
1610            inner.check_flow_changed();
1611            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1612            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1613            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1614        }
1615        Ok(count)
1616    }
1617
1618    // ── Resources ────────────────────────────────────────────
1619
1620    /// Add a resource (image, stylesheet) to the document.
1621    pub fn add_resource(
1622        &self,
1623        resource_type: ResourceType,
1624        name: &str,
1625        mime_type: &str,
1626        data: &[u8],
1627    ) -> Result<()> {
1628        let mut inner = self.inner.lock();
1629        let dto = frontend::resource::dtos::CreateResourceDto {
1630            created_at: Default::default(),
1631            updated_at: Default::default(),
1632            resource_type,
1633            name: name.into(),
1634            url: String::new(),
1635            mime_type: mime_type.into(),
1636            data_base64: BASE64.encode(data),
1637        };
1638        let created = resource_commands::create_resource(
1639            &inner.ctx,
1640            Some(inner.stack_id),
1641            &dto,
1642            inner.document_id,
1643            -1,
1644        )?;
1645        inner.resource_cache.insert(name.to_string(), created.id);
1646        Ok(())
1647    }
1648
1649    /// Get a resource by name. Returns `None` if not found.
1650    ///
1651    /// Uses an internal cache to avoid scanning all resources on repeated lookups.
1652    pub fn resource(&self, name: &str) -> Result<Option<Vec<u8>>> {
1653        let mut inner = self.inner.lock();
1654
1655        // Fast path: check the name → ID cache.
1656        if let Some(&id) = inner.resource_cache.get(name) {
1657            if let Some(r) = resource_commands::get_resource(&inner.ctx, &id)? {
1658                let bytes = BASE64
1659                    .decode(&r.data_base64)
1660                    .map_err(|e| DocumentError::Internal(e.into()))?;
1661                return Ok(Some(bytes));
1662            }
1663            // ID was stale — fall through to full scan.
1664            inner.resource_cache.remove(name);
1665        }
1666
1667        // Slow path: linear scan, then populate cache for the match.
1668        let all = resource_commands::get_all_resource(&inner.ctx)?;
1669        for r in &all {
1670            if r.name == name {
1671                inner.resource_cache.insert(name.to_string(), r.id);
1672                let bytes = BASE64
1673                    .decode(&r.data_base64)
1674                    .map_err(|e| DocumentError::Internal(e.into()))?;
1675                return Ok(Some(bytes));
1676            }
1677        }
1678        Ok(None)
1679    }
1680
1681    // ── Undo / Redo ──────────────────────────────────────────
1682
1683    /// Undo the last operation.
1684    pub fn undo(&self) -> Result<()> {
1685        let queued = {
1686            let mut inner = self.inner.lock();
1687            let before = capture_block_state(&inner);
1688            let stepped = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1689            let result = undo_redo_commands::undo(&inner.ctx, Some(inner.stack_id));
1690            inner.invalidate_text_cache();
1691            // An undo that actually popped a command changed the buffer, so the
1692            // document is dirty again. Nothing else sets `modified` here: every
1693            // other setter is an *edit* in `cursor.rs`/`streaming.rs`. Without
1694            // this, an embedder that gates its write-back on `is_modified()`
1695            // silently discards the undo — the text reverts on screen while the
1696            // persisted copy keeps the pre-undo version, and the next reload
1697            // brings the stale text back. (Skribisto's `ProseField::flush` did
1698            // exactly that.)
1699            //
1700            // `can_undo` is sampled *before* the call because `undo()` on an
1701            // empty stack is a successful no-op, and marking a clean document
1702            // dirty for a keystroke that did nothing would be its own bug.
1703            //
1704            // Set *before* `result?`, and deliberately. A composite entry undoes
1705            // its parts in reverse and gives up on the first failure, so a
1706            // failed undo can still have reverted some of them — the buffer has
1707            // moved, which is why `invalidate_text_cache` above is also
1708            // unconditional. Marking a document dirty that turns out not to need
1709            // saving costs one redundant write; the other way round loses the
1710            // writer's text.
1711            if stepped {
1712                inner.modified = true;
1713            }
1714            result?;
1715            inner.rehighlight_all();
1716            emit_content_change_events(&mut inner, &before);
1717            inner.check_block_count_changed();
1718            inner.check_flow_changed();
1719            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1720            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1721            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1722            inner.take_queued_events()
1723        };
1724        crate::inner::dispatch_queued_events(queued);
1725        Ok(())
1726    }
1727
1728    /// Redo the last undone operation.
1729    pub fn redo(&self) -> Result<()> {
1730        let queued = {
1731            let mut inner = self.inner.lock();
1732            let before = capture_block_state(&inner);
1733            let stepped = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1734            let result = undo_redo_commands::redo(&inner.ctx, Some(inner.stack_id));
1735            inner.invalidate_text_cache();
1736            // A redo re-applies an edit the writer took back, which is a change
1737            // to the buffer like any other: same reasoning as `undo` above, and
1738            // the same placement before `result?` for the same reason. Here the
1739            // predicate is `can_redo`, sampled before the call because `redo()`
1740            // on an empty redo branch is a successful no-op.
1741            if stepped {
1742                inner.modified = true;
1743            }
1744            result?;
1745            inner.rehighlight_all();
1746            emit_content_change_events(&mut inner, &before);
1747            inner.check_block_count_changed();
1748            inner.check_flow_changed();
1749            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1750            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1751            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1752            inner.take_queued_events()
1753        };
1754        crate::inner::dispatch_queued_events(queued);
1755        Ok(())
1756    }
1757
1758    /// Close the current undo entry, so the next edit starts a new one.
1759    ///
1760    /// Typing is coalesced — contiguous inserts within a couple of seconds
1761    /// become one undo step, which is what makes Ctrl+Z take back a word rather
1762    /// than a letter. The rule looks only at the *shape* of two edits and cannot
1763    /// see that something happened between them: an embedder whose user typed,
1764    /// renamed a chapter somewhere else, then typed again gets one entry
1765    /// spanning both bursts, and undoing it takes back text entered before an
1766    /// event the user remembers as a dividing line.
1767    ///
1768    /// The embedder is the only one who knows such a line was crossed. This is
1769    /// how it says so. Idempotent, and harmless on an empty history.
1770    pub fn break_undo_merge(&self) {
1771        let inner = self.inner.lock();
1772        undo_redo_commands::seal_head(&inner.ctx, Some(inner.stack_id));
1773    }
1774
1775    /// Bound how many undo entries this document keeps, dropping the oldest
1776    /// past the limit. `None` — the default — keeps everything.
1777    ///
1778    /// Typing history is unbounded by construction: every keystroke that does
1779    /// not coalesce into the entry below it is another entry, and each holds a
1780    /// snapshot of what it changed. Over a day-long drafting session on one
1781    /// document that is a ceiling nobody set. An embedder that cares about the
1782    /// ceiling needs a way to say so, and this is it — the far end of a long
1783    /// history is the part nobody reaches for.
1784    ///
1785    /// The limit belongs to the document, not to one edit: lowering it trims
1786    /// on the next push rather than immediately, so an entry the writer can
1787    /// still see in a menu does not vanish under them.
1788    pub fn set_undo_limit(&self, limit: Option<usize>) {
1789        let inner = self.inner.lock();
1790        undo_redo_commands::set_undo_limit(&inner.ctx, limit);
1791    }
1792
1793    /// The current entry limit, if one is set.
1794    pub fn undo_limit(&self) -> Option<usize> {
1795        let inner = self.inner.lock();
1796        undo_redo_commands::undo_limit(&inner.ctx)
1797    }
1798
1799    /// Returns true if there are operations that can be undone.
1800    pub fn can_undo(&self) -> bool {
1801        let inner = self.inner.lock();
1802        undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id))
1803    }
1804
1805    /// Returns true if there are operations that can be redone.
1806    pub fn can_redo(&self) -> bool {
1807        let inner = self.inner.lock();
1808        undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id))
1809    }
1810
1811    /// Clear all undo/redo history.
1812    pub fn clear_undo_redo(&self) {
1813        let inner = self.inner.lock();
1814        undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
1815    }
1816
1817    // ── Modified state ───────────────────────────────────────
1818
1819    /// Returns true if the document has been modified since creation or last reset.
1820    pub fn is_modified(&self) -> bool {
1821        self.inner.lock().modified
1822    }
1823
1824    /// Set or clear the modified flag.
1825    pub fn set_modified(&self, modified: bool) {
1826        let queued = {
1827            let mut inner = self.inner.lock();
1828            if inner.modified != modified {
1829                inner.modified = modified;
1830                inner.queue_event(DocumentEvent::ModificationChanged(modified));
1831            }
1832            inner.take_queued_events()
1833        };
1834        crate::inner::dispatch_queued_events(queued);
1835    }
1836
1837    /// A monotonic counter, bumped once per [`DocumentEvent::ContentsChanged`]
1838    /// queued so far. Starts at `0`.
1839    ///
1840    /// Lets a caller answer "was this notification caused by exactly the
1841    /// most recent edit, with nothing else having happened since" precisely
1842    /// — snapshot the value when acting on a notification, and compare it
1843    /// against the current value later. This is deliberately *not* the same
1844    /// as [`is_modified`](Self::is_modified) (a flag, not a count) or the
1845    /// undo stack's depth (which does not grow when consecutive compatible
1846    /// edits merge into one entry — e.g. fast consecutive typing).
1847    pub fn content_revision(&self) -> u64 {
1848        self.inner.lock().content_revision
1849    }
1850
1851    // ── Document properties ──────────────────────────────────
1852
1853    /// Get the document title.
1854    pub fn title(&self) -> String {
1855        let inner = self.inner.lock();
1856        document_commands::get_document(&inner.ctx, &inner.document_id)
1857            .ok()
1858            .flatten()
1859            .map(|d| d.title)
1860            .unwrap_or_default()
1861    }
1862
1863    /// Set the document title.
1864    pub fn set_title(&self, title: &str) -> Result<()> {
1865        let inner = self.inner.lock();
1866        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1867            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1868        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1869        update.title = title.into();
1870        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1871        Ok(())
1872    }
1873
1874    /// Get the text direction.
1875    pub fn text_direction(&self) -> TextDirection {
1876        let inner = self.inner.lock();
1877        document_commands::get_document(&inner.ctx, &inner.document_id)
1878            .ok()
1879            .flatten()
1880            .map(|d| d.text_direction)
1881            .unwrap_or(TextDirection::LeftToRight)
1882    }
1883
1884    /// Set the text direction.
1885    pub fn set_text_direction(&self, direction: TextDirection) -> Result<()> {
1886        let inner = self.inner.lock();
1887        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1888            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1889        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1890        update.text_direction = direction;
1891        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1892        Ok(())
1893    }
1894
1895    /// Get the default wrap mode.
1896    pub fn default_wrap_mode(&self) -> WrapMode {
1897        let inner = self.inner.lock();
1898        document_commands::get_document(&inner.ctx, &inner.document_id)
1899            .ok()
1900            .flatten()
1901            .map(|d| d.default_wrap_mode)
1902            .unwrap_or(WrapMode::WordWrap)
1903    }
1904
1905    /// Set the default wrap mode.
1906    pub fn set_default_wrap_mode(&self, mode: WrapMode) -> Result<()> {
1907        let inner = self.inner.lock();
1908        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1909            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1910        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1911        update.default_wrap_mode = mode;
1912        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1913        Ok(())
1914    }
1915
1916    /// Get the document-wide default language (ISO 639-1 code, e.g. "en").
1917    /// This is the fallback hyphenation language for blocks that don't set
1918    /// their own `language`. Defaults to `"en"` when never set.
1919    pub fn default_language(&self) -> String {
1920        let inner = self.inner.lock();
1921        document_commands::get_document(&inner.ctx, &inner.document_id)
1922            .ok()
1923            .flatten()
1924            .and_then(|d| d.default_language)
1925            .unwrap_or_else(|| "en".to_string())
1926    }
1927
1928    /// Set the document-wide default language (ISO 639-1 code). Blocks
1929    /// without an explicit `language` inherit this for hyphenation.
1930    pub fn set_default_language(&self, language: &str) -> Result<()> {
1931        let inner = self.inner.lock();
1932        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1933            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1934        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1935        update.default_language = Some(language.to_string());
1936        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1937        Ok(())
1938    }
1939
1940    // ── Event subscription ───────────────────────────────────
1941
1942    /// Subscribe to document events via callback.
1943    ///
1944    /// Callbacks are invoked **outside** the document lock (after the editing
1945    /// operation completes and the lock is released). It is safe to call
1946    /// `TextDocument` or `TextCursor` methods from within the callback without
1947    /// risk of deadlock. However, keep callbacks lightweight — they run
1948    /// synchronously on the calling thread and block the caller until they
1949    /// return.
1950    ///
1951    /// Drop the returned [`Subscription`] to unsubscribe.
1952    ///
1953    /// # Breaking change (v0.0.6)
1954    ///
1955    /// The callback bound changed from `Send` to `Send + Sync` in v0.0.6
1956    /// to support `Arc`-based dispatch. Callbacks that capture non-`Sync`
1957    /// types (e.g., `Rc<T>`, `Cell<T>`) must be wrapped in a `Mutex`.
1958    pub fn on_change<F>(&self, callback: F) -> Subscription
1959    where
1960        F: Fn(DocumentEvent) + Send + Sync + 'static,
1961    {
1962        let mut inner = self.inner.lock();
1963        events::subscribe_inner(&mut inner, callback)
1964    }
1965
1966    /// Return events accumulated since the last `poll_events()` call.
1967    ///
1968    /// This delivery path is independent of callback dispatch via
1969    /// [`on_change`](Self::on_change) — using both simultaneously is safe
1970    /// and each path sees every event exactly once.
1971    pub fn poll_events(&self) -> Vec<DocumentEvent> {
1972        let mut inner = self.inner.lock();
1973        inner.drain_poll_events()
1974    }
1975
1976    // ── Syntax highlighting ──────────────────────────────────
1977
1978    /// Attach a single syntax highlighter to this document — the classic, one-highlighter
1979    /// entry point.
1980    ///
1981    /// Immediately re-highlights the entire document. **Replaces** the one highlighter this
1982    /// method manages, and *only* that one: a spell-checker or find layer registered
1983    /// independently via [`add_syntax_session`](Self::add_syntax_session) /
1984    /// [`add_range_session`](Self::add_range_session) is left untouched. Pass `None` to remove
1985    /// it.
1986    ///
1987    /// This is a convenience over the session registry — it owns exactly one "shim" session. A
1988    /// host that wants to manage several layers uses the session methods directly.
1989    pub fn set_syntax_highlighter(&self, highlighter: Option<Arc<dyn crate::SyntaxHighlighter>>) {
1990        let queued = {
1991            let mut inner = self.inner.lock();
1992            let prev_kind = inner.highlight_kind;
1993            let installed = highlighter.is_some();
1994            inner.highlights.set_shim(highlighter);
1995            if installed {
1996                inner.rehighlight_all(); // recomputes highlight_kind
1997            } else {
1998                inner.recompute_highlight_kind();
1999            }
2000            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
2001            inner.take_queued_events()
2002        };
2003        crate::inner::dispatch_queued_events(queued);
2004    }
2005
2006    /// Register a **syntax session** — a [`SyntaxHighlighter`](crate::SyntaxHighlighter)
2007    /// callback with its own per-block state cascade — and return its [`crate::SessionId`].
2008    ///
2009    /// Unlike [`set_syntax_highlighter`](Self::set_syntax_highlighter), this **adds** rather
2010    /// than replaces: a document can carry a syntax highlighter and a spell-checker at once,
2011    /// each a session, merged in `(priority, registration)` order (a later session's field
2012    /// wins). Sessions remain visible only in views whose
2013    /// [`HighlightMask`](crate::highlight::HighlightMask) admits them.
2014    pub fn add_syntax_session(
2015        &self,
2016        highlighter: Arc<dyn crate::SyntaxHighlighter>,
2017    ) -> crate::highlight::SessionId {
2018        self.add_syntax_session_with_priority(highlighter, 0)
2019    }
2020
2021    /// [`add_syntax_session`](Self::add_syntax_session) at an explicit merge priority — see
2022    /// [`add_range_session_with_priority`](Self::add_range_session_with_priority).
2023    pub fn add_syntax_session_with_priority(
2024        &self,
2025        highlighter: Arc<dyn crate::SyntaxHighlighter>,
2026        priority: i32,
2027    ) -> crate::highlight::SessionId {
2028        let (id, queued) = {
2029            let mut inner = self.inner.lock();
2030            let prev_kind = inner.highlight_kind;
2031            let id = inner.highlights.add_syntax(highlighter, priority);
2032            inner.rehighlight_all();
2033            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
2034            (id, inner.take_queued_events())
2035        };
2036        crate::inner::dispatch_queued_events(queued);
2037        id
2038    }
2039
2040    /// Register an empty **range session** — absolute-offset ranges set with
2041    /// [`set_session_ranges`](Self::set_session_ranges), the shape used for search and (later)
2042    /// an externally-driven spell-checker. Returns its [`crate::SessionId`].
2043    ///
2044    /// A view's own find session is a range session it alone admits; that is how two panes
2045    /// over one document highlight different queries.
2046    ///
2047    /// **Shared**: every view renders it unless its mask says otherwise. For a layer that
2048    /// belongs to one view rather than to the text, see
2049    /// [`add_opt_in_range_session`](Self::add_opt_in_range_session).
2050    pub fn add_range_session(&self) -> crate::highlight::SessionId {
2051        self.add_range_session_with_priority(0)
2052    }
2053
2054    /// Register an empty range session that **no view renders until it asks for it** by name
2055    /// (`HighlightMask::all().with(id)`).
2056    ///
2057    /// The session lives on the document like any other (a range session has nowhere else to
2058    /// live), but it is a fact about one *view*, not about the text, so a second view of the
2059    /// same document must be left alone. Reach for this whenever the answer to "should the
2060    /// pane next door draw this too?" is no: a reading that marks every mention of one
2061    /// character marks it in the reading, not in every editor that happens to hold the same
2062    /// scene.
2063    ///
2064    /// The alternative, [`HighlightMask::only`](crate::highlight::HighlightMask::only) on
2065    /// every *other* view, cannot be written: a view would have to name every session it
2066    /// does want, including ones it holds no handle on, and would silently drop the next
2067    /// layer anyone adds.
2068    pub fn add_opt_in_range_session(&self) -> crate::highlight::SessionId {
2069        self.add_opt_in_range_session_with_priority(0)
2070    }
2071
2072    /// Every [`OptIn`](crate::highlight::SessionVisibility::OptIn) session on this document,
2073    /// in merge order.
2074    ///
2075    /// Sessions can be added and retired but there was no way to ask what a document carries,
2076    /// which is the one question worth asking about a private layer: it is invisible to the
2077    /// plain snapshot by design, so "is it there, and is it marking the right characters" has
2078    /// no other answer. Shared sessions are deliberately not listed, since every view already
2079    /// draws those, so enumerating them answers nothing.
2080    ///
2081    /// Not a route to a view's mask: a view names the session it *owns*, and one built from
2082    /// this list would draw whatever the pane next door happens to have registered, which is
2083    /// exactly what [`add_opt_in_range_session`](Self::add_opt_in_range_session) exists to
2084    /// prevent.
2085    pub fn opt_in_session_ids(&self) -> Vec<crate::highlight::SessionId> {
2086        let inner = self.inner.lock();
2087        inner
2088            .highlights
2089            .sessions
2090            .iter()
2091            .filter(|s| s.visibility == crate::highlight::SessionVisibility::OptIn)
2092            .map(|s| s.id)
2093            .collect()
2094    }
2095
2096    /// [`add_opt_in_range_session`](Self::add_opt_in_range_session) at an explicit **merge
2097    /// priority**. See
2098    /// [`add_range_session_with_priority`](Self::add_range_session_with_priority).
2099    pub fn add_opt_in_range_session_with_priority(
2100        &self,
2101        priority: i32,
2102    ) -> crate::highlight::SessionId {
2103        let mut inner = self.inner.lock();
2104        inner
2105            .highlights
2106            .add_range(priority, crate::highlight::SessionVisibility::OptIn)
2107        // No repaint: an empty range session shows nothing until its ranges are set.
2108    }
2109
2110    /// [`add_range_session`](Self::add_range_session) at an explicit **merge priority**.
2111    ///
2112    /// Where two sessions format the same character, the higher priority wins field by field;
2113    /// equal priorities fall back to registration order, which is what every session gets by
2114    /// default (`0`).
2115    ///
2116    /// Reach for this when a layer must reliably lose — an ambient background band that every
2117    /// find match and spell squiggle should paint over. Registration order cannot express that:
2118    /// a per-view layer is registered when its view appears, so whether it lands before or
2119    /// after the find session depends on the order the user happened to open things in.
2120    pub fn add_range_session_with_priority(&self, priority: i32) -> crate::highlight::SessionId {
2121        let mut inner = self.inner.lock();
2122        inner
2123            .highlights
2124            .add_range(priority, crate::highlight::SessionVisibility::Shared)
2125        // No repaint: an empty range session shows nothing until its ranges are set.
2126    }
2127
2128    /// Replace the ranges of a range session (absolute char offsets, the space
2129    /// [`FindMatch`] reports in). Returns `false` if `id` is not a range
2130    /// session.
2131    ///
2132    /// Fires a highlight-changed event so live views showing this session re-snapshot — the
2133    /// only signal there is, since the ranges do not mutate the document.
2134    pub fn set_session_ranges(
2135        &self,
2136        id: crate::highlight::SessionId,
2137        ranges: Vec<crate::highlight::RangeHighlight>,
2138    ) -> bool {
2139        let (ok, queued) = {
2140            let mut inner = self.inner.lock();
2141            let prev_kind = inner.highlight_kind;
2142            // The block layout the ranges are bucketed against — cheap (ids + positions, no
2143            // block text) and computed before the mutable borrow of `highlights`. This is what
2144            // lets `merged_spans_for_block` look up only a block's own ranges instead of
2145            // scanning the whole vector per block.
2146            let block_positions = crate::highlight::ordered_block_positions(&inner);
2147            let changed = inner.highlights.set_ranges(id, ranges, &block_positions);
2148            if let Some((position, length)) = changed {
2149                inner.recompute_highlight_kind();
2150                // The real extent, not `0, 0`: a view can then recolor just the block it covers
2151                // rather than re-snapshotting the whole document on every caret move.
2152                Self::queue_highlight_changed(&mut inner, position, length, prev_kind);
2153            }
2154            (changed.is_some(), inner.take_queued_events())
2155        };
2156        crate::inner::dispatch_queued_events(queued);
2157        ok
2158    }
2159
2160    /// Retire a session (of either kind). Returns whether it existed.
2161    pub fn remove_session(&self, id: crate::highlight::SessionId) -> bool {
2162        let (existed, queued) = {
2163            let mut inner = self.inner.lock();
2164            let prev_kind = inner.highlight_kind;
2165            let existed = inner.highlights.remove(id);
2166            if existed {
2167                inner.recompute_highlight_kind();
2168                Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
2169            }
2170            (existed, inner.take_queued_events())
2171        };
2172        crate::inner::dispatch_queued_events(queued);
2173        existed
2174    }
2175
2176    /// Re-highlight the entire document.
2177    ///
2178    /// Call this when the highlighter's rules change (e.g., new keywords
2179    /// were added, spellcheck dictionary updated).
2180    pub fn rehighlight(&self) {
2181        let queued = {
2182            let mut inner = self.inner.lock();
2183            let prev_kind = inner.highlight_kind;
2184            inner.rehighlight_all();
2185            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
2186            inner.take_queued_events()
2187        };
2188        crate::inner::dispatch_queued_events(queued);
2189    }
2190
2191    /// Re-highlight a single block and cascade to subsequent blocks if
2192    /// the block state changes.
2193    pub fn rehighlight_block(&self, block_id: usize) {
2194        let queued = {
2195            let mut inner = self.inner.lock();
2196            let prev_kind = inner.highlight_kind;
2197            inner.rehighlight_from_block(block_id);
2198            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
2199            inner.take_queued_events()
2200        };
2201        crate::inner::dispatch_queued_events(queued);
2202    }
2203
2204    /// Queue the relayout/repaint notification for a highlight-only change.
2205    ///
2206    /// Highlighting overlays the layout without touching stored formatting,
2207    /// so it emits no edit event on its own — subscribers (live editors)
2208    /// must be told to re-snapshot. The event kind depends on whether the
2209    /// shaping input (`fragments`) changed:
2210    ///
2211    /// - A change that leaves `fragments` BASE on both sides (paint-only ↔
2212    ///   paint-only / none) emits [`DocumentEvent::HighlightPaintChanged`],
2213    ///   which the editor handles by recoloring the cached layout without
2214    ///   reshaping.
2215    /// - Any transition involving a metric-affecting highlighter changes
2216    ///   `fragments` (highlights are merged in / removed), so it emits
2217    ///   [`DocumentEvent::FormatChanged`] (full relayout, caret/scroll
2218    ///   preserved).
2219    ///
2220    /// `position` / `length` name the extent that changed, so a live view can
2221    /// recolor just the blocks it covers instead of re-deriving the whole
2222    /// snapshot. **A `length` of `0` means "unknown — assume the whole
2223    /// document"**, which is what the genuinely document-wide operations pass
2224    /// (installing or retiring a highlighter, a full rehighlight). Only
2225    /// [`set_session_ranges`](Self::set_session_ranges) reports a real extent,
2226    /// its before/after range sets giving an exact answer.
2227    fn queue_highlight_changed(
2228        inner: &mut TextDocumentInner,
2229        position: usize,
2230        length: usize,
2231        prev_kind: crate::highlight::HighlighterKind,
2232    ) {
2233        use crate::highlight::HighlighterKind::{Metric, None as KNone, PaintOnly};
2234        let new_kind = inner.highlight_kind;
2235        let event = match (prev_kind, new_kind) {
2236            // No highlighter before or after — nothing changed.
2237            (KNone, KNone) => return,
2238            // Fragments are BASE on both sides: recolor-only.
2239            (PaintOnly, PaintOnly) | (KNone, PaintOnly) | (PaintOnly, KNone) => {
2240                DocumentEvent::HighlightPaintChanged { position, length }
2241            }
2242            // A metric highlighter is involved on one side: fragments change.
2243            (KNone, Metric)
2244            | (Metric, Metric)
2245            | (Metric, PaintOnly)
2246            | (Metric, KNone)
2247            | (PaintOnly, Metric) => DocumentEvent::FormatChanged {
2248                position,
2249                length,
2250                kind: crate::flow::FormatChangeKind::Character,
2251            },
2252        };
2253        inner.queue_event(event);
2254    }
2255}
2256
2257impl Default for TextDocument {
2258    fn default() -> Self {
2259        Self::new()
2260    }
2261}
2262
2263// ── Undo/redo change detection helpers ─────────────────────────
2264
2265/// Lightweight block state for before/after comparison.
2266///
2267/// Named for undo/redo because that is where it started; it is now also how a
2268/// structural table edit works out what it did to the text. See
2269/// [`emit_content_change_events`].
2270pub(crate) struct UndoBlockState {
2271    id: u64,
2272    position: i64,
2273    text_length: i64,
2274    plain_text: String,
2275    format: BlockFormat,
2276}
2277
2278/// Capture the state of all blocks, sorted by document_position.
2279///
2280/// Reads through the store rather than the plain-text cache, so a caller does
2281/// not have to have invalidated anything first.
2282pub(crate) fn capture_block_state(inner: &TextDocumentInner) -> Vec<UndoBlockState> {
2283    let mut all_blocks =
2284        frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
2285    let store = inner.ctx.db_context.get_store();
2286    crate::inner::refresh_block_positions(&mut all_blocks, store);
2287    let mut states: Vec<UndoBlockState> = all_blocks
2288        .into_iter()
2289        .map(|b| {
2290            let format = BlockFormat::from(&b);
2291            let entity: common::entities::Block = b.clone().into();
2292            let plain_text =
2293                common::database::rope_helpers::block_content_via_store(&entity, store);
2294            let text_length = common::database::rope_helpers::block_char_length(&entity, store);
2295            UndoBlockState {
2296                id: b.id,
2297                position: b.document_position,
2298                text_length,
2299                plain_text,
2300                format,
2301            }
2302        })
2303        .collect();
2304    states.sort_by_key(|s| s.position);
2305    states
2306}
2307
2308/// Build the full document text from sorted block states (joined with newlines).
2309fn build_doc_text(states: &[UndoBlockState]) -> String {
2310    states
2311        .iter()
2312        .map(|s| s.plain_text.as_str())
2313        .collect::<Vec<_>>()
2314        .join("\n")
2315}
2316
2317/// Compute the precise edit between two strings by comparing common prefix and suffix.
2318/// Returns `(edit_offset, chars_removed, chars_added)`.
2319fn compute_text_edit(before: &str, after: &str) -> (usize, usize, usize) {
2320    let before_chars: Vec<char> = before.chars().collect();
2321    let after_chars: Vec<char> = after.chars().collect();
2322
2323    // Common prefix
2324    let prefix_len = before_chars
2325        .iter()
2326        .zip(after_chars.iter())
2327        .take_while(|(a, b)| a == b)
2328        .count();
2329
2330    // Common suffix (not overlapping with prefix)
2331    let before_remaining = before_chars.len() - prefix_len;
2332    let after_remaining = after_chars.len() - prefix_len;
2333    let suffix_len = before_chars
2334        .iter()
2335        .rev()
2336        .zip(after_chars.iter().rev())
2337        .take(before_remaining.min(after_remaining))
2338        .take_while(|(a, b)| a == b)
2339        .count();
2340
2341    let removed = before_remaining - suffix_len;
2342    let added = after_remaining - suffix_len;
2343
2344    (prefix_len, removed, added)
2345}
2346
2347/// Compare block state before and after an edit and emit
2348/// `ContentsChanged` / `FormatChanged` events for the affected regions.
2349///
2350/// ## Why anything else would be a guess
2351///
2352/// The delta this computes is a **real text diff**, and consumers rely on that
2353/// being true rather than approximate: a comment anchor shifts by
2354/// `(position, chars_removed, chars_added)`, so a figure that is merely
2355/// plausible moves an anchor to somewhere that was never right — which is
2356/// harder to notice than not moving it at all.
2357///
2358/// That is why the table primitives call this rather than describing their own
2359/// edit. A row insert knows how many rows it added; it does not know where in
2360/// the document's text that lands, and working it out by hand would be seven
2361/// separate opportunities to be subtly wrong.
2362///
2363/// Used by undo, redo, and every structural table edit.
2364pub(crate) fn emit_content_change_events(inner: &mut TextDocumentInner, before: &[UndoBlockState]) {
2365    let after = capture_block_state(inner);
2366
2367    // Build a map of block id → state for the "before" set.
2368    let before_map: std::collections::HashMap<u64, &UndoBlockState> =
2369        before.iter().map(|s| (s.id, s)).collect();
2370    let after_map: std::collections::HashMap<u64, &UndoBlockState> =
2371        after.iter().map(|s| (s.id, s)).collect();
2372
2373    // Track the affected content region (earliest position, total old/new length).
2374    let mut content_changed = false;
2375    let mut earliest_pos: Option<usize> = None;
2376    let mut old_end: usize = 0;
2377    let mut new_end: usize = 0;
2378    let mut blocks_affected: usize = 0;
2379
2380    let mut format_only_changes: Vec<(usize, usize)> = Vec::new(); // (position, length)
2381
2382    // Check blocks present in both before and after.
2383    for after_state in &after {
2384        if let Some(before_state) = before_map.get(&after_state.id) {
2385            let text_changed = before_state.plain_text != after_state.plain_text
2386                || before_state.text_length != after_state.text_length;
2387            let format_changed = before_state.format != after_state.format;
2388
2389            if text_changed {
2390                content_changed = true;
2391                blocks_affected += 1;
2392                let pos = after_state.position.max(0) as usize;
2393                earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2394                old_end = old_end.max(
2395                    before_state.position.max(0) as usize
2396                        + before_state.text_length.max(0) as usize,
2397                );
2398                new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
2399            } else if format_changed {
2400                let pos = after_state.position.max(0) as usize;
2401                let len = after_state.text_length.max(0) as usize;
2402                format_only_changes.push((pos, len));
2403            }
2404        } else {
2405            // Block exists in after but not in before — new block from undo/redo.
2406            content_changed = true;
2407            blocks_affected += 1;
2408            let pos = after_state.position.max(0) as usize;
2409            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2410            new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
2411        }
2412    }
2413
2414    // Check blocks that were removed (present in before but not after).
2415    for before_state in before {
2416        if !after_map.contains_key(&before_state.id) {
2417            content_changed = true;
2418            blocks_affected += 1;
2419            let pos = before_state.position.max(0) as usize;
2420            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2421            old_end = old_end.max(pos + before_state.text_length.max(0) as usize);
2422        }
2423    }
2424
2425    if content_changed {
2426        let position = earliest_pos.unwrap_or(0);
2427        let chars_removed = old_end.saturating_sub(position);
2428        let chars_added = new_end.saturating_sub(position);
2429
2430        // Use a precise text-level diff for cursor adjustment so cursors land
2431        // at the actual edit point rather than the end of the affected block.
2432        let before_text = build_doc_text(before);
2433        let after_text = build_doc_text(&after);
2434        let (edit_offset, precise_removed, precise_added) =
2435            compute_text_edit(&before_text, &after_text);
2436        if precise_removed > 0 || precise_added > 0 {
2437            inner.adjust_cursors(edit_offset, precise_removed, precise_added);
2438        }
2439
2440        inner.queue_event(DocumentEvent::ContentsChanged {
2441            position,
2442            chars_removed,
2443            chars_added,
2444            blocks_affected,
2445        });
2446        // **`Replayed`, hard-coded, and it cannot double-count.** Undo and redo
2447        // never re-enter the insertion API — they snapshot and diff, which is
2448        // why this function exists — so text restored by them is reported once,
2449        // under an origin that says it came back rather than arrived.
2450        //
2451        // ⚠ Measured as the document's **total** gain, not from `chars_added`.
2452        // That figure is the size of the restored region, which is non-zero for
2453        // an undo that removes text: reporting it would put `Replayed` on a
2454        // count of characters nothing brought back. A test caught exactly that.
2455        let before_len: i64 = before.iter().map(|b| b.text_length.max(0)).sum();
2456        let after_len: i64 = after.iter().map(|a| a.text_length.max(0)).sum();
2457        if after_len > before_len {
2458            inner.queue_event(DocumentEvent::TextInserted {
2459                position,
2460                chars_inserted: (after_len - before_len) as usize,
2461                origin: crate::InsertionOrigin::Replayed,
2462            });
2463        }
2464    }
2465
2466    // Emit FormatChanged for blocks where only formatting changed (not content).
2467    for (position, length) in format_only_changes {
2468        inner.queue_event(DocumentEvent::FormatChanged {
2469            position,
2470            length,
2471            kind: FormatChangeKind::Block,
2472        });
2473    }
2474}
2475
2476// ── Flow helpers ──────────────────────────────────────────────
2477
2478/// Get the main frame ID for the document.
2479/// Collect all block IDs in document order from a frame, recursing into nested
2480/// sub-frames (negative entries in child_order).
2481fn collect_frame_block_ids(
2482    inner: &TextDocumentInner,
2483    frame_id: frontend::common::types::EntityId,
2484) -> Option<Vec<u64>> {
2485    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
2486        .ok()
2487        .flatten()?;
2488
2489    if !frame_dto.child_order.is_empty() {
2490        let mut block_ids = Vec::new();
2491        for &entry in &frame_dto.child_order {
2492            if entry > 0 {
2493                block_ids.push(entry as u64);
2494            } else if entry < 0 {
2495                let sub_frame_id = (-entry) as u64;
2496                let sub_frame = frame_commands::get_frame(&inner.ctx, &sub_frame_id)
2497                    .ok()
2498                    .flatten();
2499                if let Some(ref sf) = sub_frame {
2500                    if let Some(table_id) = sf.table {
2501                        // Table anchor frame: collect blocks from cell frames
2502                        // in row-major order, matching collect_block_ids_recursive.
2503                        if let Some(table_dto) = table_commands::get_table(&inner.ctx, &table_id)
2504                            .ok()
2505                            .flatten()
2506                        {
2507                            let mut cell_dtos: Vec<_> = table_dto
2508                                .cells
2509                                .iter()
2510                                .filter_map(|&cid| {
2511                                    table_cell_commands::get_table_cell(&inner.ctx, &cid)
2512                                        .ok()
2513                                        .flatten()
2514                                })
2515                                .collect();
2516                            cell_dtos
2517                                .sort_by(|a, b| a.row.cmp(&b.row).then(a.column.cmp(&b.column)));
2518                            for cell_dto in &cell_dtos {
2519                                if let Some(cf_id) = cell_dto.cell_frame
2520                                    && let Some(cf_ids) = collect_frame_block_ids(inner, cf_id)
2521                                {
2522                                    block_ids.extend(cf_ids);
2523                                }
2524                            }
2525                        }
2526                    } else if let Some(sub_ids) = collect_frame_block_ids(inner, sub_frame_id) {
2527                        block_ids.extend(sub_ids);
2528                    }
2529                }
2530            }
2531        }
2532        Some(block_ids)
2533    } else {
2534        Some(frame_dto.blocks.to_vec())
2535    }
2536}
2537
2538pub(crate) fn get_main_frame_id(inner: &TextDocumentInner) -> frontend::common::types::EntityId {
2539    // The document's first frame is the main frame.
2540    let frames = frontend::commands::document_commands::get_document_relationship(
2541        &inner.ctx,
2542        &inner.document_id,
2543        &frontend::document::dtos::DocumentRelationshipField::Frames,
2544    )
2545    .unwrap_or_default();
2546
2547    frames.first().copied().unwrap_or(0)
2548}
2549
2550// ── Long-operation event data helpers ─────────────────────────
2551
2552/// Parse progress JSON: `{"id":"...", "percentage": 50.0, "message": "..."}`
2553fn parse_progress_data(data: &Option<String>) -> (String, f64, String) {
2554    let Some(json) = data else {
2555        return (String::new(), 0.0, String::new());
2556    };
2557    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2558    let id = v["id"].as_str().unwrap_or_default().to_string();
2559    let pct = v["percentage"].as_f64().unwrap_or(0.0);
2560    let msg = v["message"].as_str().unwrap_or_default().to_string();
2561    (id, pct, msg)
2562}
2563
2564/// Parse completed/cancelled JSON: `{"id":"..."}`
2565fn parse_id_data(data: &Option<String>) -> String {
2566    let Some(json) = data else {
2567        return String::new();
2568    };
2569    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2570    v["id"].as_str().unwrap_or_default().to_string()
2571}
2572
2573/// Parse failed JSON: `{"id":"...", "error":"..."}`
2574fn parse_failed_data(data: &Option<String>) -> (String, String) {
2575    let Some(json) = data else {
2576        return (String::new(), "unknown error".into());
2577    };
2578    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2579    let id = v["id"].as_str().unwrap_or_default().to_string();
2580    let error = v["error"].as_str().unwrap_or("unknown error").to_string();
2581    (id, error)
2582}