at main 275 lines 8.5 kB view raw
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}