···1+//! Core editor document trait and implementations.
2+//!
3+//! Defines the `EditorDocument` trait for abstracting editor behavior,
4+//! allowing different storage strategies (plain fields vs Signals) while
5+//! sharing the core editing logic.
6+7+use std::ops::Range;
8+9+use smol_str::SmolStr;
10+use web_time::Instant;
11+12+use crate::text::TextBuffer;
13+use crate::types::{BLOCK_SYNTAX_ZONE, CompositionState, CursorState, EditInfo, Selection};
14+use crate::undo::UndoManager;
15+16+/// Core trait for editor documents.
17+///
18+/// Defines the interface for any editor implementation. Different backends
19+/// can implement this trait with different storage strategies:
20+/// - `PlainEditor<T>`: Simple field-based storage
21+/// - Reactive implementations: Use Signals/state management
22+///
23+/// The trait is generic over the buffer type, which must implement both
24+/// `TextBuffer` (for text operations) and `UndoManager` (for undo/redo).
25+pub trait EditorDocument {
26+ /// The buffer type used for text storage and undo.
27+ type Buffer: TextBuffer + UndoManager;
28+29+ // === Required: Buffer access ===
30+31+ /// Get a reference to the underlying buffer.
32+ fn buffer(&self) -> &Self::Buffer;
33+34+ /// Get a mutable reference to the underlying buffer.
35+ fn buffer_mut(&mut self) -> &mut Self::Buffer;
36+37+ // === Required: Cursor/selection state ===
38+39+ /// Get the current cursor state.
40+ fn cursor(&self) -> CursorState;
41+42+ /// Set the cursor state.
43+ fn set_cursor(&mut self, cursor: CursorState);
44+45+ /// Get the current selection, if any.
46+ fn selection(&self) -> Option<Selection>;
47+48+ /// Set the selection.
49+ fn set_selection(&mut self, selection: Option<Selection>);
50+51+ // === Required: Edit tracking ===
52+53+ /// Get the last edit info, if any.
54+ fn last_edit(&self) -> Option<EditInfo>;
55+56+ /// Set the last edit info.
57+ fn set_last_edit(&mut self, edit: Option<EditInfo>);
58+59+ // === Required: Composition (IME) state ===
60+61+ /// Get the current composition state.
62+ fn composition(&self) -> Option<CompositionState>;
63+64+ /// Set the composition state.
65+ fn set_composition(&mut self, composition: Option<CompositionState>);
66+67+ // === Provided: Convenience accessors ===
68+69+ /// Get the cursor offset.
70+ fn cursor_offset(&self) -> usize {
71+ self.cursor().offset
72+ }
73+74+ /// Set just the cursor offset, preserving other cursor state.
75+ fn set_cursor_offset(&mut self, offset: usize) {
76+ let mut cursor = self.cursor();
77+ cursor.offset = offset;
78+ self.set_cursor(cursor);
79+ }
80+81+ /// Get the full content as a String.
82+ fn content_string(&self) -> String {
83+ self.buffer().to_string()
84+ }
85+86+ /// Get length in characters.
87+ fn len_chars(&self) -> usize {
88+ self.buffer().len_chars()
89+ }
90+91+ /// Get length in bytes.
92+ fn len_bytes(&self) -> usize {
93+ self.buffer().len_bytes()
94+ }
95+96+ /// Check if document is empty.
97+ fn is_empty(&self) -> bool {
98+ self.buffer().len_chars() == 0
99+ }
100+101+ /// Get a slice of the content.
102+ fn slice(&self, range: Range<usize>) -> Option<SmolStr> {
103+ self.buffer().slice(range)
104+ }
105+106+ /// Get character at offset.
107+ fn char_at(&self, offset: usize) -> Option<char> {
108+ self.buffer().char_at(offset)
109+ }
110+111+ /// Convert char offset to byte offset.
112+ fn char_to_byte(&self, char_offset: usize) -> usize {
113+ self.buffer().char_to_byte(char_offset)
114+ }
115+116+ /// Convert byte offset to char offset.
117+ fn byte_to_char(&self, byte_offset: usize) -> usize {
118+ self.buffer().byte_to_char(byte_offset)
119+ }
120+121+ /// Get selected text, if any.
122+ fn selected_text(&self) -> Option<SmolStr> {
123+ self.selection()
124+ .and_then(|sel| self.buffer().slice(sel.to_range()))
125+ }
126+127+ // === Provided: Text operations ===
128+129+ /// Insert text at char offset, returning edit info.
130+ fn insert(&mut self, offset: usize, text: &str) -> EditInfo {
131+ let contains_newline = text.contains('\n');
132+ let in_block_syntax_zone = self.is_in_block_syntax_zone(offset);
133+134+ self.buffer_mut().insert(offset, text);
135+136+ let inserted_len = text.chars().count();
137+ self.set_cursor_offset(offset + inserted_len);
138+139+ let edit = EditInfo {
140+ edit_char_pos: offset,
141+ inserted_len,
142+ deleted_len: 0,
143+ contains_newline,
144+ in_block_syntax_zone,
145+ doc_len_after: self.buffer().len_chars(),
146+ timestamp: Instant::now(),
147+ };
148+149+ self.set_last_edit(Some(edit.clone()));
150+ edit
151+ }
152+153+ /// Delete char range, returning edit info.
154+ fn delete(&mut self, range: Range<usize>) -> EditInfo {
155+ let deleted_text = self.buffer().slice(range.clone());
156+ let contains_newline = deleted_text
157+ .as_ref()
158+ .map(|s| s.contains('\n'))
159+ .unwrap_or(false);
160+ let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start);
161+ let deleted_len = range.end - range.start;
162+163+ self.buffer_mut().delete(range.clone());
164+ self.set_cursor_offset(range.start);
165+166+ let edit = EditInfo {
167+ edit_char_pos: range.start,
168+ inserted_len: 0,
169+ deleted_len,
170+ contains_newline,
171+ in_block_syntax_zone,
172+ doc_len_after: self.buffer().len_chars(),
173+ timestamp: Instant::now(),
174+ };
175+176+ self.set_last_edit(Some(edit.clone()));
177+ edit
178+ }
179+180+ /// Replace char range with text, returning edit info.
181+ fn replace(&mut self, range: Range<usize>, text: &str) -> EditInfo {
182+ let deleted_text = self.buffer().slice(range.clone());
183+ let deleted_contains_newline = deleted_text
184+ .as_ref()
185+ .map(|s| s.contains('\n'))
186+ .unwrap_or(false);
187+ let contains_newline = text.contains('\n') || deleted_contains_newline;
188+ let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start);
189+ let deleted_len = range.end - range.start;
190+191+ self.buffer_mut().delete(range.clone());
192+ self.buffer_mut().insert(range.start, text);
193+194+ let inserted_len = text.chars().count();
195+ self.set_cursor_offset(range.start + inserted_len);
196+197+ let edit = EditInfo {
198+ edit_char_pos: range.start,
199+ inserted_len,
200+ deleted_len,
201+ contains_newline,
202+ in_block_syntax_zone,
203+ doc_len_after: self.buffer().len_chars(),
204+ timestamp: Instant::now(),
205+ };
206+207+ self.set_last_edit(Some(edit.clone()));
208+ edit
209+ }
210+211+ /// Delete the current selection, if any.
212+ fn delete_selection(&mut self) -> Option<EditInfo> {
213+ let sel = self.selection()?;
214+ self.set_selection(None);
215+ if sel.is_collapsed() {
216+ return None;
217+ }
218+ Some(self.delete(sel.to_range()))
219+ }
220+221+ // === Provided: Undo/Redo ===
222+223+ fn undo(&mut self) -> bool {
224+ self.buffer_mut().undo()
225+ }
226+227+ fn redo(&mut self) -> bool {
228+ self.buffer_mut().redo()
229+ }
230+231+ fn can_undo(&self) -> bool {
232+ self.buffer().can_undo()
233+ }
234+235+ fn can_redo(&self) -> bool {
236+ self.buffer().can_redo()
237+ }
238+239+ fn clear_history(&mut self) {
240+ self.buffer_mut().clear_history();
241+ }
242+243+ // === Provided: Helpers ===
244+245+ /// Check if offset is in the block-syntax zone (first ~6 chars of line).
246+ fn is_in_block_syntax_zone(&self, offset: usize) -> bool {
247+ let mut line_start = offset;
248+ while line_start > 0 {
249+ if let Some('\n') = self.buffer().char_at(line_start - 1) {
250+ break;
251+ }
252+ line_start -= 1;
253+ }
254+ offset - line_start < BLOCK_SYNTAX_ZONE
255+ }
256+}
257+258+/// Simple field-based implementation of EditorDocument.
259+///
260+/// Stores cursor, selection, and edit state as plain fields.
261+/// Use this for non-reactive contexts or as a base for testing.
262+#[derive(Clone)]
263+pub struct PlainEditor<T: TextBuffer + UndoManager> {
264+ buffer: T,
265+ cursor: CursorState,
266+ selection: Option<Selection>,
267+ last_edit: Option<EditInfo>,
268+ composition: Option<CompositionState>,
269+}
270+271+impl<T: TextBuffer + UndoManager + Default> Default for PlainEditor<T> {
272+ fn default() -> Self {
273+ Self::new(T::default())
274+ }
275+}
276+277+impl<T: TextBuffer + UndoManager> PlainEditor<T> {
278+ /// Create a new editor with the given buffer.
279+ pub fn new(buffer: T) -> Self {
280+ Self {
281+ buffer,
282+ cursor: CursorState::default(),
283+ selection: None,
284+ last_edit: None,
285+ composition: None,
286+ }
287+ }
288+289+ /// Get direct access to the inner buffer (bypasses trait).
290+ pub fn inner(&self) -> &T {
291+ &self.buffer
292+ }
293+294+ /// Get direct mutable access to the inner buffer (bypasses trait).
295+ pub fn inner_mut(&mut self) -> &mut T {
296+ &mut self.buffer
297+ }
298+}
299+300+impl<T: TextBuffer + UndoManager> EditorDocument for PlainEditor<T> {
301+ type Buffer = T;
302+303+ fn buffer(&self) -> &Self::Buffer {
304+ &self.buffer
305+ }
306+307+ fn buffer_mut(&mut self) -> &mut Self::Buffer {
308+ &mut self.buffer
309+ }
310+311+ fn cursor(&self) -> CursorState {
312+ self.cursor.clone()
313+ }
314+315+ fn set_cursor(&mut self, cursor: CursorState) {
316+ self.cursor = cursor;
317+ }
318+319+ fn selection(&self) -> Option<Selection> {
320+ self.selection.clone()
321+ }
322+323+ fn set_selection(&mut self, selection: Option<Selection>) {
324+ self.selection = selection;
325+ }
326+327+ fn last_edit(&self) -> Option<EditInfo> {
328+ self.last_edit.clone()
329+ }
330+331+ fn set_last_edit(&mut self, edit: Option<EditInfo>) {
332+ self.last_edit = edit;
333+ }
334+335+ fn composition(&self) -> Option<CompositionState> {
336+ self.composition.clone()
337+ }
338+339+ fn set_composition(&mut self, composition: Option<CompositionState>) {
340+ self.composition = composition;
341+ }
342+}
343+344+#[cfg(test)]
345+mod tests {
346+ use super::*;
347+ use crate::{EditorRope, UndoableBuffer};
348+349+ type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>;
350+351+ fn make_editor(content: &str) -> TestEditor {
352+ let rope = EditorRope::from_str(content);
353+ let buf = UndoableBuffer::new(rope, 100);
354+ PlainEditor::new(buf)
355+ }
356+357+ #[test]
358+ fn test_basic_insert() {
359+ let mut editor = make_editor("hello");
360+ assert_eq!(editor.content_string(), "hello");
361+362+ let edit = editor.insert(5, " world");
363+ assert_eq!(editor.content_string(), "hello world");
364+ assert_eq!(edit.inserted_len, 6);
365+ assert_eq!(editor.cursor_offset(), 11);
366+ }
367+368+ #[test]
369+ fn test_delete() {
370+ let mut editor = make_editor("hello world");
371+372+ let edit = editor.delete(5..11);
373+ assert_eq!(editor.content_string(), "hello");
374+ assert_eq!(edit.deleted_len, 6);
375+ assert_eq!(editor.cursor_offset(), 5);
376+ }
377+378+ #[test]
379+ fn test_replace() {
380+ let mut editor = make_editor("hello world");
381+382+ let edit = editor.replace(6..11, "rust");
383+ assert_eq!(editor.content_string(), "hello rust");
384+ assert_eq!(edit.deleted_len, 5);
385+ assert_eq!(edit.inserted_len, 4);
386+ }
387+388+ #[test]
389+ fn test_undo_redo() {
390+ let mut editor = make_editor("hello");
391+392+ editor.insert(5, " world");
393+ assert_eq!(editor.content_string(), "hello world");
394+395+ assert!(editor.undo());
396+ assert_eq!(editor.content_string(), "hello");
397+398+ assert!(editor.redo());
399+ assert_eq!(editor.content_string(), "hello world");
400+ }
401+402+ #[test]
403+ fn test_selection() {
404+ let mut editor = make_editor("hello world");
405+406+ editor.set_selection(Some(Selection::new(0, 5)));
407+ assert_eq!(editor.selected_text(), Some("hello".into()));
408+409+ let edit = editor.delete_selection();
410+ assert!(edit.is_some());
411+ assert_eq!(editor.content_string(), " world");
412+ assert!(editor.selection().is_none());
413+ }
414+415+ #[test]
416+ fn test_block_syntax_zone() {
417+ let mut editor = make_editor("# heading\nparagraph");
418+419+ // Position 0 is in block syntax zone
420+ let edit = editor.insert(0, "x");
421+ assert!(edit.in_block_syntax_zone);
422+423+ // Position after newline (start of "paragraph") is also in zone
424+ // Original was "# heading\nparagraph", after insert "x# heading\nparagraph"
425+ // Position 11 is start of "paragraph" line
426+ let edit = editor.insert(11, "y");
427+ assert!(edit.in_block_syntax_zone);
428+ }
429+430+ #[test]
431+ fn test_composition_state() {
432+ let mut editor = make_editor("hello");
433+434+ assert!(editor.composition().is_none());
435+436+ let comp = CompositionState::new(5, "わ".into());
437+ editor.set_composition(Some(comp.clone()));
438+439+ assert_eq!(editor.composition(), Some(comp));
440+441+ editor.set_composition(None);
442+ assert!(editor.composition().is_none());
443+ }
444+445+ #[test]
446+ fn test_offset_conversions() {
447+ let editor = make_editor("héllo wörld"); // multi-byte chars
448+449+ // 'é' is 2 bytes, 'ö' is 2 bytes
450+ // chars: h é l l o w ö r l d
451+ // idx: 0 1 2 3 4 5 6 7 8 9 10
452+453+ assert_eq!(editor.len_chars(), 11);
454+ assert!(editor.len_bytes() > 11); // multi-byte chars
455+456+ // char 1 ('é') starts at byte 1
457+ assert_eq!(editor.char_to_byte(1), 1);
458+ // char 2 ('l') starts after 'é' (2 bytes)
459+ assert_eq!(editor.char_to_byte(2), 3);
460+ }
461+}
+13-3
crates/weaver-editor-core/src/lib.rs
···3//! This crate provides:
4//! - `TextBuffer` trait for text storage abstraction
5//! - `EditorRope` - ropey-backed implementation
6-//! - `EditorDocument<T>` - generic document with undo support
7-//! - Rendering, actions, formatting - all generic over TextBuffer
00809pub mod offset_map;
10pub mod paragraph;
011pub mod syntax;
12pub mod text;
13pub mod types;
014pub mod visibility;
01516pub use offset_map::{
17 OffsetMapping, RenderResult, SnapDirection, SnappedPosition, find_mapping_for_byte,
···22pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
23pub use text::{EditorRope, TextBuffer};
24pub use types::{
25- Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE,
26};
00027pub use visibility::VisibilityState;
0
···3//! This crate provides:
4//! - `TextBuffer` trait for text storage abstraction
5//! - `EditorRope` - ropey-backed implementation
6+//! - `UndoableBuffer<T>` - TextBuffer wrapper with undo/redo
7+//! - `EditorDocument` trait - interface for editor implementations
8+//! - `PlainEditor<T>` - simple field-based EditorDocument impl
9+//! - Rendering types and offset mapping utilities
1011+pub mod document;
12pub mod offset_map;
13pub mod paragraph;
14+pub mod render;
15pub mod syntax;
16pub mod text;
17pub mod types;
18+pub mod undo;
19pub mod visibility;
20+pub mod writer;
2122pub use offset_map::{
23 OffsetMapping, RenderResult, SnapDirection, SnappedPosition, find_mapping_for_byte,
···28pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
29pub use text::{EditorRope, TextBuffer};
30pub use types::{
31+ Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, BLOCK_SYNTAX_ZONE,
32};
33+pub use document::{EditorDocument, PlainEditor};
34+pub use render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
35+pub use undo::{UndoManager, UndoableBuffer};
36pub use visibility::VisibilityState;
37+pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
···1+//! Rendering traits for the editor.
2+//!
3+//! These traits abstract over external concerns during rendering:
4+//! - Resolving embed URLs to HTML content
5+//! - Resolving image URLs to CDN paths
6+//! - Validating wikilinks
7+//!
8+//! Implementations are provided by the consuming application (e.g., weaver-app).
9+10+/// Provides HTML content for embedded resources.
11+///
12+/// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait
13+/// is consulted to get the pre-rendered HTML for the embed.
14+pub trait EmbedContentProvider {
15+ /// Get HTML content for an embed URL.
16+ ///
17+ /// Returns `Some(html)` if the embed content is available,
18+ /// `None` to render a placeholder.
19+ fn get_embed_html(&self, url: &str) -> Option<&str>;
20+}
21+22+/// Unit type implementation - no embeds available.
23+impl EmbedContentProvider for () {
24+ fn get_embed_html(&self, _url: &str) -> Option<&str> {
25+ None
26+ }
27+}
28+29+/// Resolves image URLs from markdown to actual paths.
30+///
31+/// Markdown may reference images by name (e.g., `/image/photo.jpg`).
32+/// This trait maps those to actual CDN URLs or data URLs.
33+pub trait ImageResolver {
34+ /// Resolve an image URL from markdown to an actual URL.
35+ ///
36+ /// Returns `Some(resolved_url)` if the image is found,
37+ /// `None` to use the original URL unchanged.
38+ fn resolve_image_url(&self, url: &str) -> Option<String>;
39+}
40+41+/// Unit type implementation - no image resolution.
42+impl ImageResolver for () {
43+ fn resolve_image_url(&self, _url: &str) -> Option<String> {
44+ None
45+ }
46+}
47+48+/// Validates wikilinks during rendering.
49+///
50+/// Used to add CSS classes indicating whether a wikilink target exists.
51+pub trait WikilinkValidator {
52+ /// Check if a wikilink target is valid (exists).
53+ fn is_valid_link(&self, target: &str) -> bool;
54+}
55+56+/// Unit type implementation - all links are valid.
57+impl WikilinkValidator for () {
58+ fn is_valid_link(&self, _target: &str) -> bool {
59+ true
60+ }
61+}
62+63+/// Reference implementations for common patterns.
64+65+impl<T: EmbedContentProvider> EmbedContentProvider for &T {
66+ fn get_embed_html(&self, url: &str) -> Option<&str> {
67+ (*self).get_embed_html(url)
68+ }
69+}
70+71+impl<T: ImageResolver> ImageResolver for &T {
72+ fn resolve_image_url(&self, url: &str) -> Option<String> {
73+ (*self).resolve_image_url(url)
74+ }
75+}
76+77+impl<T: WikilinkValidator> WikilinkValidator for &T {
78+ fn is_valid_link(&self, target: &str) -> bool {
79+ (*self).is_valid_link(target)
80+ }
81+}
82+83+impl<T: EmbedContentProvider> EmbedContentProvider for Option<T> {
84+ fn get_embed_html(&self, url: &str) -> Option<&str> {
85+ self.as_ref().and_then(|p| p.get_embed_html(url))
86+ }
87+}
88+89+impl<T: ImageResolver> ImageResolver for Option<T> {
90+ fn resolve_image_url(&self, url: &str) -> Option<String> {
91+ self.as_ref().and_then(|r| r.resolve_image_url(url))
92+ }
93+}
94+95+impl<T: WikilinkValidator> WikilinkValidator for Option<T> {
96+ fn is_valid_link(&self, target: &str) -> bool {
97+ self.as_ref().map(|v| v.is_valid_link(target)).unwrap_or(true)
98+ }
99+}
100+101+#[cfg(test)]
102+mod tests {
103+ use super::*;
104+105+ struct TestEmbedProvider;
106+107+ impl EmbedContentProvider for TestEmbedProvider {
108+ fn get_embed_html(&self, url: &str) -> Option<&str> {
109+ if url == "at://test/embed" {
110+ Some("<div>Test Embed</div>")
111+ } else {
112+ None
113+ }
114+ }
115+ }
116+117+ struct TestImageResolver;
118+119+ impl ImageResolver for TestImageResolver {
120+ fn resolve_image_url(&self, url: &str) -> Option<String> {
121+ if url.starts_with("/image/") {
122+ Some(format!("https://cdn.example.com{}", url))
123+ } else {
124+ None
125+ }
126+ }
127+ }
128+129+ struct TestWikilinkValidator {
130+ valid: Vec<String>,
131+ }
132+133+ impl WikilinkValidator for TestWikilinkValidator {
134+ fn is_valid_link(&self, target: &str) -> bool {
135+ self.valid.iter().any(|v| v == target)
136+ }
137+ }
138+139+ #[test]
140+ fn test_embed_provider() {
141+ let provider = TestEmbedProvider;
142+ assert_eq!(
143+ provider.get_embed_html("at://test/embed"),
144+ Some("<div>Test Embed</div>")
145+ );
146+ assert_eq!(provider.get_embed_html("at://other"), None);
147+ }
148+149+ #[test]
150+ fn test_image_resolver() {
151+ let resolver = TestImageResolver;
152+ assert_eq!(
153+ resolver.resolve_image_url("/image/photo.jpg"),
154+ Some("https://cdn.example.com/image/photo.jpg".to_string())
155+ );
156+ assert_eq!(resolver.resolve_image_url("https://other.com/img.png"), None);
157+ }
158+159+ #[test]
160+ fn test_wikilink_validator() {
161+ let validator = TestWikilinkValidator {
162+ valid: vec!["Home".to_string(), "About".to_string()],
163+ };
164+ assert!(validator.is_valid_link("Home"));
165+ assert!(validator.is_valid_link("About"));
166+ assert!(!validator.is_valid_link("Missing"));
167+ }
168+169+ #[test]
170+ fn test_unit_impls() {
171+ let embed: () = ();
172+ assert_eq!(embed.get_embed_html("anything"), None);
173+174+ let image: () = ();
175+ assert_eq!(image.resolve_image_url("anything"), None);
176+177+ let wiki: () = ();
178+ assert!(wiki.is_valid_link("anything")); // default true
179+ }
180+181+ #[test]
182+ fn test_option_impls() {
183+ let some_provider: Option<TestEmbedProvider> = Some(TestEmbedProvider);
184+ assert_eq!(
185+ some_provider.get_embed_html("at://test/embed"),
186+ Some("<div>Test Embed</div>")
187+ );
188+189+ let none_provider: Option<TestEmbedProvider> = None;
190+ assert_eq!(none_provider.get_embed_html("at://test/embed"), None);
191+ }
192+}
+13
crates/weaver-editor-core/src/types.rs
···3//! These types are framework-agnostic and can be used with any text buffer implementation.
45use std::ops::Range;
0006use web_time::Instant;
000000000078/// Cursor state including position and affinity.
9#[derive(Clone, Debug, Copy, PartialEq, Eq)]
···3//! These types are framework-agnostic and can be used with any text buffer implementation.
45use std::ops::Range;
6+7+use jacquard::types::string::AtUri;
8+use weaver_api::sh_weaver::embed::images::Image;
9use web_time::Instant;
10+11+/// Image stored in the editor, with optional publish state tracking.
12+#[derive(Clone, Debug)]
13+pub struct EditorImage {
14+ /// The lexicon Image type (deserialized via from_json_value)
15+ pub image: Image<'static>,
16+ /// AT-URI of the PublishedBlob record (for cleanup on publish/delete).
17+ /// None for existing images that are already in an entry record.
18+ pub published_blob_uri: Option<AtUri<'static>>,
19+}
2021/// Cursor state including position and affinity.
22#[derive(Clone, Debug, Copy, PartialEq, Eq)]