atproto blogging
1//! JsEditor - the main editor wrapper for JavaScript.
2
3use std::collections::HashMap;
4
5use wasm_bindgen::JsCast;
6use wasm_bindgen::prelude::*;
7use web_sys::HtmlElement;
8
9use weaver_editor_browser::{
10 BrowserClipboard, BrowserCursor, ParagraphRender, update_paragraph_dom,
11 update_syntax_visibility,
12};
13use weaver_editor_core::{
14 CursorPlatform, EditorDocument, EditorImageResolver, EditorRope, PlainEditor, RenderCache,
15 UndoableBuffer, apply_formatting, execute_action_with_clipboard, render_paragraphs_incremental,
16};
17
18use crate::actions::{ActionKind, parse_action};
19use crate::types::{
20 EntryEmbeds, EntryJson, FinalizedImage, JsParagraphRender, JsResolvedContent, PendingImage,
21};
22
23type InnerEditor = PlainEditor<UndoableBuffer<EditorRope>>;
24
25/// The main editor instance exposed to JavaScript.
26///
27/// Wraps the core editor with WASM bindings for browser use.
28#[wasm_bindgen]
29pub struct JsEditor {
30 pub(crate) doc: InnerEditor,
31 pub(crate) cache: RenderCache,
32 pub(crate) resolved_content: weaver_common::ResolvedContent,
33 pub(crate) image_resolver: EditorImageResolver,
34 pub(crate) entry_index: weaver_common::EntryIndex,
35 pub(crate) paragraphs: Vec<ParagraphRender>,
36
37 // Mount state
38 pub(crate) editor_id: Option<String>,
39 pub(crate) on_change: Option<js_sys::Function>,
40
41 // Metadata
42 title: String,
43 path: String,
44 tags: Vec<String>,
45 created_at: String,
46
47 // Image tracking
48 pending_images: HashMap<String, PendingImage>,
49 finalized_images: HashMap<String, FinalizedImage>,
50}
51
52#[wasm_bindgen]
53impl JsEditor {
54 /// Create a new empty editor.
55 #[wasm_bindgen(constructor)]
56 pub fn new() -> Self {
57 let rope = EditorRope::new();
58 let buffer = UndoableBuffer::new(rope, 100);
59 let doc = PlainEditor::new(buffer);
60
61 Self {
62 doc,
63 cache: RenderCache::default(),
64 resolved_content: weaver_common::ResolvedContent::new(),
65 image_resolver: EditorImageResolver::new(),
66 entry_index: weaver_common::EntryIndex::new(),
67 paragraphs: Vec::new(),
68 editor_id: None,
69 on_change: None,
70 title: String::new(),
71 path: String::new(),
72 tags: Vec::new(),
73 created_at: now_iso(),
74 pending_images: HashMap::new(),
75 finalized_images: HashMap::new(),
76 }
77 }
78
79 /// Create an editor from markdown content.
80 #[wasm_bindgen(js_name = fromMarkdown)]
81 pub fn from_markdown(content: &str) -> Self {
82 let rope = EditorRope::from_str(content);
83 let buffer = UndoableBuffer::new(rope, 100);
84 let doc = PlainEditor::new(buffer);
85
86 Self {
87 doc,
88 cache: RenderCache::default(),
89 resolved_content: weaver_common::ResolvedContent::new(),
90 image_resolver: EditorImageResolver::new(),
91 entry_index: weaver_common::EntryIndex::new(),
92 paragraphs: Vec::new(),
93 editor_id: None,
94 on_change: None,
95 title: String::new(),
96 path: String::new(),
97 tags: Vec::new(),
98 created_at: now_iso(),
99 pending_images: HashMap::new(),
100 finalized_images: HashMap::new(),
101 }
102 }
103
104 /// Create an editor from a snapshot (EntryJson).
105 #[wasm_bindgen(js_name = fromSnapshot)]
106 pub fn from_snapshot(snapshot: JsValue) -> Result<JsEditor, JsError> {
107 let entry: EntryJson = serde_wasm_bindgen::from_value(snapshot)
108 .map_err(|e| JsError::new(&format!("Invalid snapshot: {}", e)))?;
109
110 let rope = EditorRope::from_str(&entry.content);
111 let buffer = UndoableBuffer::new(rope, 100);
112 let doc = PlainEditor::new(buffer);
113
114 Ok(Self {
115 doc,
116 cache: RenderCache::default(),
117 resolved_content: weaver_common::ResolvedContent::new(),
118 image_resolver: EditorImageResolver::new(),
119 entry_index: weaver_common::EntryIndex::new(),
120 paragraphs: Vec::new(),
121 editor_id: None,
122 on_change: None,
123 title: entry.title,
124 path: entry.path,
125 tags: entry.tags.unwrap_or_default(),
126 created_at: entry.created_at,
127 pending_images: HashMap::new(),
128 finalized_images: HashMap::new(),
129 })
130 }
131
132 /// Set pre-resolved embed content.
133 #[wasm_bindgen(js_name = setResolvedContent)]
134 pub fn set_resolved_content(&mut self, content: JsResolvedContent) {
135 self.resolved_content = content.into_inner();
136 }
137
138 // === Content access ===
139
140 /// Get the markdown content.
141 #[wasm_bindgen(js_name = getMarkdown)]
142 pub fn get_markdown(&self) -> String {
143 self.doc.content_string()
144 }
145
146 /// Get the current state as a snapshot (EntryJson).
147 #[wasm_bindgen(js_name = getSnapshot)]
148 pub fn get_snapshot(&self) -> Result<JsValue, JsError> {
149 let entry = EntryJson {
150 title: self.title.clone(),
151 path: self.path.clone(),
152 content: self.doc.content_string(),
153 created_at: self.created_at.clone(),
154 updated_at: Some(now_iso()),
155 tags: if self.tags.is_empty() {
156 None
157 } else {
158 Some(self.tags.clone())
159 },
160 embeds: self.build_embeds(),
161 authors: None,
162 content_warnings: None,
163 rating: None,
164 };
165
166 serde_wasm_bindgen::to_value(&entry)
167 .map_err(|e| JsError::new(&format!("Serialization error: {}", e)))
168 }
169
170 /// Get the entry JSON, validating required fields.
171 ///
172 /// Throws if title or path is empty, or if there are pending images.
173 #[wasm_bindgen(js_name = toEntry)]
174 pub fn to_entry(&self) -> Result<JsValue, JsError> {
175 if self.title.is_empty() {
176 return Err(JsError::new("Title is required"));
177 }
178 if self.path.is_empty() {
179 return Err(JsError::new("Path is required"));
180 }
181 if !self.pending_images.is_empty() {
182 return Err(JsError::new(
183 "Pending images must be finalized before publishing",
184 ));
185 }
186
187 self.get_snapshot()
188 }
189
190 // === Metadata ===
191
192 /// Get the title.
193 #[wasm_bindgen(js_name = getTitle)]
194 pub fn get_title(&self) -> String {
195 self.title.clone()
196 }
197
198 /// Set the title.
199 #[wasm_bindgen(js_name = setTitle)]
200 pub fn set_title(&mut self, title: &str) {
201 self.title = title.to_string();
202 }
203
204 /// Get the path.
205 #[wasm_bindgen(js_name = getPath)]
206 pub fn get_path(&self) -> String {
207 self.path.clone()
208 }
209
210 /// Set the path.
211 #[wasm_bindgen(js_name = setPath)]
212 pub fn set_path(&mut self, path: &str) {
213 self.path = path.to_string();
214 }
215
216 /// Get the tags.
217 #[wasm_bindgen(js_name = getTags)]
218 pub fn get_tags(&self) -> Vec<String> {
219 self.tags.clone()
220 }
221
222 /// Set the tags.
223 #[wasm_bindgen(js_name = setTags)]
224 pub fn set_tags(&mut self, tags: Vec<String>) {
225 self.tags = tags;
226 }
227
228 // === Actions ===
229
230 /// Execute an editor action.
231 ///
232 /// Automatically re-renders and updates the DOM after the action.
233 #[wasm_bindgen(js_name = executeAction)]
234 pub fn execute_action(&mut self, action: JsValue) -> Result<(), JsError> {
235 let js_action = parse_action(action)?;
236 let kind = js_action.to_action_kind();
237
238 let clipboard = BrowserClipboard::empty();
239 match kind {
240 ActionKind::Editor(editor_action) => {
241 execute_action_with_clipboard(&mut self.doc, &editor_action, &clipboard);
242 }
243 ActionKind::Format(format_action) => {
244 apply_formatting(&mut self.doc, format_action);
245 }
246 }
247
248 // Update DOM and notify
249 self.render_and_update_dom();
250 self.notify_change();
251
252 Ok(())
253 }
254
255 // === Image handling ===
256
257 /// Add a pending image (called when user adds an image).
258 ///
259 /// The `data_url` is used for preview rendering until uploaded.
260 #[wasm_bindgen(js_name = addPendingImage)]
261 pub fn add_pending_image(&mut self, image: JsValue, data_url: &str) -> Result<(), JsError> {
262 let pending: PendingImage = serde_wasm_bindgen::from_value(image)
263 .map_err(|e| JsError::new(&format!("Invalid pending image: {}", e)))?;
264
265 // Add to image resolver for preview rendering
266 self.image_resolver
267 .add_pending(&pending.local_id, data_url.to_string());
268
269 self.pending_images
270 .insert(pending.local_id.clone(), pending);
271 Ok(())
272 }
273
274 /// Finalize an image after upload.
275 ///
276 /// Requires the blob rkey (from sh.weaver.publish.blob) and the user's identifier.
277 #[wasm_bindgen(js_name = finalizeImage)]
278 pub fn finalize_image(
279 &mut self,
280 local_id: &str,
281 finalized: JsValue,
282 blob_rkey: &str,
283 ident: &str,
284 ) -> Result<(), JsError> {
285 use weaver_common::jacquard::IntoStatic;
286 use weaver_common::jacquard::types::ident::AtIdentifier;
287 use weaver_common::jacquard::types::string::Rkey;
288
289 let finalized_data: FinalizedImage = serde_wasm_bindgen::from_value(finalized)
290 .map_err(|e| JsError::new(&format!("Invalid finalized image: {}", e)))?;
291
292 let rkey = Rkey::new(blob_rkey)
293 .map_err(|e| JsError::new(&format!("Invalid rkey: {}", e)))?
294 .into_static();
295 let identifier = AtIdentifier::new(ident)
296 .map_err(|e| JsError::new(&format!("Invalid identifier: {}", e)))?
297 .into_static();
298
299 // Promote pending to uploaded in image resolver
300 self.image_resolver
301 .promote_to_uploaded(local_id, rkey, identifier);
302
303 self.pending_images.remove(local_id);
304 self.finalized_images
305 .insert(local_id.to_string(), finalized_data);
306 Ok(())
307 }
308
309 /// Remove an image from tracking.
310 ///
311 /// Note: The image resolver does not support removal, so images remain
312 /// until the editor is destroyed.
313 #[wasm_bindgen(js_name = removeImage)]
314 pub fn remove_image(&mut self, local_id: &str) {
315 self.pending_images.remove(local_id);
316 self.finalized_images.remove(local_id);
317 }
318
319 /// Get pending images that need upload.
320 #[wasm_bindgen(js_name = getPendingImages)]
321 pub fn get_pending_images(&self) -> Result<JsValue, JsError> {
322 let pending: Vec<_> = self.pending_images.values().cloned().collect();
323 serde_wasm_bindgen::to_value(&pending)
324 .map_err(|e| JsError::new(&format!("Serialization error: {}", e)))
325 }
326
327 /// Get staging URIs for cleanup after publish.
328 #[wasm_bindgen(js_name = getStagingUris)]
329 pub fn get_staging_uris(&self) -> Vec<String> {
330 self.finalized_images
331 .values()
332 .map(|f| f.staging_uri.clone())
333 .collect()
334 }
335
336 // === Entry index (for wikilinks) ===
337
338 /// Add an entry to the wikilink index.
339 ///
340 /// This allows wikilinks to resolve correctly in the editor.
341 /// - `title`: The entry title (matched case-insensitively)
342 /// - `path`: The entry path slug (matched case-insensitively)
343 /// - `canonical_url`: The URL to link to (e.g., "/my-notebook/my-entry")
344 #[wasm_bindgen(js_name = addEntryToIndex)]
345 pub fn add_entry_to_index(&mut self, title: &str, path: &str, canonical_url: &str) {
346 self.entry_index
347 .add_entry(title, path, canonical_url.to_string());
348 }
349
350 /// Clear the entry index.
351 #[wasm_bindgen(js_name = clearEntryIndex)]
352 pub fn clear_entry_index(&mut self) {
353 self.entry_index = weaver_common::EntryIndex::new();
354 }
355
356 // === Cursor/selection ===
357
358 /// Get the current cursor offset.
359 #[wasm_bindgen(js_name = getCursorOffset)]
360 pub fn get_cursor_offset(&self) -> usize {
361 self.doc.cursor_offset()
362 }
363
364 /// Set the cursor offset.
365 #[wasm_bindgen(js_name = setCursorOffset)]
366 pub fn set_cursor_offset(&mut self, offset: usize) {
367 self.doc.set_cursor_offset(offset);
368 }
369
370 /// Get the document length in characters.
371 #[wasm_bindgen(js_name = getLength)]
372 pub fn get_length(&self) -> usize {
373 self.doc.len_chars()
374 }
375
376 // === Undo/redo ===
377
378 /// Check if undo is available.
379 #[wasm_bindgen(js_name = canUndo)]
380 pub fn can_undo(&self) -> bool {
381 self.doc.can_undo()
382 }
383
384 /// Check if redo is available.
385 #[wasm_bindgen(js_name = canRedo)]
386 pub fn can_redo(&self) -> bool {
387 self.doc.can_redo()
388 }
389
390 // === Mounting ===
391
392 /// Mount the editor into a container element.
393 ///
394 /// Creates a contenteditable div inside the container and sets up event handlers.
395 /// The onChange callback is called after each edit.
396 #[wasm_bindgen]
397 pub fn mount(
398 &mut self,
399 container: &HtmlElement,
400 on_change: Option<js_sys::Function>,
401 ) -> Result<(), JsError> {
402 let window = web_sys::window().ok_or_else(|| JsError::new("No window"))?;
403 let document = window
404 .document()
405 .ok_or_else(|| JsError::new("No document"))?;
406
407 // Generate unique ID for the editor element
408 let editor_id = format!("weaver-editor-{}", js_sys::Math::random().to_bits());
409
410 // Create the contenteditable element
411 let editor_el = document
412 .create_element("div")
413 .map_err(|e| JsError::new(&format!("Failed to create element: {:?}", e)))?;
414
415 editor_el
416 .set_attribute("id", &editor_id)
417 .map_err(|e| JsError::new(&format!("Failed to set id: {:?}", e)))?;
418 editor_el
419 .set_attribute("contenteditable", "true")
420 .map_err(|e| JsError::new(&format!("Failed to set contenteditable: {:?}", e)))?;
421 editor_el
422 .set_attribute("class", "weaver-editor-content")
423 .map_err(|e| JsError::new(&format!("Failed to set class: {:?}", e)))?;
424
425 container
426 .append_child(&editor_el)
427 .map_err(|e| JsError::new(&format!("Failed to append child: {:?}", e)))?;
428
429 self.editor_id = Some(editor_id);
430 self.on_change = on_change;
431
432 // Initial render
433 self.render_and_update_dom();
434
435 Ok(())
436 }
437
438 /// Check if the editor is mounted.
439 #[wasm_bindgen(js_name = isMounted)]
440 pub fn is_mounted(&self) -> bool {
441 self.editor_id.is_some()
442 }
443
444 /// Unmount the editor and clean up.
445 #[wasm_bindgen]
446 pub fn unmount(&mut self) {
447 if let Some(ref editor_id) = self.editor_id {
448 if let Some(window) = web_sys::window() {
449 if let Some(document) = window.document() {
450 if let Some(element) = document.get_element_by_id(editor_id) {
451 let _ = element.remove();
452 }
453 }
454 }
455 }
456 self.editor_id = None;
457 self.on_change = None;
458 }
459
460 /// Focus the editor.
461 #[wasm_bindgen]
462 pub fn focus(&self) {
463 if let Some(ref editor_id) = self.editor_id {
464 if let Some(window) = web_sys::window() {
465 if let Some(document) = window.document() {
466 if let Some(element) = document.get_element_by_id(editor_id) {
467 if let Ok(html_el) = element.dyn_into::<HtmlElement>() {
468 let _ = html_el.focus();
469 }
470 }
471 }
472 }
473 }
474 }
475
476 /// Blur the editor.
477 #[wasm_bindgen]
478 pub fn blur(&self) {
479 if let Some(ref editor_id) = self.editor_id {
480 if let Some(window) = web_sys::window() {
481 if let Some(document) = window.document() {
482 if let Some(element) = document.get_element_by_id(editor_id) {
483 if let Ok(html_el) = element.dyn_into::<HtmlElement>() {
484 let _ = html_el.blur();
485 }
486 }
487 }
488 }
489 }
490 }
491
492 // === Rendering ===
493
494 /// Get rendered paragraphs as JS objects.
495 ///
496 /// For use when host needs to inspect render state.
497 #[wasm_bindgen(js_name = getParagraphs)]
498 pub fn get_paragraphs(&self) -> Result<JsValue, JsError> {
499 let js_paras: Vec<JsParagraphRender> = self
500 .paragraphs
501 .iter()
502 .map(JsParagraphRender::from)
503 .collect();
504 serde_wasm_bindgen::to_value(&js_paras)
505 .map_err(|e| JsError::new(&format!("Serialization error: {}", e)))
506 }
507}
508
509impl Default for JsEditor {
510 fn default() -> Self {
511 Self::new()
512 }
513}
514
515// Internal methods (not exposed to JS)
516impl JsEditor {
517 /// Render the document and update the DOM.
518 pub fn render_and_update_dom(&mut self) {
519 let Some(ref editor_id) = self.editor_id else {
520 return;
521 };
522
523 let cursor_offset = self.doc.cursor_offset();
524 let last_edit = self.doc.last_edit();
525
526 // Render with incremental caching
527 let result = render_paragraphs_incremental(
528 self.doc.buffer(),
529 Some(&self.cache),
530 cursor_offset,
531 last_edit.as_ref(),
532 Some(&self.image_resolver),
533 Some(&self.entry_index),
534 &self.resolved_content,
535 );
536
537 let old_paragraphs = std::mem::replace(&mut self.paragraphs, result.paragraphs);
538 self.cache = result.cache;
539 self.doc.set_last_edit(None); // Clear after using
540
541 // Update DOM
542 let cursor_para_updated = update_paragraph_dom(
543 editor_id,
544 &old_paragraphs,
545 &self.paragraphs,
546 cursor_offset,
547 false,
548 );
549
550 // Update syntax visibility
551 let syntax_spans: Vec<_> = self
552 .paragraphs
553 .iter()
554 .flat_map(|p| p.syntax_spans.iter().cloned())
555 .collect();
556 update_syntax_visibility(cursor_offset, None, &syntax_spans, &self.paragraphs);
557
558 // Restore cursor position after DOM update
559 if cursor_para_updated {
560 let cursor = BrowserCursor::new(editor_id);
561 let snap_direction = self.doc.pending_snap();
562 let _ = cursor.restore_cursor(cursor_offset, &self.paragraphs, snap_direction);
563 }
564 }
565
566 /// Notify the onChange callback.
567 pub(crate) fn notify_change(&self) {
568 if let Some(ref callback) = self.on_change {
569 let this = JsValue::null();
570 let _ = callback.call0(&this);
571 }
572 }
573}
574
575impl JsEditor {
576 /// Build embeds from finalized images.
577 fn build_embeds(&self) -> Option<EntryEmbeds> {
578 if self.finalized_images.is_empty() {
579 return None;
580 }
581
582 use crate::types::{ImageEmbed, ImagesEmbed};
583
584 let images: Vec<ImageEmbed> = self
585 .finalized_images
586 .values()
587 .map(|f| ImageEmbed {
588 image: f.blob_ref.clone(),
589 alt: String::new(), // TODO: track alt text
590 aspect_ratio: None,
591 })
592 .collect();
593
594 Some(EntryEmbeds {
595 images: Some(ImagesEmbed { images }),
596 records: None,
597 externals: None,
598 videos: None,
599 })
600 }
601}
602
603/// Get current time as ISO string.
604fn now_iso() -> String {
605 // Use js_sys::Date for browser-compatible time
606 let date = js_sys::Date::new_0();
607 date.to_iso_string().into()
608}