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