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