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