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