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