atproto blogging
1//! Platform abstraction traits for editor operations.
2//!
3//! These traits define the interface between the editor logic and platform-specific
4//! implementations (browser DOM, native UI, etc.). This enables the same editor
5//! logic to work across different platforms.
6
7use crate::offset_map::SnapDirection;
8use crate::paragraph::ParagraphRender;
9use crate::types::{CursorRect, SelectionRect};
10
11/// Error type for platform operations.
12#[derive(Debug, Clone)]
13pub struct PlatformError(pub String);
14
15impl std::fmt::Display for PlatformError {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 write!(f, "{}", self.0)
18 }
19}
20
21impl std::error::Error for PlatformError {}
22
23impl From<&str> for PlatformError {
24 fn from(s: &str) -> Self {
25 PlatformError(s.to_string())
26 }
27}
28
29impl From<String> for PlatformError {
30 fn from(s: String) -> Self {
31 PlatformError(s)
32 }
33}
34
35/// Platform-specific cursor and selection operations.
36///
37/// Implementations handle the actual UI interaction for cursor positioning
38/// and selection rendering. The browser implementation uses the DOM Selection API,
39/// native implementations would use their respective UI frameworks.
40pub trait CursorPlatform {
41 /// Restore cursor position in the UI after content changes.
42 ///
43 /// Given a character offset and rendered paragraphs, positions the cursor
44 /// in the rendered content. The snap direction is used when the offset falls
45 /// on invisible content (formatting syntax).
46 fn restore_cursor(
47 &self,
48 char_offset: usize,
49 paragraphs: &[ParagraphRender],
50 snap_direction: Option<SnapDirection>,
51 ) -> Result<(), PlatformError>;
52
53 /// Get the screen coordinates for a cursor at the given offset.
54 ///
55 /// Returns None if the offset cannot be mapped to screen coordinates.
56 fn get_cursor_rect(
57 &self,
58 char_offset: usize,
59 paragraphs: &[ParagraphRender],
60 ) -> Option<CursorRect>;
61
62 /// Get screen coordinates relative to the editor container.
63 ///
64 /// Same as `get_cursor_rect` but coordinates are relative to the editor
65 /// element rather than the viewport.
66 fn get_cursor_rect_relative(
67 &self,
68 char_offset: usize,
69 paragraphs: &[ParagraphRender],
70 ) -> Option<CursorRect>;
71
72 /// Get screen rectangles for a selection range.
73 ///
74 /// Returns multiple rects if the selection spans multiple lines.
75 /// Coordinates are relative to the editor container.
76 fn get_selection_rects_relative(
77 &self,
78 start: usize,
79 end: usize,
80 paragraphs: &[ParagraphRender],
81 ) -> Vec<SelectionRect>;
82}
83
84/// Platform-specific cursor state synchronization.
85///
86/// Handles reading the current cursor/selection state from the platform UI
87/// back into the editor model. This is the inverse of `CursorPlatform`.
88pub trait CursorSync {
89 /// Sync cursor state from the platform UI into the provided callbacks.
90 ///
91 /// The implementation reads the current selection from the UI and calls
92 /// the appropriate callback with the character offset(s).
93 ///
94 /// - For a collapsed cursor: calls `on_cursor(offset)`
95 /// - For a selection: calls `on_selection(anchor, head)`
96 fn sync_cursor_from_platform<F, G>(
97 &self,
98 paragraphs: &[ParagraphRender],
99 direction_hint: Option<SnapDirection>,
100 on_cursor: F,
101 on_selection: G,
102 ) where
103 F: FnOnce(usize),
104 G: FnOnce(usize, usize);
105}
106
107/// Platform-specific clipboard operations.
108///
109/// Implementations handle the low-level clipboard access (sync and async paths
110/// as appropriate for the platform). Document-level operations (selection
111/// extraction, cursor updates) are handled by the `clipboard_*` functions
112/// in this module.
113pub trait ClipboardPlatform {
114 /// Write plain text to clipboard.
115 ///
116 /// For browsers, implementations should use both the sync DataTransfer API
117 /// (for immediate fallback) and the async Clipboard API (for custom MIME types).
118 fn write_text(&self, text: &str);
119
120 /// Write markdown rendered as HTML to clipboard.
121 ///
122 /// The `plain_text` is the original markdown, `html` is the rendered output.
123 /// Both should be written to clipboard with appropriate MIME types.
124 fn write_html(&self, html: &str, plain_text: &str);
125
126 /// Read text from clipboard.
127 ///
128 /// For browsers, this reads from the paste event's DataTransfer.
129 /// Returns None if no text is available.
130 fn read_text(&self) -> Option<String>;
131}
132
133/// Strip zero-width characters used for formatting gaps.
134///
135/// The editor uses ZWNJ (U+200C) and ZWSP (U+200B) to create cursor positions
136/// within invisible formatting syntax. These should be stripped when copying
137/// text to the clipboard.
138pub fn strip_zero_width(text: &str) -> String {
139 text.replace('\u{200C}', "").replace('\u{200B}', "")
140}
141
142/// Copy selected text from document to clipboard.
143///
144/// Returns true if text was copied, false if no selection.
145pub fn clipboard_copy<D: crate::EditorDocument, P: ClipboardPlatform>(
146 doc: &D,
147 platform: &P,
148) -> bool {
149 let Some(sel) = doc.selection() else {
150 return false;
151 };
152
153 let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
154 if start == end {
155 return false;
156 }
157
158 let Some(text) = doc.slice(start..end) else {
159 return false;
160 };
161
162 let clean_text = strip_zero_width(&text);
163 platform.write_text(&clean_text);
164 true
165}
166
167/// Cut selected text from document to clipboard.
168///
169/// Copies the selection to clipboard, then deletes it from the document.
170/// Returns true if text was cut, false if no selection.
171pub fn clipboard_cut<D: crate::EditorDocument, P: ClipboardPlatform>(
172 doc: &mut D,
173 platform: &P,
174) -> bool {
175 let Some(sel) = doc.selection() else {
176 return false;
177 };
178
179 let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
180 if start == end {
181 return false;
182 }
183
184 let Some(text) = doc.slice(start..end) else {
185 return false;
186 };
187
188 let clean_text = strip_zero_width(&text);
189 platform.write_text(&clean_text);
190
191 // Delete selection.
192 doc.delete(start..end);
193 doc.set_selection(None);
194
195 true
196}
197
198/// Paste text from clipboard into document.
199///
200/// Replaces any selection with the pasted text, or inserts at cursor.
201/// Returns true if text was pasted, false if clipboard was empty.
202pub fn clipboard_paste<D: crate::EditorDocument, P: ClipboardPlatform>(
203 doc: &mut D,
204 platform: &P,
205) -> bool {
206 let Some(text) = platform.read_text() else {
207 return false;
208 };
209
210 if text.is_empty() {
211 return false;
212 }
213
214 // Delete selection if present.
215 if let Some(sel) = doc.selection() {
216 let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
217 if start != end {
218 doc.delete(start..end);
219 doc.set_cursor_offset(start);
220 }
221 }
222 doc.set_selection(None);
223
224 // Insert at cursor.
225 let cursor = doc.cursor_offset();
226 doc.insert(cursor, &text);
227
228 true
229}
230
231/// Render markdown to HTML using the ClientWriter.
232///
233/// Uses a minimal context with no embed resolution, suitable for clipboard operations.
234pub fn render_markdown_to_html(markdown: &str) -> Option<String> {
235 use crate::markdown_weaver::Parser;
236 use crate::weaver_renderer::atproto::ClientWriter;
237
238 let parser = Parser::new(markdown).into_offset_iter();
239 let mut html = String::new();
240 ClientWriter::<_, _, ()>::new(parser, &mut html, markdown)
241 .run()
242 .ok()?;
243 Some(html)
244}
245
246/// Copy selected text as rendered HTML to clipboard.
247///
248/// Renders the selected markdown to HTML and writes both representations
249/// to the clipboard. Returns true if text was copied, false if no selection.
250pub fn clipboard_copy_as_html<D: crate::EditorDocument, P: ClipboardPlatform>(
251 doc: &D,
252 platform: &P,
253) -> bool {
254 let Some(sel) = doc.selection() else {
255 return false;
256 };
257
258 let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
259 if start == end {
260 return false;
261 }
262
263 let Some(text) = doc.slice(start..end) else {
264 return false;
265 };
266
267 let clean_text = strip_zero_width(&text);
268
269 let Some(html) = render_markdown_to_html(&clean_text) else {
270 return false;
271 };
272
273 platform.write_html(&html, &clean_text);
274 true
275}