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