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, HtmlImportResult, MarkdownImportResult, Operation,
25};
26use crate::{BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions};
27
28/// A rich text document.
29///
30/// Owns the backend (database, event hub, undo/redo manager) and provides
31/// document-level operations. All cursor-based editing goes through
32/// [`TextCursor`], obtained via [`cursor()`](TextDocument::cursor) or
33/// [`cursor_at()`](TextDocument::cursor_at).
34///
35/// Internally uses `Arc<Mutex<...>>` so that multiple [`TextCursor`]s can
36/// coexist and edit concurrently. Cloning a `TextDocument` creates a new
37/// handle to the **same** underlying document (like Qt's implicit sharing).
38#[derive(Clone)]
39pub struct TextDocument {
40    pub(crate) inner: Arc<Mutex<TextDocumentInner>>,
41}
42
43/// Test-only accessor for the underlying rope-backed store. Not part
44/// of the stable public API.
45impl TextDocument {
46    #[doc(hidden)]
47    pub fn rope_store_for_test(&self) -> std::sync::Arc<common::database::Store> {
48        let inner = self.inner.lock();
49        std::sync::Arc::clone(inner.ctx.db_context.get_store())
50    }
51}
52
53impl TextDocument {
54    // ── Construction ──────────────────────────────────────────
55
56    /// Create a new, empty document.
57    ///
58    /// # Panics
59    ///
60    /// Panics if the database context cannot be created (e.g. filesystem error).
61    /// Use [`TextDocument::try_new`] for a fallible alternative.
62    pub fn new() -> Self {
63        Self::try_new().expect("failed to initialize document")
64    }
65
66    /// Create a new, empty document, returning an error on failure.
67    pub fn try_new() -> Result<Self> {
68        let ctx = frontend::AppContext::new();
69        let doc_inner = TextDocumentInner::initialize(ctx)?;
70        let inner = Arc::new(Mutex::new(doc_inner));
71
72        // Bridge backend long-operation events to public DocumentEvent.
73        Self::subscribe_long_operation_events(&inner);
74
75        Ok(Self { inner })
76    }
77
78    /// Subscribe to backend long-operation events and bridge them to DocumentEvent.
79    fn subscribe_long_operation_events(inner: &Arc<Mutex<TextDocumentInner>>) {
80        use frontend::common::event::{LongOperationEvent as LOE, Origin};
81
82        let weak = Arc::downgrade(inner);
83        let mut locked = inner.lock();
84
85        // Progress
86        let w = weak.clone();
87        let progress_tok =
88            locked
89                .event_client
90                .subscribe(Origin::LongOperation(LOE::Progress), move |event| {
91                    if let Some(inner) = w.upgrade() {
92                        let (op_id, percent, message) = parse_progress_data(&event.data);
93                        let mut inner = inner.lock();
94                        inner.queue_event(DocumentEvent::LongOperationProgress {
95                            operation_id: op_id,
96                            percent,
97                            message,
98                        });
99                    }
100                });
101
102        // Completed
103        let w = weak.clone();
104        let completed_tok =
105            locked
106                .event_client
107                .subscribe(Origin::LongOperation(LOE::Completed), move |event| {
108                    if let Some(inner) = w.upgrade() {
109                        let op_id = parse_id_data(&event.data);
110                        let mut inner = inner.lock();
111                        inner.queue_event(DocumentEvent::DocumentReset);
112                        inner.check_block_count_changed();
113                        inner.reset_cached_child_order();
114                        inner.queue_event(DocumentEvent::LongOperationFinished {
115                            operation_id: op_id,
116                            success: true,
117                            error: None,
118                        });
119                    }
120                });
121
122        // Cancelled
123        let w = weak.clone();
124        let cancelled_tok =
125            locked
126                .event_client
127                .subscribe(Origin::LongOperation(LOE::Cancelled), move |event| {
128                    if let Some(inner) = w.upgrade() {
129                        let op_id = parse_id_data(&event.data);
130                        let mut inner = inner.lock();
131                        inner.queue_event(DocumentEvent::LongOperationFinished {
132                            operation_id: op_id,
133                            success: false,
134                            error: Some("cancelled".into()),
135                        });
136                    }
137                });
138
139        // Failed
140        let failed_tok =
141            locked
142                .event_client
143                .subscribe(Origin::LongOperation(LOE::Failed), move |event| {
144                    if let Some(inner) = weak.upgrade() {
145                        let (op_id, error) = parse_failed_data(&event.data);
146                        let mut inner = inner.lock();
147                        inner.queue_event(DocumentEvent::LongOperationFinished {
148                            operation_id: op_id,
149                            success: false,
150                            error: Some(error),
151                        });
152                    }
153                });
154
155        locked.long_op_subscriptions.extend([
156            progress_tok,
157            completed_tok,
158            cancelled_tok,
159            failed_tok,
160        ]);
161    }
162
163    // ── Whole-document content ────────────────────────────────
164
165    /// Replace the entire document with plain text. Clears undo history.
166    pub fn set_plain_text(&self, text: &str) -> Result<()> {
167        let queued = {
168            let mut inner = self.inner.lock();
169            let dto = frontend::document_io::ImportPlainTextDto {
170                plain_text: text.into(),
171            };
172            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
173            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
174            inner.invalidate_text_cache();
175            inner.rehighlight_all();
176            inner.queue_event(DocumentEvent::DocumentReset);
177            inner.check_block_count_changed();
178            inner.reset_cached_child_order();
179            inner.queue_event(DocumentEvent::UndoRedoChanged {
180                can_undo: false,
181                can_redo: false,
182            });
183            inner.take_queued_events()
184        };
185        crate::inner::dispatch_queued_events(queued);
186        Ok(())
187    }
188
189    /// Export the entire document as plain text.
190    pub fn to_plain_text(&self) -> Result<String> {
191        let mut inner = self.inner.lock();
192        Ok(inner.plain_text()?.to_string())
193    }
194
195    /// Replace the entire document with Markdown. Clears undo history.
196    ///
197    /// This is a **long operation**. Returns a typed [`Operation`] handle.
198    pub fn set_markdown(&self, markdown: &str) -> Result<Operation<MarkdownImportResult>> {
199        let mut inner = self.inner.lock();
200        inner.invalidate_text_cache();
201        let dto = frontend::document_io::ImportMarkdownDto {
202            markdown_text: markdown.into(),
203        };
204        let op_id = document_io_commands::import_markdown(&inner.ctx, &dto)?;
205        Ok(Operation::new(
206            op_id,
207            &inner.ctx,
208            Box::new(|ctx, id| {
209                document_io_commands::get_import_markdown_result(ctx, id)
210                    .ok()
211                    .flatten()
212                    .map(|r| {
213                        Ok(MarkdownImportResult {
214                            block_count: to_usize(r.block_count),
215                        })
216                    })
217            }),
218        ))
219    }
220
221    /// Export the entire document as Markdown.
222    pub fn to_markdown(&self) -> Result<String> {
223        let inner = self.inner.lock();
224        let dto = document_io_commands::export_markdown(&inner.ctx)?;
225        Ok(dto.markdown_text)
226    }
227
228    /// Replace the entire document with djot markup. Clears undo history.
229    ///
230    /// This is a **long operation**. Returns a typed [`Operation`] handle.
231    pub fn set_djot(&self, djot: &str) -> Result<Operation<DjotImportResult>> {
232        self.set_djot_with_options(djot, DjotImportOptions::default())
233    }
234
235    /// Replace the entire document with djot markup, selecting which optional
236    /// block attributes (alignment, line height, direction, non-breakable
237    /// lines, background color) are applied via `options`. Clears undo history.
238    ///
239    /// This is a **long operation**. Returns a typed [`Operation`] handle.
240    pub fn set_djot_with_options(
241        &self,
242        djot: &str,
243        options: DjotImportOptions,
244    ) -> Result<Operation<DjotImportResult>> {
245        let mut inner = self.inner.lock();
246        inner.invalidate_text_cache();
247        let dto = frontend::document_io::ImportDjotDto {
248            djot_text: djot.into(),
249            options,
250        };
251        let op_id = document_io_commands::import_djot(&inner.ctx, &dto)?;
252        Ok(Operation::new(
253            op_id,
254            &inner.ctx,
255            Box::new(|ctx, id| {
256                document_io_commands::get_import_djot_result(ctx, id)
257                    .ok()
258                    .flatten()
259                    .map(|r| {
260                        Ok(DjotImportResult {
261                            block_count: to_usize(r.block_count),
262                        })
263                    })
264            }),
265        ))
266    }
267
268    /// Export the entire document as djot markup.
269    pub fn to_djot(&self) -> Result<String> {
270        self.to_djot_with_options(DjotExportOptions::default())
271    }
272
273    /// Export the entire document as djot markup, selecting which optional block
274    /// attributes (alignment, line height, direction, non-breakable lines,
275    /// background color) are emitted via `options`.
276    pub fn to_djot_with_options(&self, options: DjotExportOptions) -> Result<String> {
277        let inner = self.inner.lock();
278        let dto = document_io_commands::export_djot(&inner.ctx, &options)?;
279        Ok(dto.djot_text)
280    }
281
282    /// Replace the entire document with HTML. Clears undo history.
283    ///
284    /// This is a **long operation**. Returns a typed [`Operation`] handle.
285    pub fn set_html(&self, html: &str) -> Result<Operation<HtmlImportResult>> {
286        let mut inner = self.inner.lock();
287        inner.invalidate_text_cache();
288        let dto = frontend::document_io::ImportHtmlDto {
289            html_text: html.into(),
290        };
291        let op_id = document_io_commands::import_html(&inner.ctx, &dto)?;
292        Ok(Operation::new(
293            op_id,
294            &inner.ctx,
295            Box::new(|ctx, id| {
296                document_io_commands::get_import_html_result(ctx, id)
297                    .ok()
298                    .flatten()
299                    .map(|r| {
300                        Ok(HtmlImportResult {
301                            block_count: to_usize(r.block_count),
302                        })
303                    })
304            }),
305        ))
306    }
307
308    /// Export the entire document as HTML.
309    pub fn to_html(&self) -> Result<String> {
310        let inner = self.inner.lock();
311        let dto = document_io_commands::export_html(&inner.ctx)?;
312        Ok(dto.html_text)
313    }
314
315    /// Export the entire document as LaTeX.
316    pub fn to_latex(&self, document_class: &str, include_preamble: bool) -> Result<String> {
317        let inner = self.inner.lock();
318        let dto = frontend::document_io::ExportLatexDto {
319            document_class: document_class.into(),
320            include_preamble,
321        };
322        let result = document_io_commands::export_latex(&inner.ctx, &dto)?;
323        Ok(result.latex_text)
324    }
325
326    /// Export the entire document as DOCX to a file path.
327    ///
328    /// This is a **long operation**. Returns a typed [`Operation`] handle.
329    pub fn to_docx(&self, output_path: &str) -> Result<Operation<DocxExportResult>> {
330        let inner = self.inner.lock();
331        let dto = frontend::document_io::ExportDocxDto {
332            output_path: output_path.into(),
333        };
334        let op_id = document_io_commands::export_docx(&inner.ctx, &dto)?;
335        Ok(Operation::new(
336            op_id,
337            &inner.ctx,
338            Box::new(|ctx, id| {
339                document_io_commands::get_export_docx_result(ctx, id)
340                    .ok()
341                    .flatten()
342                    .map(|r| {
343                        Ok(DocxExportResult {
344                            file_path: r.file_path,
345                            paragraph_count: to_usize(r.paragraph_count),
346                        })
347                    })
348            }),
349        ))
350    }
351
352    /// Clear all document content and reset to an empty state.
353    pub fn clear(&self) -> Result<()> {
354        let queued = {
355            let mut inner = self.inner.lock();
356            let dto = frontend::document_io::ImportPlainTextDto {
357                plain_text: String::new(),
358            };
359            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
360            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
361            inner.invalidate_text_cache();
362            inner.rehighlight_all();
363            inner.queue_event(DocumentEvent::DocumentReset);
364            inner.check_block_count_changed();
365            inner.reset_cached_child_order();
366            inner.queue_event(DocumentEvent::UndoRedoChanged {
367                can_undo: false,
368                can_redo: false,
369            });
370            inner.take_queued_events()
371        };
372        crate::inner::dispatch_queued_events(queued);
373        Ok(())
374    }
375
376    // ── Cursor factory ───────────────────────────────────────
377
378    /// Create a cursor at position 0.
379    pub fn cursor(&self) -> TextCursor {
380        self.cursor_at(0)
381    }
382
383    /// Create a cursor at the given position. If `position` falls
384    /// inside an extended grapheme cluster (decomposed accents, ZWJ
385    /// emoji, skin-tone sequences, flag pairs), the cursor snaps
386    /// forward to the end of the containing cluster so subsequent
387    /// `NextCharacter`/`PreviousCharacter` round-trips remain identity.
388    pub fn cursor_at(&self, position: usize) -> TextCursor {
389        let data = {
390            let mut inner = self.inner.lock();
391            inner.register_cursor(position)
392        };
393        let cursor = TextCursor {
394            doc: self.inner.clone(),
395            data,
396        };
397        cursor.snap_position_to_grapheme_boundary();
398        cursor
399    }
400
401    // ── Document queries ─────────────────────────────────────
402
403    /// Get document statistics. O(1) — reads cached values.
404    pub fn stats(&self) -> DocumentStats {
405        let inner = self.inner.lock();
406        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
407            .expect("get_document_stats should not fail");
408        DocumentStats::from(&dto)
409    }
410
411    /// Get the total character count. O(1) — reads cached value.
412    pub fn character_count(&self) -> usize {
413        let inner = self.inner.lock();
414        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
415            .expect("get_document_stats should not fail");
416        to_usize(dto.character_count)
417    }
418
419    /// Get the number of blocks (paragraphs). O(1) — reads cached value.
420    pub fn block_count(&self) -> usize {
421        let inner = self.inner.lock();
422        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
423            .expect("get_document_stats should not fail");
424        to_usize(dto.block_count)
425    }
426
427    /// Returns true if the document has no text content.
428    pub fn is_empty(&self) -> bool {
429        self.character_count() == 0
430    }
431
432    /// Get text at a position for a given length.
433    pub fn text_at(&self, position: usize, length: usize) -> Result<String> {
434        let inner = self.inner.lock();
435        let dto = frontend::document_inspection::GetTextAtPositionDto {
436            position: to_i64(position),
437            length: to_i64(length),
438        };
439        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
440        Ok(result.text)
441    }
442
443    /// Find the inline segment containing `position` and return its
444    /// stable element id (synthesized from `(block_id, byte_start)`
445    /// via [`common::format_runs::synth_element_id`]) together with the
446    /// segment's absolute start position and the character offset of
447    /// `position` within the segment. Used by accessibility layers to
448    /// convert a document-absolute character position into the
449    /// `(element_id, character_index_in_run)` coordinate space
450    /// AccessKit's `TextPosition` expects.
451    ///
452    /// Returns `None` when the position is outside the document.
453    /// Returns the element at position `position - 1` when `position`
454    /// falls exactly on an element boundary, matching the "cursor
455    /// belongs to the preceding element at a boundary" convention
456    /// used throughout text-document.
457    pub fn find_element_at_position(&self, position: usize) -> Option<(u64, usize, usize)> {
458        let block_info = self.block_at(position).ok()?;
459        let block_start = block_info.start;
460        let offset_in_block = position.checked_sub(block_start)?;
461        let block = crate::text_block::TextBlock {
462            doc: std::sync::Arc::clone(&self.inner),
463            block_id: block_info.block_id,
464        };
465        let frags = block.fragments();
466        // Walk fragments; match the fragment that contains
467        // `offset_in_block`. For a boundary position shared with the
468        // next fragment, prefer the preceding fragment (boundary
469        // belongs to the end of the previous element).
470        let mut last_text: Option<(u64, usize, usize, usize)> = None; // (id, abs_start, frag_offset, frag_length)
471        for frag in &frags {
472            match frag {
473                crate::flow::FragmentContent::Text {
474                    offset,
475                    length,
476                    element_id,
477                    ..
478                } => {
479                    let frag_start = *offset;
480                    let frag_end = frag_start + *length;
481                    if offset_in_block >= frag_start && offset_in_block < frag_end {
482                        let abs_start = block_start + frag_start;
483                        let offset_within = offset_in_block - frag_start;
484                        return Some((*element_id, abs_start, offset_within));
485                    }
486                    // Record as a candidate for the "end-of-element"
487                    // boundary fallback (offset_in_block == frag_end).
488                    if offset_in_block == frag_end {
489                        last_text =
490                            Some((*element_id, block_start + frag_start, frag_start, *length));
491                    }
492                }
493                crate::flow::FragmentContent::Image {
494                    offset, element_id, ..
495                } => {
496                    if offset_in_block == *offset {
497                        return Some((*element_id, block_start + offset, 0));
498                    }
499                }
500            }
501        }
502        // Boundary fallback: position was at the end of the last text
503        // fragment we saw.
504        last_text.map(|(id, abs_start, _, length)| (id, abs_start, length))
505    }
506
507    /// Get info about the block at a position. O(log n).
508    pub fn block_at(&self, position: usize) -> Result<BlockInfo> {
509        let inner = self.inner.lock();
510        let dto = frontend::document_inspection::GetBlockAtPositionDto {
511            position: to_i64(position),
512        };
513        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
514        Ok(BlockInfo::from(&result))
515    }
516
517    /// Get the block format at a position.
518    pub fn block_format_at(&self, position: usize) -> Result<BlockFormat> {
519        let inner = self.inner.lock();
520        let dto = frontend::document_inspection::GetBlockAtPositionDto {
521            position: to_i64(position),
522        };
523        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
524        let block_id = block_info.block_id;
525        let block_id = block_id as u64;
526        let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
527            .ok_or_else(|| DocumentError::NotFound("block not found".into()))?;
528        Ok(BlockFormat::from(&block_dto))
529    }
530
531    // ── Flow traversal (layout engine API) ─────────────────
532
533    /// Walk the main frame's visual flow in document order.
534    ///
535    /// Returns the top-level flow elements — blocks, tables, and
536    /// sub-frames — in the order defined by the main frame's
537    /// `child_order`. Table cell contents are NOT included here;
538    /// access them through [`TextTableCell::blocks()`](crate::TextTableCell::blocks).
539    ///
540    /// This is the primary entry point for layout initialization.
541    pub fn flow(&self) -> Vec<crate::flow::FlowElement> {
542        let inner = self.inner.lock();
543        let main_frame_id = get_main_frame_id(&inner);
544        crate::text_frame::build_flow_elements(&inner, &self.inner, main_frame_id)
545    }
546
547    /// Get a read-only handle to a block by its entity ID.
548    ///
549    /// Entity IDs are stable across insertions and deletions.
550    /// Returns `None` if no block with this ID exists.
551    pub fn block_by_id(&self, block_id: usize) -> Option<crate::text_block::TextBlock> {
552        let inner = self.inner.lock();
553        let exists = frontend::commands::block_commands::get_block(&inner.ctx, &(block_id as u64))
554            .ok()
555            .flatten()
556            .is_some();
557
558        if exists {
559            Some(crate::text_block::TextBlock {
560                doc: self.inner.clone(),
561                block_id,
562            })
563        } else {
564            None
565        }
566    }
567
568    /// Build a single `BlockSnapshot` for the block at the given position.
569    ///
570    /// This is O(k) where k = format runs + image anchors in that block,
571    /// compared to `snapshot_flow()` which is O(n) over the entire document.
572    /// Use for incremental layout updates after single-block edits.
573    pub fn snapshot_block_at_position(
574        &self,
575        position: usize,
576    ) -> Option<crate::flow::BlockSnapshot> {
577        self.snapshot_block_at_position_impl(position, true)
578    }
579
580    /// Like [`snapshot_block_at_position`](Self::snapshot_block_at_position)
581    /// but with **no highlights applied** — base fragments and empty
582    /// `paint_highlights`, regardless of the active highlighter. Used by the
583    /// incremental relayout path of a view that has opted out of highlights.
584    pub fn snapshot_block_at_position_without_highlights(
585        &self,
586        position: usize,
587    ) -> Option<crate::flow::BlockSnapshot> {
588        self.snapshot_block_at_position_impl(position, false)
589    }
590
591    fn snapshot_block_at_position_impl(
592        &self,
593        position: usize,
594        apply_highlights: bool,
595    ) -> Option<crate::flow::BlockSnapshot> {
596        let inner = self.inner.lock();
597        let effective_kind = if apply_highlights {
598            inner.highlight_kind
599        } else {
600            crate::highlight::HighlighterKind::None
601        };
602        let main_frame_id = get_main_frame_id(&inner);
603        let store = inner.ctx.db_context.get_store();
604
605        // Rope-authoritative fast path. When every block is mirrored to the
606        // rope (now true with tables — see `rope_positions_match_flow`), the
607        // rope IS the position space the snapshot reports in, so we must also
608        // *locate* the block via the rope. Walking a hand-rolled `running_pos`
609        // here instead would search in the old cells-inline-no-sentinel space
610        // and then report the rope position — an off-by-the-sentinel mismatch
611        // for any block after a table.
612        if common::database::rope_helpers::rope_positions_match_flow(store)
613            && let Some((block_id, _, _)) =
614                common::database::rope_helpers::find_block_at_char_position(store, position as i64)
615        {
616            return crate::text_block::build_block_snapshot(&inner, block_id, effective_kind);
617        }
618
619        // Collect all block IDs in document order, traversing into nested frames
620        let ordered_block_ids = collect_frame_block_ids(&inner, main_frame_id)?;
621
622        // Walk blocks computing positions on the fly
623        let pos = position as i64;
624        let mut running_pos: i64 = 0;
625        for &block_id in &ordered_block_ids {
626            let block_dto = block_commands::get_block(&inner.ctx, &block_id)
627                .ok()
628                .flatten()?;
629            let entity: common::entities::Block = block_dto.clone().into();
630            let block_end =
631                running_pos + common::database::rope_helpers::block_char_length(&entity, store);
632            if pos >= running_pos && pos <= block_end {
633                return crate::text_block::build_block_snapshot_with_position(
634                    &inner,
635                    block_id,
636                    Some(running_pos as usize),
637                    effective_kind,
638                );
639            }
640            running_pos = block_end + 1;
641        }
642
643        // Fallback to last block
644        if let Some(&last_id) = ordered_block_ids.last() {
645            return crate::text_block::build_block_snapshot(&inner, last_id, effective_kind);
646        }
647        None
648    }
649
650    /// Get a read-only handle to the block containing the given
651    /// character position. Returns `None` if position is out of range.
652    pub fn block_at_position(&self, position: usize) -> Option<crate::text_block::TextBlock> {
653        let inner = self.inner.lock();
654        let dto = frontend::document_inspection::GetBlockAtPositionDto {
655            position: to_i64(position),
656        };
657        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
658        Some(crate::text_block::TextBlock {
659            doc: self.inner.clone(),
660            block_id: result.block_id as usize,
661        })
662    }
663
664    /// Get a read-only handle to a block by its 0-indexed global
665    /// block number.
666    ///
667    /// **O(n)**: requires scanning all blocks sorted by
668    /// `document_position` to find the nth one. Prefer
669    /// [`block_at_position()`](TextDocument::block_at_position) or
670    /// [`block_by_id()`](TextDocument::block_by_id) in
671    /// performance-sensitive paths.
672    pub fn block_by_number(&self, block_number: usize) -> Option<crate::text_block::TextBlock> {
673        let inner = self.inner.lock();
674        let all_blocks = frontend::commands::block_commands::get_all_block(&inner.ctx).ok()?;
675        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
676        let store = inner.ctx.db_context.get_store();
677        crate::inner::refresh_block_positions(&mut sorted, store);
678        sorted.sort_by_key(|b| b.document_position);
679
680        sorted
681            .get(block_number)
682            .map(|b| crate::text_block::TextBlock {
683                doc: self.inner.clone(),
684                block_id: b.id as usize,
685            })
686    }
687
688    /// All blocks in the document, sorted by `document_position`. **O(n)**.
689    ///
690    /// Returns blocks from all frames, including those inside table cells.
691    /// This is the efficient way to iterate all blocks — avoids the O(n^2)
692    /// cost of calling `block_by_number(i)` in a loop.
693    pub fn blocks(&self) -> Vec<crate::text_block::TextBlock> {
694        let inner = self.inner.lock();
695        let all_blocks =
696            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
697        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
698        let store = inner.ctx.db_context.get_store();
699        crate::inner::refresh_block_positions(&mut sorted, store);
700        sorted.sort_by_key(|b| b.document_position);
701        sorted
702            .iter()
703            .map(|b| crate::text_block::TextBlock {
704                doc: self.inner.clone(),
705                block_id: b.id as usize,
706            })
707            .collect()
708    }
709
710    /// All blocks whose character range intersects `[position, position + length)`.
711    ///
712    /// **O(n)**: scans all blocks once. Returns them sorted by `document_position`.
713    /// A block intersects if its range `[block.position, block.position + block.length)`
714    /// overlaps the query range. An empty query range (`length == 0`) returns the
715    /// block containing that position, if any.
716    pub fn blocks_in_range(
717        &self,
718        position: usize,
719        length: usize,
720    ) -> Vec<crate::text_block::TextBlock> {
721        let inner = self.inner.lock();
722        let all_blocks =
723            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
724        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
725        let store = inner.ctx.db_context.get_store();
726        crate::inner::refresh_block_positions(&mut sorted, store);
727        sorted.sort_by_key(|b| b.document_position);
728
729        let range_start = position;
730        let range_end = position + length;
731        sorted
732            .iter()
733            .filter(|b| {
734                let block_start = b.document_position.max(0) as usize;
735                let entity: common::entities::Block = (*b).clone().into();
736                let block_end = block_start
737                    + common::database::rope_helpers::block_char_length(&entity, store).max(0)
738                        as usize;
739                // Overlap check: block intersects [range_start, range_end)
740                if length == 0 {
741                    // Point query: block contains the position
742                    range_start >= block_start && range_start < block_end
743                } else {
744                    block_start < range_end && block_end > range_start
745                }
746            })
747            .map(|b| crate::text_block::TextBlock {
748                doc: self.inner.clone(),
749                block_id: b.id as usize,
750            })
751            .collect()
752    }
753
754    /// Snapshot the entire main flow in a single lock acquisition.
755    ///
756    /// Returns a [`FlowSnapshot`](crate::FlowSnapshot) containing snapshots
757    /// for every element in the flow.
758    pub fn snapshot_flow(&self) -> crate::flow::FlowSnapshot {
759        let inner = self.inner.lock();
760        let main_frame_id = get_main_frame_id(&inner);
761        let elements =
762            crate::text_frame::build_flow_snapshot(&inner, main_frame_id, inner.highlight_kind);
763        crate::flow::FlowSnapshot { elements }
764    }
765
766    /// Snapshot the entire main flow with **no highlights applied** — base
767    /// fragments and empty `paint_highlights` on every block, regardless of
768    /// the active syntax highlighter.
769    ///
770    /// This is the per-view opt-out: a read-only viewer that should stay
771    /// free of search / spell / syntax highlighting pulls *this* snapshot
772    /// instead of [`snapshot_flow`](Self::snapshot_flow). Because suppression
773    /// happens at build time, it works for metric-affecting highlighters too
774    /// (whose highlights are otherwise merged into `fragments` irreversibly).
775    pub fn snapshot_flow_without_highlights(&self) -> crate::flow::FlowSnapshot {
776        let inner = self.inner.lock();
777        let main_frame_id = get_main_frame_id(&inner);
778        let elements = crate::text_frame::build_flow_snapshot(
779            &inner,
780            main_frame_id,
781            crate::highlight::HighlighterKind::None,
782        );
783        crate::flow::FlowSnapshot { elements }
784    }
785
786    // ── Search ───────────────────────────────────────────────
787
788    /// Find the next (or previous) occurrence. Returns `None` if not found.
789    pub fn find(
790        &self,
791        query: &str,
792        from: usize,
793        options: &FindOptions,
794    ) -> Result<Option<FindMatch>> {
795        let inner = self.inner.lock();
796        let dto = options.to_find_text_dto(query, from);
797        let result = document_search_commands::find_text(&inner.ctx, &dto)?;
798        Ok(convert::find_result_to_match(&result))
799    }
800
801    /// Find all occurrences.
802    pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
803        let inner = self.inner.lock();
804        let dto = options.to_find_all_dto(query);
805        let result = document_search_commands::find_all(&inner.ctx, &dto)?;
806        Ok(convert::find_all_to_matches(&result))
807    }
808
809    /// Replace occurrences. Returns the number of replacements. Undoable.
810    pub fn replace_text(
811        &self,
812        query: &str,
813        replacement: &str,
814        replace_all: bool,
815        options: &FindOptions,
816    ) -> Result<usize> {
817        let (count, queued) = {
818            let mut inner = self.inner.lock();
819            let dto = options.to_replace_dto(query, replacement, replace_all);
820            let result =
821                document_search_commands::replace_text(&inner.ctx, Some(inner.stack_id), &dto)?;
822            let count = to_usize(result.replacements_count);
823            inner.invalidate_text_cache();
824            if count > 0 {
825                inner.modified = true;
826                inner.rehighlight_all();
827                // Replacements are scattered across the document — we can't
828                // provide a single position/chars delta. Signal "content changed
829                // from position 0, affecting `count` sites" so the consumer
830                // knows to re-read.
831                inner.queue_event(DocumentEvent::ContentsChanged {
832                    position: 0,
833                    chars_removed: 0,
834                    chars_added: 0,
835                    blocks_affected: count,
836                });
837                inner.check_block_count_changed();
838                inner.check_flow_changed();
839                let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
840                let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
841                inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
842            }
843            (count, inner.take_queued_events())
844        };
845        crate::inner::dispatch_queued_events(queued);
846        Ok(count)
847    }
848
849    // ── Resources ────────────────────────────────────────────
850
851    /// Add a resource (image, stylesheet) to the document.
852    pub fn add_resource(
853        &self,
854        resource_type: ResourceType,
855        name: &str,
856        mime_type: &str,
857        data: &[u8],
858    ) -> Result<()> {
859        let mut inner = self.inner.lock();
860        let dto = frontend::resource::dtos::CreateResourceDto {
861            created_at: Default::default(),
862            updated_at: Default::default(),
863            resource_type,
864            name: name.into(),
865            url: String::new(),
866            mime_type: mime_type.into(),
867            data_base64: BASE64.encode(data),
868        };
869        let created = resource_commands::create_resource(
870            &inner.ctx,
871            Some(inner.stack_id),
872            &dto,
873            inner.document_id,
874            -1,
875        )?;
876        inner.resource_cache.insert(name.to_string(), created.id);
877        Ok(())
878    }
879
880    /// Get a resource by name. Returns `None` if not found.
881    ///
882    /// Uses an internal cache to avoid scanning all resources on repeated lookups.
883    pub fn resource(&self, name: &str) -> Result<Option<Vec<u8>>> {
884        let mut inner = self.inner.lock();
885
886        // Fast path: check the name → ID cache.
887        if let Some(&id) = inner.resource_cache.get(name) {
888            if let Some(r) = resource_commands::get_resource(&inner.ctx, &id)? {
889                let bytes = BASE64
890                    .decode(&r.data_base64)
891                    .map_err(|e| DocumentError::Internal(e.into()))?;
892                return Ok(Some(bytes));
893            }
894            // ID was stale — fall through to full scan.
895            inner.resource_cache.remove(name);
896        }
897
898        // Slow path: linear scan, then populate cache for the match.
899        let all = resource_commands::get_all_resource(&inner.ctx)?;
900        for r in &all {
901            if r.name == name {
902                inner.resource_cache.insert(name.to_string(), r.id);
903                let bytes = BASE64
904                    .decode(&r.data_base64)
905                    .map_err(|e| DocumentError::Internal(e.into()))?;
906                return Ok(Some(bytes));
907            }
908        }
909        Ok(None)
910    }
911
912    // ── Undo / Redo ──────────────────────────────────────────
913
914    /// Undo the last operation.
915    pub fn undo(&self) -> Result<()> {
916        let queued = {
917            let mut inner = self.inner.lock();
918            let before = capture_block_state(&inner);
919            let result = undo_redo_commands::undo(&inner.ctx, Some(inner.stack_id));
920            inner.invalidate_text_cache();
921            result?;
922            inner.rehighlight_all();
923            emit_undo_redo_change_events(&mut inner, &before);
924            inner.check_block_count_changed();
925            inner.check_flow_changed();
926            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
927            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
928            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
929            inner.take_queued_events()
930        };
931        crate::inner::dispatch_queued_events(queued);
932        Ok(())
933    }
934
935    /// Redo the last undone operation.
936    pub fn redo(&self) -> Result<()> {
937        let queued = {
938            let mut inner = self.inner.lock();
939            let before = capture_block_state(&inner);
940            let result = undo_redo_commands::redo(&inner.ctx, Some(inner.stack_id));
941            inner.invalidate_text_cache();
942            result?;
943            inner.rehighlight_all();
944            emit_undo_redo_change_events(&mut inner, &before);
945            inner.check_block_count_changed();
946            inner.check_flow_changed();
947            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
948            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
949            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
950            inner.take_queued_events()
951        };
952        crate::inner::dispatch_queued_events(queued);
953        Ok(())
954    }
955
956    /// Returns true if there are operations that can be undone.
957    pub fn can_undo(&self) -> bool {
958        let inner = self.inner.lock();
959        undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id))
960    }
961
962    /// Returns true if there are operations that can be redone.
963    pub fn can_redo(&self) -> bool {
964        let inner = self.inner.lock();
965        undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id))
966    }
967
968    /// Clear all undo/redo history.
969    pub fn clear_undo_redo(&self) {
970        let inner = self.inner.lock();
971        undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
972    }
973
974    // ── Modified state ───────────────────────────────────────
975
976    /// Returns true if the document has been modified since creation or last reset.
977    pub fn is_modified(&self) -> bool {
978        self.inner.lock().modified
979    }
980
981    /// Set or clear the modified flag.
982    pub fn set_modified(&self, modified: bool) {
983        let queued = {
984            let mut inner = self.inner.lock();
985            if inner.modified != modified {
986                inner.modified = modified;
987                inner.queue_event(DocumentEvent::ModificationChanged(modified));
988            }
989            inner.take_queued_events()
990        };
991        crate::inner::dispatch_queued_events(queued);
992    }
993
994    // ── Document properties ──────────────────────────────────
995
996    /// Get the document title.
997    pub fn title(&self) -> String {
998        let inner = self.inner.lock();
999        document_commands::get_document(&inner.ctx, &inner.document_id)
1000            .ok()
1001            .flatten()
1002            .map(|d| d.title)
1003            .unwrap_or_default()
1004    }
1005
1006    /// Set the document title.
1007    pub fn set_title(&self, title: &str) -> Result<()> {
1008        let inner = self.inner.lock();
1009        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1010            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1011        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1012        update.title = title.into();
1013        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1014        Ok(())
1015    }
1016
1017    /// Get the text direction.
1018    pub fn text_direction(&self) -> TextDirection {
1019        let inner = self.inner.lock();
1020        document_commands::get_document(&inner.ctx, &inner.document_id)
1021            .ok()
1022            .flatten()
1023            .map(|d| d.text_direction)
1024            .unwrap_or(TextDirection::LeftToRight)
1025    }
1026
1027    /// Set the text direction.
1028    pub fn set_text_direction(&self, direction: TextDirection) -> Result<()> {
1029        let inner = self.inner.lock();
1030        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1031            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1032        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1033        update.text_direction = direction;
1034        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1035        Ok(())
1036    }
1037
1038    /// Get the default wrap mode.
1039    pub fn default_wrap_mode(&self) -> WrapMode {
1040        let inner = self.inner.lock();
1041        document_commands::get_document(&inner.ctx, &inner.document_id)
1042            .ok()
1043            .flatten()
1044            .map(|d| d.default_wrap_mode)
1045            .unwrap_or(WrapMode::WordWrap)
1046    }
1047
1048    /// Set the default wrap mode.
1049    pub fn set_default_wrap_mode(&self, mode: WrapMode) -> Result<()> {
1050        let inner = self.inner.lock();
1051        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1052            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1053        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1054        update.default_wrap_mode = mode;
1055        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1056        Ok(())
1057    }
1058
1059    /// Get the document-wide default language (ISO 639-1 code, e.g. "en").
1060    /// This is the fallback hyphenation language for blocks that don't set
1061    /// their own `language`. Defaults to `"en"` when never set.
1062    pub fn default_language(&self) -> String {
1063        let inner = self.inner.lock();
1064        document_commands::get_document(&inner.ctx, &inner.document_id)
1065            .ok()
1066            .flatten()
1067            .and_then(|d| d.default_language)
1068            .unwrap_or_else(|| "en".to_string())
1069    }
1070
1071    /// Set the document-wide default language (ISO 639-1 code). Blocks
1072    /// without an explicit `language` inherit this for hyphenation.
1073    pub fn set_default_language(&self, language: &str) -> Result<()> {
1074        let inner = self.inner.lock();
1075        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1076            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1077        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1078        update.default_language = Some(language.to_string());
1079        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1080        Ok(())
1081    }
1082
1083    // ── Event subscription ───────────────────────────────────
1084
1085    /// Subscribe to document events via callback.
1086    ///
1087    /// Callbacks are invoked **outside** the document lock (after the editing
1088    /// operation completes and the lock is released). It is safe to call
1089    /// `TextDocument` or `TextCursor` methods from within the callback without
1090    /// risk of deadlock. However, keep callbacks lightweight — they run
1091    /// synchronously on the calling thread and block the caller until they
1092    /// return.
1093    ///
1094    /// Drop the returned [`Subscription`] to unsubscribe.
1095    ///
1096    /// # Breaking change (v0.0.6)
1097    ///
1098    /// The callback bound changed from `Send` to `Send + Sync` in v0.0.6
1099    /// to support `Arc`-based dispatch. Callbacks that capture non-`Sync`
1100    /// types (e.g., `Rc<T>`, `Cell<T>`) must be wrapped in a `Mutex`.
1101    pub fn on_change<F>(&self, callback: F) -> Subscription
1102    where
1103        F: Fn(DocumentEvent) + Send + Sync + 'static,
1104    {
1105        let mut inner = self.inner.lock();
1106        events::subscribe_inner(&mut inner, callback)
1107    }
1108
1109    /// Return events accumulated since the last `poll_events()` call.
1110    ///
1111    /// This delivery path is independent of callback dispatch via
1112    /// [`on_change`](Self::on_change) — using both simultaneously is safe
1113    /// and each path sees every event exactly once.
1114    pub fn poll_events(&self) -> Vec<DocumentEvent> {
1115        let mut inner = self.inner.lock();
1116        inner.drain_poll_events()
1117    }
1118
1119    // ── Syntax highlighting ──────────────────────────────────
1120
1121    /// Attach a syntax highlighter to this document.
1122    ///
1123    /// Immediately re-highlights the entire document. Replaces any
1124    /// previously attached highlighter. Pass `None` to remove the
1125    /// highlighter and clear all highlight formatting.
1126    pub fn set_syntax_highlighter(&self, highlighter: Option<Arc<dyn crate::SyntaxHighlighter>>) {
1127        let queued = {
1128            let mut inner = self.inner.lock();
1129            let prev_kind = inner.highlight_kind;
1130            match highlighter {
1131                Some(hl) => {
1132                    inner.highlight = Some(crate::highlight::HighlightData {
1133                        highlighter: hl,
1134                        blocks: std::collections::HashMap::new(),
1135                    });
1136                    inner.rehighlight_all(); // recomputes highlight_kind
1137                }
1138                None => {
1139                    inner.highlight = None;
1140                    inner.recompute_highlight_kind(); // -> None
1141                }
1142            }
1143            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1144            inner.take_queued_events()
1145        };
1146        crate::inner::dispatch_queued_events(queued);
1147    }
1148
1149    /// Re-highlight the entire document.
1150    ///
1151    /// Call this when the highlighter's rules change (e.g., new keywords
1152    /// were added, spellcheck dictionary updated).
1153    pub fn rehighlight(&self) {
1154        let queued = {
1155            let mut inner = self.inner.lock();
1156            let prev_kind = inner.highlight_kind;
1157            inner.rehighlight_all();
1158            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1159            inner.take_queued_events()
1160        };
1161        crate::inner::dispatch_queued_events(queued);
1162    }
1163
1164    /// Re-highlight a single block and cascade to subsequent blocks if
1165    /// the block state changes.
1166    pub fn rehighlight_block(&self, block_id: usize) {
1167        let queued = {
1168            let mut inner = self.inner.lock();
1169            let prev_kind = inner.highlight_kind;
1170            inner.rehighlight_from_block(block_id);
1171            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1172            inner.take_queued_events()
1173        };
1174        crate::inner::dispatch_queued_events(queued);
1175    }
1176
1177    /// Queue the relayout/repaint notification for a highlight-only change.
1178    ///
1179    /// Highlighting overlays the layout without touching stored formatting,
1180    /// so it emits no edit event on its own — subscribers (live editors)
1181    /// must be told to re-snapshot. The event kind depends on whether the
1182    /// shaping input (`fragments`) changed:
1183    ///
1184    /// - A change that leaves `fragments` BASE on both sides (paint-only ↔
1185    ///   paint-only / none) emits [`DocumentEvent::HighlightPaintChanged`],
1186    ///   which the editor handles by recoloring the cached layout without
1187    ///   reshaping.
1188    /// - Any transition involving a metric-affecting highlighter changes
1189    ///   `fragments` (highlights are merged in / removed), so it emits
1190    ///   [`DocumentEvent::FormatChanged`] (full relayout, caret/scroll
1191    ///   preserved).
1192    ///
1193    /// `position` / `length` are advisory: the editor's recolor path
1194    /// re-derives the whole snapshot, so callers pass `0, 0` (whole-document)
1195    /// today.
1196    fn queue_highlight_changed(
1197        inner: &mut TextDocumentInner,
1198        position: usize,
1199        length: usize,
1200        prev_kind: crate::highlight::HighlighterKind,
1201    ) {
1202        use crate::highlight::HighlighterKind::{Metric, None as KNone, PaintOnly};
1203        let new_kind = inner.highlight_kind;
1204        let event = match (prev_kind, new_kind) {
1205            // No highlighter before or after — nothing changed.
1206            (KNone, KNone) => return,
1207            // Fragments are BASE on both sides: recolor-only.
1208            (PaintOnly, PaintOnly) | (KNone, PaintOnly) | (PaintOnly, KNone) => {
1209                DocumentEvent::HighlightPaintChanged { position, length }
1210            }
1211            // A metric highlighter is involved on one side: fragments change.
1212            (KNone, Metric)
1213            | (Metric, Metric)
1214            | (Metric, PaintOnly)
1215            | (Metric, KNone)
1216            | (PaintOnly, Metric) => DocumentEvent::FormatChanged {
1217                position,
1218                length,
1219                kind: crate::flow::FormatChangeKind::Character,
1220            },
1221        };
1222        inner.queue_event(event);
1223    }
1224}
1225
1226impl Default for TextDocument {
1227    fn default() -> Self {
1228        Self::new()
1229    }
1230}
1231
1232// ── Undo/redo change detection helpers ─────────────────────────
1233
1234/// Lightweight block state for before/after comparison during undo/redo.
1235struct UndoBlockState {
1236    id: u64,
1237    position: i64,
1238    text_length: i64,
1239    plain_text: String,
1240    format: BlockFormat,
1241}
1242
1243/// Capture the state of all blocks, sorted by document_position.
1244fn capture_block_state(inner: &TextDocumentInner) -> Vec<UndoBlockState> {
1245    let mut all_blocks =
1246        frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1247    let store = inner.ctx.db_context.get_store();
1248    crate::inner::refresh_block_positions(&mut all_blocks, store);
1249    let mut states: Vec<UndoBlockState> = all_blocks
1250        .into_iter()
1251        .map(|b| {
1252            let format = BlockFormat::from(&b);
1253            let entity: common::entities::Block = b.clone().into();
1254            let plain_text =
1255                common::database::rope_helpers::block_content_via_store(&entity, store);
1256            let text_length = common::database::rope_helpers::block_char_length(&entity, store);
1257            UndoBlockState {
1258                id: b.id,
1259                position: b.document_position,
1260                text_length,
1261                plain_text,
1262                format,
1263            }
1264        })
1265        .collect();
1266    states.sort_by_key(|s| s.position);
1267    states
1268}
1269
1270/// Build the full document text from sorted block states (joined with newlines).
1271fn build_doc_text(states: &[UndoBlockState]) -> String {
1272    states
1273        .iter()
1274        .map(|s| s.plain_text.as_str())
1275        .collect::<Vec<_>>()
1276        .join("\n")
1277}
1278
1279/// Compute the precise edit between two strings by comparing common prefix and suffix.
1280/// Returns `(edit_offset, chars_removed, chars_added)`.
1281fn compute_text_edit(before: &str, after: &str) -> (usize, usize, usize) {
1282    let before_chars: Vec<char> = before.chars().collect();
1283    let after_chars: Vec<char> = after.chars().collect();
1284
1285    // Common prefix
1286    let prefix_len = before_chars
1287        .iter()
1288        .zip(after_chars.iter())
1289        .take_while(|(a, b)| a == b)
1290        .count();
1291
1292    // Common suffix (not overlapping with prefix)
1293    let before_remaining = before_chars.len() - prefix_len;
1294    let after_remaining = after_chars.len() - prefix_len;
1295    let suffix_len = before_chars
1296        .iter()
1297        .rev()
1298        .zip(after_chars.iter().rev())
1299        .take(before_remaining.min(after_remaining))
1300        .take_while(|(a, b)| a == b)
1301        .count();
1302
1303    let removed = before_remaining - suffix_len;
1304    let added = after_remaining - suffix_len;
1305
1306    (prefix_len, removed, added)
1307}
1308
1309/// Compare block state before and after undo/redo and emit
1310/// ContentsChanged / FormatChanged events for affected regions.
1311fn emit_undo_redo_change_events(inner: &mut TextDocumentInner, before: &[UndoBlockState]) {
1312    let after = capture_block_state(inner);
1313
1314    // Build a map of block id → state for the "before" set.
1315    let before_map: std::collections::HashMap<u64, &UndoBlockState> =
1316        before.iter().map(|s| (s.id, s)).collect();
1317    let after_map: std::collections::HashMap<u64, &UndoBlockState> =
1318        after.iter().map(|s| (s.id, s)).collect();
1319
1320    // Track the affected content region (earliest position, total old/new length).
1321    let mut content_changed = false;
1322    let mut earliest_pos: Option<usize> = None;
1323    let mut old_end: usize = 0;
1324    let mut new_end: usize = 0;
1325    let mut blocks_affected: usize = 0;
1326
1327    let mut format_only_changes: Vec<(usize, usize)> = Vec::new(); // (position, length)
1328
1329    // Check blocks present in both before and after.
1330    for after_state in &after {
1331        if let Some(before_state) = before_map.get(&after_state.id) {
1332            let text_changed = before_state.plain_text != after_state.plain_text
1333                || before_state.text_length != after_state.text_length;
1334            let format_changed = before_state.format != after_state.format;
1335
1336            if text_changed {
1337                content_changed = true;
1338                blocks_affected += 1;
1339                let pos = after_state.position.max(0) as usize;
1340                earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
1341                old_end = old_end.max(
1342                    before_state.position.max(0) as usize
1343                        + before_state.text_length.max(0) as usize,
1344                );
1345                new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
1346            } else if format_changed {
1347                let pos = after_state.position.max(0) as usize;
1348                let len = after_state.text_length.max(0) as usize;
1349                format_only_changes.push((pos, len));
1350            }
1351        } else {
1352            // Block exists in after but not in before — new block from undo/redo.
1353            content_changed = true;
1354            blocks_affected += 1;
1355            let pos = after_state.position.max(0) as usize;
1356            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
1357            new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
1358        }
1359    }
1360
1361    // Check blocks that were removed (present in before but not after).
1362    for before_state in before {
1363        if !after_map.contains_key(&before_state.id) {
1364            content_changed = true;
1365            blocks_affected += 1;
1366            let pos = before_state.position.max(0) as usize;
1367            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
1368            old_end = old_end.max(pos + before_state.text_length.max(0) as usize);
1369        }
1370    }
1371
1372    if content_changed {
1373        let position = earliest_pos.unwrap_or(0);
1374        let chars_removed = old_end.saturating_sub(position);
1375        let chars_added = new_end.saturating_sub(position);
1376
1377        // Use a precise text-level diff for cursor adjustment so cursors land
1378        // at the actual edit point rather than the end of the affected block.
1379        let before_text = build_doc_text(before);
1380        let after_text = build_doc_text(&after);
1381        let (edit_offset, precise_removed, precise_added) =
1382            compute_text_edit(&before_text, &after_text);
1383        if precise_removed > 0 || precise_added > 0 {
1384            inner.adjust_cursors(edit_offset, precise_removed, precise_added);
1385        }
1386
1387        inner.queue_event(DocumentEvent::ContentsChanged {
1388            position,
1389            chars_removed,
1390            chars_added,
1391            blocks_affected,
1392        });
1393    }
1394
1395    // Emit FormatChanged for blocks where only formatting changed (not content).
1396    for (position, length) in format_only_changes {
1397        inner.queue_event(DocumentEvent::FormatChanged {
1398            position,
1399            length,
1400            kind: FormatChangeKind::Block,
1401        });
1402    }
1403}
1404
1405// ── Flow helpers ──────────────────────────────────────────────
1406
1407/// Get the main frame ID for the document.
1408/// Collect all block IDs in document order from a frame, recursing into nested
1409/// sub-frames (negative entries in child_order).
1410fn collect_frame_block_ids(
1411    inner: &TextDocumentInner,
1412    frame_id: frontend::common::types::EntityId,
1413) -> Option<Vec<u64>> {
1414    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
1415        .ok()
1416        .flatten()?;
1417
1418    if !frame_dto.child_order.is_empty() {
1419        let mut block_ids = Vec::new();
1420        for &entry in &frame_dto.child_order {
1421            if entry > 0 {
1422                block_ids.push(entry as u64);
1423            } else if entry < 0 {
1424                let sub_frame_id = (-entry) as u64;
1425                let sub_frame = frame_commands::get_frame(&inner.ctx, &sub_frame_id)
1426                    .ok()
1427                    .flatten();
1428                if let Some(ref sf) = sub_frame {
1429                    if let Some(table_id) = sf.table {
1430                        // Table anchor frame: collect blocks from cell frames
1431                        // in row-major order, matching collect_block_ids_recursive.
1432                        if let Some(table_dto) = table_commands::get_table(&inner.ctx, &table_id)
1433                            .ok()
1434                            .flatten()
1435                        {
1436                            let mut cell_dtos: Vec<_> = table_dto
1437                                .cells
1438                                .iter()
1439                                .filter_map(|&cid| {
1440                                    table_cell_commands::get_table_cell(&inner.ctx, &cid)
1441                                        .ok()
1442                                        .flatten()
1443                                })
1444                                .collect();
1445                            cell_dtos
1446                                .sort_by(|a, b| a.row.cmp(&b.row).then(a.column.cmp(&b.column)));
1447                            for cell_dto in &cell_dtos {
1448                                if let Some(cf_id) = cell_dto.cell_frame
1449                                    && let Some(cf_ids) = collect_frame_block_ids(inner, cf_id)
1450                                {
1451                                    block_ids.extend(cf_ids);
1452                                }
1453                            }
1454                        }
1455                    } else if let Some(sub_ids) = collect_frame_block_ids(inner, sub_frame_id) {
1456                        block_ids.extend(sub_ids);
1457                    }
1458                }
1459            }
1460        }
1461        Some(block_ids)
1462    } else {
1463        Some(frame_dto.blocks.to_vec())
1464    }
1465}
1466
1467pub(crate) fn get_main_frame_id(inner: &TextDocumentInner) -> frontend::common::types::EntityId {
1468    // The document's first frame is the main frame.
1469    let frames = frontend::commands::document_commands::get_document_relationship(
1470        &inner.ctx,
1471        &inner.document_id,
1472        &frontend::document::dtos::DocumentRelationshipField::Frames,
1473    )
1474    .unwrap_or_default();
1475
1476    frames.first().copied().unwrap_or(0)
1477}
1478
1479// ── Long-operation event data helpers ─────────────────────────
1480
1481/// Parse progress JSON: `{"id":"...", "percentage": 50.0, "message": "..."}`
1482fn parse_progress_data(data: &Option<String>) -> (String, f64, String) {
1483    let Some(json) = data else {
1484        return (String::new(), 0.0, String::new());
1485    };
1486    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1487    let id = v["id"].as_str().unwrap_or_default().to_string();
1488    let pct = v["percentage"].as_f64().unwrap_or(0.0);
1489    let msg = v["message"].as_str().unwrap_or_default().to_string();
1490    (id, pct, msg)
1491}
1492
1493/// Parse completed/cancelled JSON: `{"id":"..."}`
1494fn parse_id_data(data: &Option<String>) -> String {
1495    let Some(json) = data else {
1496        return String::new();
1497    };
1498    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1499    v["id"].as_str().unwrap_or_default().to_string()
1500}
1501
1502/// Parse failed JSON: `{"id":"...", "error":"..."}`
1503fn parse_failed_data(data: &Option<String>) -> (String, String) {
1504    let Some(json) = data else {
1505        return (String::new(), "unknown error".into());
1506    };
1507    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
1508    let id = v["id"].as_str().unwrap_or_default().to_string();
1509    let error = v["error"].as_str().unwrap_or("unknown error").to_string();
1510    (id, error)
1511}