editor js bindings scaffold

Orual cfdc9a2b 4c9f8b6f

+1164
+20
Cargo.lock
··· 12319 12319 ] 12320 12320 12321 12321 [[package]] 12322 + name = "weaver-editor-js" 12323 + version = "0.1.0" 12324 + dependencies = [ 12325 + "console_error_panic_hook", 12326 + "js-sys", 12327 + "serde", 12328 + "serde-wasm-bindgen 0.6.5", 12329 + "serde_bytes", 12330 + "tsify-next", 12331 + "wasm-bindgen", 12332 + "weaver-api", 12333 + "weaver-common", 12334 + "weaver-editor-browser", 12335 + "weaver-editor-core", 12336 + "weaver-editor-crdt", 12337 + "weaver-embed-worker", 12338 + "web-sys", 12339 + ] 12340 + 12341 + [[package]] 12322 12342 name = "weaver-embed-worker" 12323 12343 version = "0.1.0" 12324 12344 dependencies = [
+2
crates/weaver-editor-js/.gitignore
··· 1 + pkg/ 2 + target/
+57
crates/weaver-editor-js/Cargo.toml
··· 1 + [package] 2 + name = "weaver-editor-js" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + authors.workspace = true 7 + repository = "https://tangled.org/nonbinary.computer/weaver" 8 + description = "WASM bindings for the weaver markdown editor" 9 + 10 + [lib] 11 + crate-type = ["cdylib"] 12 + 13 + [features] 14 + default = [] 15 + collab = ["weaver-editor-crdt"] 16 + 17 + [dependencies] 18 + weaver-editor-core = { path = "../weaver-editor-core" } 19 + weaver-editor-browser = { path = "../weaver-editor-browser" } 20 + weaver-editor-crdt = { path = "../weaver-editor-crdt", optional = true } 21 + weaver-embed-worker = { path = "../weaver-embed-worker" } 22 + weaver-api = { path = "../weaver-api" } 23 + weaver-common = { path = "../weaver-common" } 24 + 25 + wasm-bindgen = "0.2" 26 + serde = { workspace = true } 27 + serde_bytes = "0.11" 28 + serde-wasm-bindgen = "0.6" 29 + tsify-next = "0.5" 30 + js-sys = "0.3" 31 + console_error_panic_hook = "0.1" 32 + 33 + [dependencies.web-sys] 34 + version = "0.3" 35 + features = [ 36 + "console", 37 + "Document", 38 + "Element", 39 + "HtmlElement", 40 + "HtmlDivElement", 41 + "Node", 42 + "Window", 43 + "Selection", 44 + "Range", 45 + "InputEvent", 46 + "KeyboardEvent", 47 + "ClipboardEvent", 48 + "CompositionEvent", 49 + "DataTransfer", 50 + "EventTarget", 51 + ] 52 + 53 + [package.metadata.wasm-pack.profile.dev] 54 + wasm-opt = false 55 + 56 + [package.metadata.wasm-pack.profile.release] 57 + wasm-opt = ['-Oz', '--enable-bulk-memory-opt', '--enable-nontrapping-float-to-int']
+290
crates/weaver-editor-js/build.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 + cd "$SCRIPT_DIR" 6 + 7 + PKG_NAME="@weaver.sh/editor" 8 + PKG_VERSION="0.1.0" 9 + 10 + # Targets to build 11 + TARGETS=(bundler web nodejs deno) 12 + 13 + COMMAND="${1:-build}" 14 + shift || true 15 + 16 + # Feature variants 17 + declare -A VARIANTS=( 18 + ["core"]="" 19 + ["collab"]="collab" 20 + ) 21 + 22 + build() { 23 + local target="$1" 24 + local variant="$2" 25 + local features="$3" 26 + local out_dir="pkg/${variant}/${target}" 27 + 28 + echo "Building ${variant}/${target}..." 29 + 30 + local feature_args="--no-default-features" 31 + if [[ -n "$features" ]]; then 32 + feature_args="$feature_args --features $features" 33 + fi 34 + 35 + wasm-pack build \ 36 + --out-name weaver_editor \ 37 + --out-dir "$out_dir" \ 38 + --target "$target" \ 39 + $feature_args 40 + 41 + # Report size 42 + local wasm_file="${out_dir}/weaver_editor_bg.wasm" 43 + if [[ -f "$wasm_file" ]]; then 44 + local size=$(ls -lh "$wasm_file" | awk '{print $5}') 45 + echo " → ${size}" 46 + fi 47 + } 48 + 49 + generate_package_json() { 50 + local variant="$1" 51 + local out_dir="pkg/${variant}" 52 + local pkg_suffix="" 53 + local description="" 54 + 55 + if [[ "$variant" == "collab" ]]; then 56 + pkg_suffix="-collab" 57 + description="Weaver markdown editor with collaborative editing (Loro CRDT + iroh P2P)" 58 + else 59 + pkg_suffix="-core" 60 + description="Weaver markdown editor (local editing, lightweight)" 61 + fi 62 + 63 + cat > "${out_dir}/package.json" << EOF 64 + { 65 + "name": "${PKG_NAME}${pkg_suffix}", 66 + "version": "${PKG_VERSION}", 67 + "description": "${description}", 68 + "license": "MPL-2.0", 69 + "repository": { 70 + "type": "git", 71 + "url": "https://tangled.org/nonbinary.computer/weaver" 72 + }, 73 + "keywords": ["atproto", "markdown", "editor", "wasm", "weaver"], 74 + "main": "nodejs/weaver_editor.js", 75 + "module": "bundler/weaver_editor.js", 76 + "browser": "web/weaver_editor.js", 77 + "types": "bundler/weaver_editor.d.ts", 78 + "exports": { 79 + ".": { 80 + "deno": "./deno/weaver_editor.js", 81 + "node": { 82 + "import": "./nodejs/weaver_editor.js", 83 + "require": "./nodejs/weaver_editor.js" 84 + }, 85 + "browser": { 86 + "import": "./web/weaver_editor.js" 87 + }, 88 + "default": "./bundler/weaver_editor.js" 89 + }, 90 + "./bundler": { 91 + "import": "./bundler/weaver_editor.js", 92 + "types": "./bundler/weaver_editor.d.ts" 93 + }, 94 + "./web": { 95 + "import": "./web/weaver_editor.js", 96 + "types": "./web/weaver_editor.d.ts" 97 + }, 98 + "./nodejs": { 99 + "import": "./nodejs/weaver_editor.js", 100 + "require": "./nodejs/weaver_editor.js", 101 + "types": "./nodejs/weaver_editor.d.ts" 102 + }, 103 + "./deno": { 104 + "import": "./deno/weaver_editor.js", 105 + "types": "./deno/weaver_editor.d.ts" 106 + } 107 + }, 108 + "files": [ 109 + "bundler/", 110 + "web/", 111 + "nodejs/", 112 + "deno/", 113 + "README.md" 114 + ] 115 + } 116 + EOF 117 + } 118 + 119 + generate_readme() { 120 + local variant="$1" 121 + local out_dir="pkg/${variant}" 122 + 123 + cat > "${out_dir}/README.md" << 'EOF' 124 + # @weaver.sh/editor 125 + 126 + WASM-based markdown editor for the weaver.sh ecosystem. 127 + 128 + ## Installation 129 + 130 + ```bash 131 + npm install @weaver.sh/editor-core # Local editing only 132 + npm install @weaver.sh/editor-collab # With collaborative editing 133 + ``` 134 + 135 + ## Usage 136 + 137 + ### With a bundler (webpack, vite, etc.) 138 + 139 + ```javascript 140 + import init, { JsEditor } from '@weaver.sh/editor-core'; 141 + 142 + await init(); 143 + 144 + const editor = JsEditor.fromMarkdown('# Hello\n\nWorld'); 145 + console.log(editor.getMarkdown()); 146 + ``` 147 + 148 + ### Direct browser usage (no bundler) 149 + 150 + ```html 151 + <script type="module"> 152 + import init, { JsEditor } from '@weaver.sh/editor-core/web'; 153 + await init(); 154 + // ... 155 + </script> 156 + ``` 157 + 158 + ### Node.js 159 + 160 + ```javascript 161 + const { JsEditor } = require('@weaver.sh/editor-core/nodejs'); 162 + ``` 163 + 164 + ## API 165 + 166 + See the TypeScript definitions for full API documentation. 167 + 168 + ### Core 169 + 170 + - `JsEditor.new()` - Create empty editor 171 + - `JsEditor.fromMarkdown(content)` - Create from markdown 172 + - `JsEditor.fromSnapshot(entry)` - Create from EntryJson snapshot 173 + - `editor.getMarkdown()` - Get markdown content 174 + - `editor.getSnapshot()` - Get EntryJson for drafts 175 + - `editor.toEntry()` - Get validated EntryJson for publishing 176 + - `editor.executeAction(action)` - Execute an EditorAction 177 + - `editor.setTitle(title)` / `editor.setPath(path)` / `editor.setTags(tags)` 178 + 179 + ### Images 180 + 181 + - `editor.addPendingImage(image)` - Track pending upload 182 + - `editor.finalizeImage(localId, finalized)` - Mark upload complete 183 + - `editor.getPendingImages()` - Get images awaiting upload 184 + - `editor.getStagingUris()` - Get staging record URIs for cleanup 185 + 186 + ### Collab (editor-collab only) 187 + 188 + - `JsCollabEditor` - Collaborative editor with Loro CRDT 189 + - `editor.exportUpdates()` / `editor.importUpdates(bytes)` 190 + - `editor.addPeer(nodeId)` / `editor.removePeer(nodeId)` 191 + EOF 192 + } 193 + 194 + do_build() { 195 + # Clean previous builds 196 + rm -rf pkg 197 + 198 + # Build all combinations 199 + for variant in "${!VARIANTS[@]}"; do 200 + features="${VARIANTS[$variant]}" 201 + 202 + for target in "${TARGETS[@]}"; do 203 + build "$target" "$variant" "$features" 204 + done 205 + 206 + generate_package_json "$variant" 207 + generate_readme "$variant" 208 + 209 + # Clean up wasm-pack artifacts we don't need 210 + find "pkg/${variant}" -name ".gitignore" -delete 211 + find "pkg/${variant}" -name "package.json" -path "*/bundler/*" -delete 212 + find "pkg/${variant}" -name "package.json" -path "*/web/*" -delete 213 + find "pkg/${variant}" -name "package.json" -path "*/nodejs/*" -delete 214 + find "pkg/${variant}" -name "package.json" -path "*/deno/*" -delete 215 + done 216 + 217 + echo "" 218 + echo "Build complete!" 219 + echo "" 220 + ls -lh pkg/core/web/*.wasm pkg/collab/web/*.wasm 2>/dev/null || true 221 + echo "" 222 + echo "Packages:" 223 + echo " pkg/core/ - @weaver.sh/editor-core (local editing)" 224 + echo " pkg/collab/ - @weaver.sh/editor-collab (with CRDT collab)" 225 + } 226 + 227 + do_pack() { 228 + echo "Packing..." 229 + for variant in "${!VARIANTS[@]}"; do 230 + echo " ${variant}..." 231 + (cd "pkg/${variant}" && npm pack) 232 + done 233 + echo "" 234 + echo "Tarballs created:" 235 + ls -lh pkg/*/*.tgz 2>/dev/null || true 236 + } 237 + 238 + do_publish() { 239 + local tag="${1:-}" 240 + local tag_arg="" 241 + if [[ -n "$tag" ]]; then 242 + tag_arg="--tag $tag" 243 + fi 244 + 245 + echo "Publishing..." 246 + for variant in "${!VARIANTS[@]}"; do 247 + echo " ${variant}..." 248 + (cd "pkg/${variant}" && npm publish --access public $tag_arg) 249 + done 250 + echo "" 251 + echo "Published!" 252 + } 253 + 254 + usage() { 255 + echo "Usage: $0 [command]" 256 + echo "" 257 + echo "Commands:" 258 + echo " build Build all variants and targets (default)" 259 + echo " pack Create npm tarballs" 260 + echo " publish Publish to npm registry" 261 + echo " all Build, pack, and publish" 262 + echo "" 263 + echo "Options for publish:" 264 + echo " --tag <tag> Publish with a specific tag (e.g., 'next', 'beta')" 265 + } 266 + 267 + case "$COMMAND" in 268 + build) 269 + do_build 270 + ;; 271 + pack) 272 + do_pack 273 + ;; 274 + publish) 275 + do_publish "$@" 276 + ;; 277 + all) 278 + do_build 279 + do_pack 280 + do_publish "$@" 281 + ;; 282 + -h|--help|help) 283 + usage 284 + ;; 285 + *) 286 + echo "Unknown command: $COMMAND" 287 + usage 288 + exit 1 289 + ;; 290 + esac
+153
crates/weaver-editor-js/src/actions.rs
··· 1 + //! EditorAction conversion for JavaScript. 2 + 3 + use serde::{Deserialize, Serialize}; 4 + use tsify_next::Tsify; 5 + use wasm_bindgen::prelude::*; 6 + use weaver_editor_core::{EditorAction, FormatAction, Range}; 7 + 8 + /// JavaScript-friendly editor action. 9 + /// 10 + /// Mirrors EditorAction from core but with JS-compatible types. 11 + /// Also includes FormatAction variants for extended formatting. 12 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 13 + #[tsify(into_wasm_abi, from_wasm_abi)] 14 + #[serde(tag = "type", rename_all = "camelCase")] 15 + pub enum JsEditorAction { 16 + // Text insertion 17 + Insert { text: String, start: usize, end: usize }, 18 + InsertLineBreak { start: usize, end: usize }, 19 + InsertParagraph { start: usize, end: usize }, 20 + 21 + // Deletion 22 + DeleteBackward { start: usize, end: usize }, 23 + DeleteForward { start: usize, end: usize }, 24 + DeleteWordBackward { start: usize, end: usize }, 25 + DeleteWordForward { start: usize, end: usize }, 26 + DeleteToLineStart { start: usize, end: usize }, 27 + DeleteToLineEnd { start: usize, end: usize }, 28 + DeleteSoftLineBackward { start: usize, end: usize }, 29 + DeleteSoftLineForward { start: usize, end: usize }, 30 + 31 + // History 32 + Undo, 33 + Redo, 34 + 35 + // Inline formatting (EditorAction variants) 36 + ToggleBold, 37 + ToggleItalic, 38 + ToggleCode, 39 + ToggleStrikethrough, 40 + InsertLink, 41 + 42 + // Extended formatting (FormatAction variants) 43 + InsertImage, 44 + InsertHeading { level: u8 }, 45 + ToggleBulletList, 46 + ToggleNumberedList, 47 + ToggleQuote, 48 + 49 + // Clipboard 50 + Cut, 51 + Copy, 52 + Paste { start: usize, end: usize }, 53 + CopyAsHtml, 54 + 55 + // Selection 56 + SelectAll, 57 + 58 + // Cursor 59 + MoveCursor { offset: usize }, 60 + ExtendSelection { offset: usize }, 61 + } 62 + 63 + /// Result of converting JsEditorAction. 64 + pub enum ActionKind { 65 + /// Standard EditorAction. 66 + Editor(EditorAction), 67 + /// FormatAction (needs apply_formatting). 68 + Format(FormatAction), 69 + } 70 + 71 + impl JsEditorAction { 72 + /// Convert to ActionKind (either EditorAction or FormatAction). 73 + pub fn to_action_kind(&self) -> ActionKind { 74 + match self { 75 + // Text insertion 76 + Self::Insert { text, start, end } => ActionKind::Editor(EditorAction::Insert { 77 + text: text.clone(), 78 + range: Range::new(*start, *end), 79 + }), 80 + Self::InsertLineBreak { start, end } => ActionKind::Editor(EditorAction::InsertLineBreak { 81 + range: Range::new(*start, *end), 82 + }), 83 + Self::InsertParagraph { start, end } => ActionKind::Editor(EditorAction::InsertParagraph { 84 + range: Range::new(*start, *end), 85 + }), 86 + 87 + // Deletion 88 + Self::DeleteBackward { start, end } => ActionKind::Editor(EditorAction::DeleteBackward { 89 + range: Range::new(*start, *end), 90 + }), 91 + Self::DeleteForward { start, end } => ActionKind::Editor(EditorAction::DeleteForward { 92 + range: Range::new(*start, *end), 93 + }), 94 + Self::DeleteWordBackward { start, end } => ActionKind::Editor(EditorAction::DeleteWordBackward { 95 + range: Range::new(*start, *end), 96 + }), 97 + Self::DeleteWordForward { start, end } => ActionKind::Editor(EditorAction::DeleteWordForward { 98 + range: Range::new(*start, *end), 99 + }), 100 + Self::DeleteToLineStart { start, end } => ActionKind::Editor(EditorAction::DeleteToLineStart { 101 + range: Range::new(*start, *end), 102 + }), 103 + Self::DeleteToLineEnd { start, end } => ActionKind::Editor(EditorAction::DeleteToLineEnd { 104 + range: Range::new(*start, *end), 105 + }), 106 + Self::DeleteSoftLineBackward { start, end } => ActionKind::Editor(EditorAction::DeleteSoftLineBackward { 107 + range: Range::new(*start, *end), 108 + }), 109 + Self::DeleteSoftLineForward { start, end } => ActionKind::Editor(EditorAction::DeleteSoftLineForward { 110 + range: Range::new(*start, *end), 111 + }), 112 + 113 + // History 114 + Self::Undo => ActionKind::Editor(EditorAction::Undo), 115 + Self::Redo => ActionKind::Editor(EditorAction::Redo), 116 + 117 + // Inline formatting (EditorAction) 118 + Self::ToggleBold => ActionKind::Editor(EditorAction::ToggleBold), 119 + Self::ToggleItalic => ActionKind::Editor(EditorAction::ToggleItalic), 120 + Self::ToggleCode => ActionKind::Editor(EditorAction::ToggleCode), 121 + Self::ToggleStrikethrough => ActionKind::Editor(EditorAction::ToggleStrikethrough), 122 + Self::InsertLink => ActionKind::Editor(EditorAction::InsertLink), 123 + 124 + // Extended formatting (FormatAction) 125 + Self::InsertImage => ActionKind::Format(FormatAction::Image), 126 + Self::InsertHeading { level } => ActionKind::Format(FormatAction::Heading(*level)), 127 + Self::ToggleBulletList => ActionKind::Format(FormatAction::BulletList), 128 + Self::ToggleNumberedList => ActionKind::Format(FormatAction::NumberedList), 129 + Self::ToggleQuote => ActionKind::Format(FormatAction::Quote), 130 + 131 + // Clipboard 132 + Self::Cut => ActionKind::Editor(EditorAction::Cut), 133 + Self::Copy => ActionKind::Editor(EditorAction::Copy), 134 + Self::Paste { start, end } => ActionKind::Editor(EditorAction::Paste { 135 + range: Range::new(*start, *end), 136 + }), 137 + Self::CopyAsHtml => ActionKind::Editor(EditorAction::CopyAsHtml), 138 + 139 + // Selection 140 + Self::SelectAll => ActionKind::Editor(EditorAction::SelectAll), 141 + 142 + // Cursor 143 + Self::MoveCursor { offset } => ActionKind::Editor(EditorAction::MoveCursor { offset: *offset }), 144 + Self::ExtendSelection { offset } => ActionKind::Editor(EditorAction::ExtendSelection { offset: *offset }), 145 + } 146 + } 147 + } 148 + 149 + /// Parse a JsValue into JsEditorAction. 150 + pub fn parse_action(value: JsValue) -> Result<JsEditorAction, JsError> { 151 + serde_wasm_bindgen::from_value(value) 152 + .map_err(|e| JsError::new(&format!("Invalid action: {}", e))) 153 + }
+43
crates/weaver-editor-js/src/collab.rs
··· 1 + //! JsCollabEditor - collaborative editor with Loro CRDT. 2 + //! 3 + //! Only available with the `collab` feature. 4 + 5 + use wasm_bindgen::prelude::*; 6 + 7 + use weaver_editor_crdt::LoroTextBuffer; 8 + 9 + /// Collaborative editor with CRDT sync. 10 + /// 11 + /// Wraps LoroTextBuffer for collaborative editing with iroh P2P transport. 12 + #[wasm_bindgen] 13 + pub struct JsCollabEditor { 14 + // TODO: Implement collab editor 15 + // - LoroTextBuffer for CRDT-backed text 16 + // - iroh node for P2P transport 17 + // - Session management callbacks 18 + _marker: std::marker::PhantomData<LoroTextBuffer>, 19 + } 20 + 21 + #[wasm_bindgen] 22 + impl JsCollabEditor { 23 + /// Create a new collaborative editor. 24 + #[wasm_bindgen(constructor)] 25 + pub fn new() -> Result<JsCollabEditor, JsError> { 26 + Err(JsError::new("CollabEditor not yet implemented")) 27 + } 28 + } 29 + 30 + impl Default for JsCollabEditor { 31 + fn default() -> Self { 32 + Self { 33 + _marker: std::marker::PhantomData, 34 + } 35 + } 36 + } 37 + 38 + // TODO: Implement these when ready: 39 + // - from_snapshot / from_loro_doc 40 + // - export_updates / import_updates 41 + // - get_version 42 + // - add_peer / remove_peer / get_connected_peers 43 + // - Session callbacks (onSessionNeeded, onSessionRefresh, onSessionEnd, onPeersNeeded)
+344
crates/weaver-editor-js/src/editor.rs
··· 1 + //! JsEditor - the main editor wrapper for JavaScript. 2 + 3 + use std::collections::HashMap; 4 + 5 + use wasm_bindgen::prelude::*; 6 + use web_sys::HtmlElement; 7 + 8 + use weaver_editor_browser::BrowserClipboard; 9 + use weaver_editor_core::{ 10 + EditorDocument, EditorRope, PlainEditor, RenderCache, UndoableBuffer, apply_formatting, 11 + execute_action_with_clipboard, 12 + }; 13 + 14 + use crate::actions::{ActionKind, parse_action}; 15 + use crate::types::{EntryEmbeds, EntryJson, FinalizedImage, JsResolvedContent, PendingImage}; 16 + 17 + type InnerEditor = PlainEditor<UndoableBuffer<EditorRope>>; 18 + 19 + /// The main editor instance exposed to JavaScript. 20 + /// 21 + /// Wraps the core editor with WASM bindings for browser use. 22 + #[wasm_bindgen] 23 + pub struct JsEditor { 24 + doc: InnerEditor, 25 + cache: RenderCache, 26 + resolved_content: weaver_common::ResolvedContent, 27 + 28 + // Metadata 29 + title: String, 30 + path: String, 31 + tags: Vec<String>, 32 + created_at: String, 33 + 34 + // Image tracking 35 + pending_images: HashMap<String, PendingImage>, 36 + finalized_images: HashMap<String, FinalizedImage>, 37 + } 38 + 39 + #[wasm_bindgen] 40 + impl JsEditor { 41 + /// Create a new empty editor. 42 + #[wasm_bindgen(constructor)] 43 + pub fn new() -> Self { 44 + let rope = EditorRope::new(); 45 + let buffer = UndoableBuffer::new(rope, 100); 46 + let doc = PlainEditor::new(buffer); 47 + 48 + Self { 49 + doc, 50 + cache: RenderCache::default(), 51 + resolved_content: weaver_common::ResolvedContent::new(), 52 + title: String::new(), 53 + path: String::new(), 54 + tags: Vec::new(), 55 + created_at: now_iso(), 56 + pending_images: HashMap::new(), 57 + finalized_images: HashMap::new(), 58 + } 59 + } 60 + 61 + /// Create an editor from markdown content. 62 + #[wasm_bindgen(js_name = fromMarkdown)] 63 + pub fn from_markdown(content: &str) -> Self { 64 + let rope = EditorRope::from_str(content); 65 + let buffer = UndoableBuffer::new(rope, 100); 66 + let doc = PlainEditor::new(buffer); 67 + 68 + Self { 69 + doc, 70 + cache: RenderCache::default(), 71 + resolved_content: weaver_common::ResolvedContent::new(), 72 + title: String::new(), 73 + path: String::new(), 74 + tags: Vec::new(), 75 + created_at: now_iso(), 76 + pending_images: HashMap::new(), 77 + finalized_images: HashMap::new(), 78 + } 79 + } 80 + 81 + /// Create an editor from a snapshot (EntryJson). 82 + #[wasm_bindgen(js_name = fromSnapshot)] 83 + pub fn from_snapshot(snapshot: JsValue) -> Result<JsEditor, JsError> { 84 + let entry: EntryJson = serde_wasm_bindgen::from_value(snapshot) 85 + .map_err(|e| JsError::new(&format!("Invalid snapshot: {}", e)))?; 86 + 87 + let rope = EditorRope::from_str(&entry.content); 88 + let buffer = UndoableBuffer::new(rope, 100); 89 + let doc = PlainEditor::new(buffer); 90 + 91 + Ok(Self { 92 + doc, 93 + cache: RenderCache::default(), 94 + resolved_content: weaver_common::ResolvedContent::new(), 95 + title: entry.title, 96 + path: entry.path, 97 + tags: entry.tags.unwrap_or_default(), 98 + created_at: entry.created_at, 99 + pending_images: HashMap::new(), 100 + finalized_images: HashMap::new(), 101 + }) 102 + } 103 + 104 + /// Set pre-resolved embed content. 105 + #[wasm_bindgen(js_name = setResolvedContent)] 106 + pub fn set_resolved_content(&mut self, content: JsResolvedContent) { 107 + self.resolved_content = content.into_inner(); 108 + } 109 + 110 + // === Content access === 111 + 112 + /// Get the markdown content. 113 + #[wasm_bindgen(js_name = getMarkdown)] 114 + pub fn get_markdown(&self) -> String { 115 + self.doc.content_string() 116 + } 117 + 118 + /// Get the current state as a snapshot (EntryJson). 119 + #[wasm_bindgen(js_name = getSnapshot)] 120 + pub fn get_snapshot(&self) -> Result<JsValue, JsError> { 121 + let entry = EntryJson { 122 + title: self.title.clone(), 123 + path: self.path.clone(), 124 + content: self.doc.content_string(), 125 + created_at: self.created_at.clone(), 126 + updated_at: Some(now_iso()), 127 + tags: if self.tags.is_empty() { 128 + None 129 + } else { 130 + Some(self.tags.clone()) 131 + }, 132 + embeds: self.build_embeds(), 133 + authors: None, 134 + content_warnings: None, 135 + rating: None, 136 + }; 137 + 138 + serde_wasm_bindgen::to_value(&entry) 139 + .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 140 + } 141 + 142 + /// Get the entry JSON, validating required fields. 143 + /// 144 + /// Throws if title or path is empty, or if there are pending images. 145 + #[wasm_bindgen(js_name = toEntry)] 146 + pub fn to_entry(&self) -> Result<JsValue, JsError> { 147 + if self.title.is_empty() { 148 + return Err(JsError::new("Title is required")); 149 + } 150 + if self.path.is_empty() { 151 + return Err(JsError::new("Path is required")); 152 + } 153 + if !self.pending_images.is_empty() { 154 + return Err(JsError::new( 155 + "Pending images must be finalized before publishing", 156 + )); 157 + } 158 + 159 + self.get_snapshot() 160 + } 161 + 162 + // === Metadata === 163 + 164 + /// Get the title. 165 + #[wasm_bindgen(js_name = getTitle)] 166 + pub fn get_title(&self) -> String { 167 + self.title.clone() 168 + } 169 + 170 + /// Set the title. 171 + #[wasm_bindgen(js_name = setTitle)] 172 + pub fn set_title(&mut self, title: &str) { 173 + self.title = title.to_string(); 174 + } 175 + 176 + /// Get the path. 177 + #[wasm_bindgen(js_name = getPath)] 178 + pub fn get_path(&self) -> String { 179 + self.path.clone() 180 + } 181 + 182 + /// Set the path. 183 + #[wasm_bindgen(js_name = setPath)] 184 + pub fn set_path(&mut self, path: &str) { 185 + self.path = path.to_string(); 186 + } 187 + 188 + /// Get the tags. 189 + #[wasm_bindgen(js_name = getTags)] 190 + pub fn get_tags(&self) -> Vec<String> { 191 + self.tags.clone() 192 + } 193 + 194 + /// Set the tags. 195 + #[wasm_bindgen(js_name = setTags)] 196 + pub fn set_tags(&mut self, tags: Vec<String>) { 197 + self.tags = tags; 198 + } 199 + 200 + // === Actions === 201 + 202 + /// Execute an editor action. 203 + #[wasm_bindgen(js_name = executeAction)] 204 + pub fn execute_action(&mut self, action: JsValue) -> Result<(), JsError> { 205 + let js_action = parse_action(action)?; 206 + let kind = js_action.to_action_kind(); 207 + 208 + let clipboard = BrowserClipboard::empty(); 209 + match kind { 210 + ActionKind::Editor(editor_action) => { 211 + execute_action_with_clipboard(&mut self.doc, &editor_action, &clipboard); 212 + } 213 + ActionKind::Format(format_action) => { 214 + apply_formatting(&mut self.doc, format_action); 215 + } 216 + } 217 + 218 + Ok(()) 219 + } 220 + 221 + // === Image handling === 222 + 223 + /// Add a pending image (called when user adds an image). 224 + #[wasm_bindgen(js_name = addPendingImage)] 225 + pub fn add_pending_image(&mut self, image: JsValue) -> Result<(), JsError> { 226 + let pending: PendingImage = serde_wasm_bindgen::from_value(image) 227 + .map_err(|e| JsError::new(&format!("Invalid pending image: {}", e)))?; 228 + 229 + self.pending_images 230 + .insert(pending.local_id.clone(), pending); 231 + Ok(()) 232 + } 233 + 234 + /// Finalize an image after upload. 235 + #[wasm_bindgen(js_name = finalizeImage)] 236 + pub fn finalize_image(&mut self, local_id: &str, finalized: JsValue) -> Result<(), JsError> { 237 + let finalized: FinalizedImage = serde_wasm_bindgen::from_value(finalized) 238 + .map_err(|e| JsError::new(&format!("Invalid finalized image: {}", e)))?; 239 + 240 + self.pending_images.remove(local_id); 241 + self.finalized_images 242 + .insert(local_id.to_string(), finalized); 243 + Ok(()) 244 + } 245 + 246 + /// Remove a pending image. 247 + #[wasm_bindgen(js_name = removeImage)] 248 + pub fn remove_image(&mut self, local_id: &str) { 249 + self.pending_images.remove(local_id); 250 + self.finalized_images.remove(local_id); 251 + } 252 + 253 + /// Get pending images that need upload. 254 + #[wasm_bindgen(js_name = getPendingImages)] 255 + pub fn get_pending_images(&self) -> Result<JsValue, JsError> { 256 + let pending: Vec<_> = self.pending_images.values().cloned().collect(); 257 + serde_wasm_bindgen::to_value(&pending) 258 + .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 259 + } 260 + 261 + /// Get staging URIs for cleanup after publish. 262 + #[wasm_bindgen(js_name = getStagingUris)] 263 + pub fn get_staging_uris(&self) -> Vec<String> { 264 + self.finalized_images 265 + .values() 266 + .map(|f| f.staging_uri.clone()) 267 + .collect() 268 + } 269 + 270 + // === Cursor/selection === 271 + 272 + /// Get the current cursor offset. 273 + #[wasm_bindgen(js_name = getCursorOffset)] 274 + pub fn get_cursor_offset(&self) -> usize { 275 + self.doc.cursor_offset() 276 + } 277 + 278 + /// Set the cursor offset. 279 + #[wasm_bindgen(js_name = setCursorOffset)] 280 + pub fn set_cursor_offset(&mut self, offset: usize) { 281 + self.doc.set_cursor_offset(offset); 282 + } 283 + 284 + /// Get the document length in characters. 285 + #[wasm_bindgen(js_name = getLength)] 286 + pub fn get_length(&self) -> usize { 287 + self.doc.len_chars() 288 + } 289 + 290 + // === Undo/redo === 291 + 292 + /// Check if undo is available. 293 + #[wasm_bindgen(js_name = canUndo)] 294 + pub fn can_undo(&self) -> bool { 295 + self.doc.can_undo() 296 + } 297 + 298 + /// Check if redo is available. 299 + #[wasm_bindgen(js_name = canRedo)] 300 + pub fn can_redo(&self) -> bool { 301 + self.doc.can_redo() 302 + } 303 + } 304 + 305 + impl Default for JsEditor { 306 + fn default() -> Self { 307 + Self::new() 308 + } 309 + } 310 + 311 + impl JsEditor { 312 + /// Build embeds from finalized images. 313 + fn build_embeds(&self) -> Option<EntryEmbeds> { 314 + if self.finalized_images.is_empty() { 315 + return None; 316 + } 317 + 318 + use crate::types::{ImageEmbed, ImagesEmbed}; 319 + 320 + let images: Vec<ImageEmbed> = self 321 + .finalized_images 322 + .values() 323 + .map(|f| ImageEmbed { 324 + image: f.blob_ref.clone(), 325 + alt: String::new(), // TODO: track alt text 326 + aspect_ratio: None, 327 + }) 328 + .collect(); 329 + 330 + Some(EntryEmbeds { 331 + images: Some(ImagesEmbed { images }), 332 + records: None, 333 + externals: None, 334 + videos: None, 335 + }) 336 + } 337 + } 338 + 339 + /// Get current time as ISO string. 340 + fn now_iso() -> String { 341 + // Use js_sys::Date for browser-compatible time 342 + let date = js_sys::Date::new_0(); 343 + date.to_iso_string().into() 344 + }
+30
crates/weaver-editor-js/src/lib.rs
··· 1 + //! WASM bindings for the weaver markdown editor. 2 + //! 3 + //! Provides embeddable editor components for JavaScript/TypeScript apps. 4 + //! 5 + //! # Features 6 + //! 7 + //! - `collab`: Enable collaborative editing via Loro CRDT + iroh P2P 8 + //! - `syntax-highlighting`: Enable syntax highlighting for code blocks 9 + 10 + mod actions; 11 + mod editor; 12 + mod types; 13 + 14 + #[cfg(feature = "collab")] 15 + mod collab; 16 + 17 + pub use actions::*; 18 + pub use editor::*; 19 + pub use types::*; 20 + 21 + #[cfg(feature = "collab")] 22 + pub use collab::*; 23 + 24 + use wasm_bindgen::prelude::*; 25 + 26 + /// Initialize panic hook for better error messages in console. 27 + #[wasm_bindgen(start)] 28 + pub fn init() { 29 + console_error_panic_hook::set_once(); 30 + }
+225
crates/weaver-editor-js/src/types.rs
··· 1 + //! Types exposed to JavaScript via wasm-bindgen. 2 + 3 + use serde::{Deserialize, Serialize}; 4 + use tsify_next::Tsify; 5 + use wasm_bindgen::prelude::*; 6 + 7 + /// Pending image waiting for upload. 8 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 9 + #[tsify(into_wasm_abi, from_wasm_abi)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct PendingImage { 12 + pub local_id: String, 13 + #[tsify(type = "Uint8Array")] 14 + #[serde(with = "serde_bytes")] 15 + pub data: Vec<u8>, 16 + pub mime_type: String, 17 + pub name: String, 18 + } 19 + 20 + /// Finalized image with blob ref and staging URI. 21 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 22 + #[tsify(into_wasm_abi, from_wasm_abi)] 23 + #[serde(rename_all = "camelCase")] 24 + pub struct FinalizedImage { 25 + pub blob_ref: JsBlobRef, 26 + /// AT URI of the staging record (sh.weaver.publish.blob). 27 + pub staging_uri: String, 28 + } 29 + 30 + /// Blob reference matching AT Protocol blob format. 31 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 32 + #[tsify(into_wasm_abi, from_wasm_abi)] 33 + #[serde(rename_all = "camelCase")] 34 + pub struct JsBlobRef { 35 + #[serde(rename = "$type")] 36 + pub type_marker: String, // "blob" 37 + pub r#ref: BlobLink, 38 + pub mime_type: String, 39 + pub size: u64, 40 + } 41 + 42 + /// CID link for blob. 43 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 44 + #[tsify(into_wasm_abi, from_wasm_abi)] 45 + pub struct BlobLink { 46 + #[serde(rename = "$link")] 47 + pub link: String, 48 + } 49 + 50 + /// Entry JSON matching sh.weaver.notebook.entry lexicon. 51 + /// 52 + /// Used for snapshots (drafts) and final entry output. 53 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 54 + #[tsify(into_wasm_abi, from_wasm_abi)] 55 + #[serde(rename_all = "camelCase")] 56 + pub struct EntryJson { 57 + pub title: String, 58 + pub path: String, 59 + pub content: String, 60 + pub created_at: String, 61 + #[serde(skip_serializing_if = "Option::is_none")] 62 + pub updated_at: Option<String>, 63 + #[serde(skip_serializing_if = "Option::is_none")] 64 + pub tags: Option<Vec<String>>, 65 + #[serde(skip_serializing_if = "Option::is_none")] 66 + pub embeds: Option<EntryEmbeds>, 67 + #[serde(skip_serializing_if = "Option::is_none")] 68 + pub authors: Option<Vec<Author>>, 69 + #[serde(skip_serializing_if = "Option::is_none")] 70 + pub content_warnings: Option<Vec<String>>, 71 + #[serde(skip_serializing_if = "Option::is_none")] 72 + pub rating: Option<String>, 73 + } 74 + 75 + /// Entry embeds container. 76 + #[derive(Debug, Clone, Default, Serialize, Deserialize, Tsify)] 77 + #[tsify(into_wasm_abi, from_wasm_abi)] 78 + #[serde(rename_all = "camelCase")] 79 + pub struct EntryEmbeds { 80 + #[serde(skip_serializing_if = "Option::is_none")] 81 + pub images: Option<ImagesEmbed>, 82 + #[serde(skip_serializing_if = "Option::is_none")] 83 + pub records: Option<RecordsEmbed>, 84 + #[serde(skip_serializing_if = "Option::is_none")] 85 + pub externals: Option<ExternalsEmbed>, 86 + #[serde(skip_serializing_if = "Option::is_none")] 87 + pub videos: Option<VideosEmbed>, 88 + } 89 + 90 + /// Image embed container. 91 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 92 + #[tsify(into_wasm_abi, from_wasm_abi)] 93 + pub struct ImagesEmbed { 94 + pub images: Vec<ImageEmbed>, 95 + } 96 + 97 + /// Single image embed. 98 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 99 + #[tsify(into_wasm_abi, from_wasm_abi)] 100 + #[serde(rename_all = "camelCase")] 101 + pub struct ImageEmbed { 102 + pub image: JsBlobRef, 103 + pub alt: String, 104 + #[serde(skip_serializing_if = "Option::is_none")] 105 + pub aspect_ratio: Option<AspectRatio>, 106 + } 107 + 108 + /// Aspect ratio for images/videos. 109 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 110 + #[tsify(into_wasm_abi, from_wasm_abi)] 111 + pub struct AspectRatio { 112 + pub width: u32, 113 + pub height: u32, 114 + } 115 + 116 + /// Record embed container. 117 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 118 + #[tsify(into_wasm_abi, from_wasm_abi)] 119 + pub struct RecordsEmbed { 120 + pub records: Vec<RecordEmbed>, 121 + } 122 + 123 + /// Single record embed (strong ref). 124 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 125 + #[tsify(into_wasm_abi, from_wasm_abi)] 126 + pub struct RecordEmbed { 127 + pub uri: String, 128 + pub cid: String, 129 + } 130 + 131 + /// External link embed container. 132 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 133 + #[tsify(into_wasm_abi, from_wasm_abi)] 134 + pub struct ExternalsEmbed { 135 + pub externals: Vec<ExternalEmbed>, 136 + } 137 + 138 + /// Single external link embed. 139 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 140 + #[tsify(into_wasm_abi, from_wasm_abi)] 141 + pub struct ExternalEmbed { 142 + pub uri: String, 143 + pub title: String, 144 + pub description: String, 145 + #[serde(skip_serializing_if = "Option::is_none")] 146 + pub thumb: Option<JsBlobRef>, 147 + } 148 + 149 + /// Video embed container. 150 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 151 + #[tsify(into_wasm_abi, from_wasm_abi)] 152 + pub struct VideosEmbed { 153 + pub videos: Vec<VideoEmbed>, 154 + } 155 + 156 + /// Single video embed. 157 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 158 + #[tsify(into_wasm_abi, from_wasm_abi)] 159 + #[serde(rename_all = "camelCase")] 160 + pub struct VideoEmbed { 161 + pub video: JsBlobRef, 162 + #[serde(skip_serializing_if = "Option::is_none")] 163 + pub alt: Option<String>, 164 + #[serde(skip_serializing_if = "Option::is_none")] 165 + pub aspect_ratio: Option<AspectRatio>, 166 + } 167 + 168 + /// Author reference. 169 + #[derive(Debug, Clone, Serialize, Deserialize, Tsify)] 170 + #[tsify(into_wasm_abi, from_wasm_abi)] 171 + pub struct Author { 172 + pub did: String, 173 + } 174 + 175 + /// Pre-rendered embed content for initial load. 176 + #[wasm_bindgen] 177 + pub struct JsResolvedContent { 178 + inner: weaver_common::ResolvedContent, 179 + } 180 + 181 + #[wasm_bindgen] 182 + impl JsResolvedContent { 183 + #[wasm_bindgen(constructor)] 184 + pub fn new() -> Self { 185 + Self { 186 + inner: weaver_common::ResolvedContent::new(), 187 + } 188 + } 189 + 190 + /// Add pre-rendered HTML for an AT URI. 191 + #[wasm_bindgen(js_name = addEmbed)] 192 + pub fn add_embed(&mut self, at_uri: &str, html: &str) -> Result<(), JsError> { 193 + use weaver_common::jacquard::{CowStr, IntoStatic, types::string::AtUri}; 194 + 195 + let uri = AtUri::new(at_uri) 196 + .map_err(|e| JsError::new(&format!("Invalid AT URI: {}", e)))? 197 + .into_static(); 198 + 199 + self.inner 200 + .add_embed(uri, CowStr::from(html.to_string()), None); 201 + Ok(()) 202 + } 203 + } 204 + 205 + impl Default for JsResolvedContent { 206 + fn default() -> Self { 207 + Self::new() 208 + } 209 + } 210 + 211 + impl JsResolvedContent { 212 + pub fn into_inner(self) -> weaver_common::ResolvedContent { 213 + self.inner 214 + } 215 + 216 + pub fn inner_ref(&self) -> &weaver_common::ResolvedContent { 217 + &self.inner 218 + } 219 + } 220 + 221 + /// Create an empty resolved content container. 222 + #[wasm_bindgen] 223 + pub fn create_resolved_content() -> JsResolvedContent { 224 + JsResolvedContent::new() 225 + }