From cfdc9a2ba7bb1f2ba83a8b453a79210b24a17697 Mon Sep 17 00:00:00 2001 From: Orual Date: Wed, 7 Jan 2026 22:16:28 -0500 Subject: [PATCH] editor js bindings scaffold --- Cargo.lock | 20 ++ crates/weaver-editor-js/.gitignore | 2 + crates/weaver-editor-js/Cargo.toml | 57 ++++ crates/weaver-editor-js/build.sh | 290 +++++++++++++++++++++ crates/weaver-editor-js/src/actions.rs | 153 +++++++++++ crates/weaver-editor-js/src/collab.rs | 43 ++++ crates/weaver-editor-js/src/editor.rs | 344 +++++++++++++++++++++++++ crates/weaver-editor-js/src/lib.rs | 30 +++ crates/weaver-editor-js/src/types.rs | 225 ++++++++++++++++ 9 files changed, 1164 insertions(+) create mode 100644 crates/weaver-editor-js/.gitignore create mode 100644 crates/weaver-editor-js/Cargo.toml create mode 100755 crates/weaver-editor-js/build.sh create mode 100644 crates/weaver-editor-js/src/actions.rs create mode 100644 crates/weaver-editor-js/src/collab.rs create mode 100644 crates/weaver-editor-js/src/editor.rs create mode 100644 crates/weaver-editor-js/src/lib.rs create mode 100644 crates/weaver-editor-js/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 91a7c98..d62bb04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12318,6 +12318,26 @@ dependencies = [ "web-time", ] +[[package]] +name = "weaver-editor-js" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_bytes", + "tsify-next", + "wasm-bindgen", + "weaver-api", + "weaver-common", + "weaver-editor-browser", + "weaver-editor-core", + "weaver-editor-crdt", + "weaver-embed-worker", + "web-sys", +] + [[package]] name = "weaver-embed-worker" version = "0.1.0" diff --git a/crates/weaver-editor-js/.gitignore b/crates/weaver-editor-js/.gitignore new file mode 100644 index 0000000..ebf1dde --- /dev/null +++ b/crates/weaver-editor-js/.gitignore @@ -0,0 +1,2 @@ +pkg/ +target/ diff --git a/crates/weaver-editor-js/Cargo.toml b/crates/weaver-editor-js/Cargo.toml new file mode 100644 index 0000000..bee5c43 --- /dev/null +++ b/crates/weaver-editor-js/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "weaver-editor-js" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository = "https://tangled.org/nonbinary.computer/weaver" +description = "WASM bindings for the weaver markdown editor" + +[lib] +crate-type = ["cdylib"] + +[features] +default = [] +collab = ["weaver-editor-crdt"] + +[dependencies] +weaver-editor-core = { path = "../weaver-editor-core" } +weaver-editor-browser = { path = "../weaver-editor-browser" } +weaver-editor-crdt = { path = "../weaver-editor-crdt", optional = true } +weaver-embed-worker = { path = "../weaver-embed-worker" } +weaver-api = { path = "../weaver-api" } +weaver-common = { path = "../weaver-common" } + +wasm-bindgen = "0.2" +serde = { workspace = true } +serde_bytes = "0.11" +serde-wasm-bindgen = "0.6" +tsify-next = "0.5" +js-sys = "0.3" +console_error_panic_hook = "0.1" + +[dependencies.web-sys] +version = "0.3" +features = [ + "console", + "Document", + "Element", + "HtmlElement", + "HtmlDivElement", + "Node", + "Window", + "Selection", + "Range", + "InputEvent", + "KeyboardEvent", + "ClipboardEvent", + "CompositionEvent", + "DataTransfer", + "EventTarget", +] + +[package.metadata.wasm-pack.profile.dev] +wasm-opt = false + +[package.metadata.wasm-pack.profile.release] +wasm-opt = ['-Oz', '--enable-bulk-memory-opt', '--enable-nontrapping-float-to-int'] diff --git a/crates/weaver-editor-js/build.sh b/crates/weaver-editor-js/build.sh new file mode 100755 index 0000000..6014984 --- /dev/null +++ b/crates/weaver-editor-js/build.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +PKG_NAME="@weaver.sh/editor" +PKG_VERSION="0.1.0" + +# Targets to build +TARGETS=(bundler web nodejs deno) + +COMMAND="${1:-build}" +shift || true + +# Feature variants +declare -A VARIANTS=( + ["core"]="" + ["collab"]="collab" +) + +build() { + local target="$1" + local variant="$2" + local features="$3" + local out_dir="pkg/${variant}/${target}" + + echo "Building ${variant}/${target}..." + + local feature_args="--no-default-features" + if [[ -n "$features" ]]; then + feature_args="$feature_args --features $features" + fi + + wasm-pack build \ + --out-name weaver_editor \ + --out-dir "$out_dir" \ + --target "$target" \ + $feature_args + + # Report size + local wasm_file="${out_dir}/weaver_editor_bg.wasm" + if [[ -f "$wasm_file" ]]; then + local size=$(ls -lh "$wasm_file" | awk '{print $5}') + echo " → ${size}" + fi +} + +generate_package_json() { + local variant="$1" + local out_dir="pkg/${variant}" + local pkg_suffix="" + local description="" + + if [[ "$variant" == "collab" ]]; then + pkg_suffix="-collab" + description="Weaver markdown editor with collaborative editing (Loro CRDT + iroh P2P)" + else + pkg_suffix="-core" + description="Weaver markdown editor (local editing, lightweight)" + fi + + cat > "${out_dir}/package.json" << EOF +{ + "name": "${PKG_NAME}${pkg_suffix}", + "version": "${PKG_VERSION}", + "description": "${description}", + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "https://tangled.org/nonbinary.computer/weaver" + }, + "keywords": ["atproto", "markdown", "editor", "wasm", "weaver"], + "main": "nodejs/weaver_editor.js", + "module": "bundler/weaver_editor.js", + "browser": "web/weaver_editor.js", + "types": "bundler/weaver_editor.d.ts", + "exports": { + ".": { + "deno": "./deno/weaver_editor.js", + "node": { + "import": "./nodejs/weaver_editor.js", + "require": "./nodejs/weaver_editor.js" + }, + "browser": { + "import": "./web/weaver_editor.js" + }, + "default": "./bundler/weaver_editor.js" + }, + "./bundler": { + "import": "./bundler/weaver_editor.js", + "types": "./bundler/weaver_editor.d.ts" + }, + "./web": { + "import": "./web/weaver_editor.js", + "types": "./web/weaver_editor.d.ts" + }, + "./nodejs": { + "import": "./nodejs/weaver_editor.js", + "require": "./nodejs/weaver_editor.js", + "types": "./nodejs/weaver_editor.d.ts" + }, + "./deno": { + "import": "./deno/weaver_editor.js", + "types": "./deno/weaver_editor.d.ts" + } + }, + "files": [ + "bundler/", + "web/", + "nodejs/", + "deno/", + "README.md" + ] +} +EOF +} + +generate_readme() { + local variant="$1" + local out_dir="pkg/${variant}" + + cat > "${out_dir}/README.md" << 'EOF' +# @weaver.sh/editor + +WASM-based markdown editor for the weaver.sh ecosystem. + +## Installation + +```bash +npm install @weaver.sh/editor-core # Local editing only +npm install @weaver.sh/editor-collab # With collaborative editing +``` + +## Usage + +### With a bundler (webpack, vite, etc.) + +```javascript +import init, { JsEditor } from '@weaver.sh/editor-core'; + +await init(); + +const editor = JsEditor.fromMarkdown('# Hello\n\nWorld'); +console.log(editor.getMarkdown()); +``` + +### Direct browser usage (no bundler) + +```html + +``` + +### Node.js + +```javascript +const { JsEditor } = require('@weaver.sh/editor-core/nodejs'); +``` + +## API + +See the TypeScript definitions for full API documentation. + +### Core + +- `JsEditor.new()` - Create empty editor +- `JsEditor.fromMarkdown(content)` - Create from markdown +- `JsEditor.fromSnapshot(entry)` - Create from EntryJson snapshot +- `editor.getMarkdown()` - Get markdown content +- `editor.getSnapshot()` - Get EntryJson for drafts +- `editor.toEntry()` - Get validated EntryJson for publishing +- `editor.executeAction(action)` - Execute an EditorAction +- `editor.setTitle(title)` / `editor.setPath(path)` / `editor.setTags(tags)` + +### Images + +- `editor.addPendingImage(image)` - Track pending upload +- `editor.finalizeImage(localId, finalized)` - Mark upload complete +- `editor.getPendingImages()` - Get images awaiting upload +- `editor.getStagingUris()` - Get staging record URIs for cleanup + +### Collab (editor-collab only) + +- `JsCollabEditor` - Collaborative editor with Loro CRDT +- `editor.exportUpdates()` / `editor.importUpdates(bytes)` +- `editor.addPeer(nodeId)` / `editor.removePeer(nodeId)` +EOF +} + +do_build() { + # Clean previous builds + rm -rf pkg + + # Build all combinations + for variant in "${!VARIANTS[@]}"; do + features="${VARIANTS[$variant]}" + + for target in "${TARGETS[@]}"; do + build "$target" "$variant" "$features" + done + + generate_package_json "$variant" + generate_readme "$variant" + + # Clean up wasm-pack artifacts we don't need + find "pkg/${variant}" -name ".gitignore" -delete + find "pkg/${variant}" -name "package.json" -path "*/bundler/*" -delete + find "pkg/${variant}" -name "package.json" -path "*/web/*" -delete + find "pkg/${variant}" -name "package.json" -path "*/nodejs/*" -delete + find "pkg/${variant}" -name "package.json" -path "*/deno/*" -delete + done + + echo "" + echo "Build complete!" + echo "" + ls -lh pkg/core/web/*.wasm pkg/collab/web/*.wasm 2>/dev/null || true + echo "" + echo "Packages:" + echo " pkg/core/ - @weaver.sh/editor-core (local editing)" + echo " pkg/collab/ - @weaver.sh/editor-collab (with CRDT collab)" +} + +do_pack() { + echo "Packing..." + for variant in "${!VARIANTS[@]}"; do + echo " ${variant}..." + (cd "pkg/${variant}" && npm pack) + done + echo "" + echo "Tarballs created:" + ls -lh pkg/*/*.tgz 2>/dev/null || true +} + +do_publish() { + local tag="${1:-}" + local tag_arg="" + if [[ -n "$tag" ]]; then + tag_arg="--tag $tag" + fi + + echo "Publishing..." + for variant in "${!VARIANTS[@]}"; do + echo " ${variant}..." + (cd "pkg/${variant}" && npm publish --access public $tag_arg) + done + echo "" + echo "Published!" +} + +usage() { + echo "Usage: $0 [command]" + echo "" + echo "Commands:" + echo " build Build all variants and targets (default)" + echo " pack Create npm tarballs" + echo " publish Publish to npm registry" + echo " all Build, pack, and publish" + echo "" + echo "Options for publish:" + echo " --tag Publish with a specific tag (e.g., 'next', 'beta')" +} + +case "$COMMAND" in + build) + do_build + ;; + pack) + do_pack + ;; + publish) + do_publish "$@" + ;; + all) + do_build + do_pack + do_publish "$@" + ;; + -h|--help|help) + usage + ;; + *) + echo "Unknown command: $COMMAND" + usage + exit 1 + ;; +esac diff --git a/crates/weaver-editor-js/src/actions.rs b/crates/weaver-editor-js/src/actions.rs new file mode 100644 index 0000000..6952f52 --- /dev/null +++ b/crates/weaver-editor-js/src/actions.rs @@ -0,0 +1,153 @@ +//! EditorAction conversion for JavaScript. + +use serde::{Deserialize, Serialize}; +use tsify_next::Tsify; +use wasm_bindgen::prelude::*; +use weaver_editor_core::{EditorAction, FormatAction, Range}; + +/// JavaScript-friendly editor action. +/// +/// Mirrors EditorAction from core but with JS-compatible types. +/// Also includes FormatAction variants for extended formatting. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum JsEditorAction { + // Text insertion + Insert { text: String, start: usize, end: usize }, + InsertLineBreak { start: usize, end: usize }, + InsertParagraph { start: usize, end: usize }, + + // Deletion + DeleteBackward { start: usize, end: usize }, + DeleteForward { start: usize, end: usize }, + DeleteWordBackward { start: usize, end: usize }, + DeleteWordForward { start: usize, end: usize }, + DeleteToLineStart { start: usize, end: usize }, + DeleteToLineEnd { start: usize, end: usize }, + DeleteSoftLineBackward { start: usize, end: usize }, + DeleteSoftLineForward { start: usize, end: usize }, + + // History + Undo, + Redo, + + // Inline formatting (EditorAction variants) + ToggleBold, + ToggleItalic, + ToggleCode, + ToggleStrikethrough, + InsertLink, + + // Extended formatting (FormatAction variants) + InsertImage, + InsertHeading { level: u8 }, + ToggleBulletList, + ToggleNumberedList, + ToggleQuote, + + // Clipboard + Cut, + Copy, + Paste { start: usize, end: usize }, + CopyAsHtml, + + // Selection + SelectAll, + + // Cursor + MoveCursor { offset: usize }, + ExtendSelection { offset: usize }, +} + +/// Result of converting JsEditorAction. +pub enum ActionKind { + /// Standard EditorAction. + Editor(EditorAction), + /// FormatAction (needs apply_formatting). + Format(FormatAction), +} + +impl JsEditorAction { + /// Convert to ActionKind (either EditorAction or FormatAction). + pub fn to_action_kind(&self) -> ActionKind { + match self { + // Text insertion + Self::Insert { text, start, end } => ActionKind::Editor(EditorAction::Insert { + text: text.clone(), + range: Range::new(*start, *end), + }), + Self::InsertLineBreak { start, end } => ActionKind::Editor(EditorAction::InsertLineBreak { + range: Range::new(*start, *end), + }), + Self::InsertParagraph { start, end } => ActionKind::Editor(EditorAction::InsertParagraph { + range: Range::new(*start, *end), + }), + + // Deletion + Self::DeleteBackward { start, end } => ActionKind::Editor(EditorAction::DeleteBackward { + range: Range::new(*start, *end), + }), + Self::DeleteForward { start, end } => ActionKind::Editor(EditorAction::DeleteForward { + range: Range::new(*start, *end), + }), + Self::DeleteWordBackward { start, end } => ActionKind::Editor(EditorAction::DeleteWordBackward { + range: Range::new(*start, *end), + }), + Self::DeleteWordForward { start, end } => ActionKind::Editor(EditorAction::DeleteWordForward { + range: Range::new(*start, *end), + }), + Self::DeleteToLineStart { start, end } => ActionKind::Editor(EditorAction::DeleteToLineStart { + range: Range::new(*start, *end), + }), + Self::DeleteToLineEnd { start, end } => ActionKind::Editor(EditorAction::DeleteToLineEnd { + range: Range::new(*start, *end), + }), + Self::DeleteSoftLineBackward { start, end } => ActionKind::Editor(EditorAction::DeleteSoftLineBackward { + range: Range::new(*start, *end), + }), + Self::DeleteSoftLineForward { start, end } => ActionKind::Editor(EditorAction::DeleteSoftLineForward { + range: Range::new(*start, *end), + }), + + // History + Self::Undo => ActionKind::Editor(EditorAction::Undo), + Self::Redo => ActionKind::Editor(EditorAction::Redo), + + // Inline formatting (EditorAction) + Self::ToggleBold => ActionKind::Editor(EditorAction::ToggleBold), + Self::ToggleItalic => ActionKind::Editor(EditorAction::ToggleItalic), + Self::ToggleCode => ActionKind::Editor(EditorAction::ToggleCode), + Self::ToggleStrikethrough => ActionKind::Editor(EditorAction::ToggleStrikethrough), + Self::InsertLink => ActionKind::Editor(EditorAction::InsertLink), + + // Extended formatting (FormatAction) + Self::InsertImage => ActionKind::Format(FormatAction::Image), + Self::InsertHeading { level } => ActionKind::Format(FormatAction::Heading(*level)), + Self::ToggleBulletList => ActionKind::Format(FormatAction::BulletList), + Self::ToggleNumberedList => ActionKind::Format(FormatAction::NumberedList), + Self::ToggleQuote => ActionKind::Format(FormatAction::Quote), + + // Clipboard + Self::Cut => ActionKind::Editor(EditorAction::Cut), + Self::Copy => ActionKind::Editor(EditorAction::Copy), + Self::Paste { start, end } => ActionKind::Editor(EditorAction::Paste { + range: Range::new(*start, *end), + }), + Self::CopyAsHtml => ActionKind::Editor(EditorAction::CopyAsHtml), + + // Selection + Self::SelectAll => ActionKind::Editor(EditorAction::SelectAll), + + // Cursor + Self::MoveCursor { offset } => ActionKind::Editor(EditorAction::MoveCursor { offset: *offset }), + Self::ExtendSelection { offset } => ActionKind::Editor(EditorAction::ExtendSelection { offset: *offset }), + } + } +} + +/// Parse a JsValue into JsEditorAction. +pub fn parse_action(value: JsValue) -> Result { + serde_wasm_bindgen::from_value(value) + .map_err(|e| JsError::new(&format!("Invalid action: {}", e))) +} diff --git a/crates/weaver-editor-js/src/collab.rs b/crates/weaver-editor-js/src/collab.rs new file mode 100644 index 0000000..ac0353d --- /dev/null +++ b/crates/weaver-editor-js/src/collab.rs @@ -0,0 +1,43 @@ +//! JsCollabEditor - collaborative editor with Loro CRDT. +//! +//! Only available with the `collab` feature. + +use wasm_bindgen::prelude::*; + +use weaver_editor_crdt::LoroTextBuffer; + +/// Collaborative editor with CRDT sync. +/// +/// Wraps LoroTextBuffer for collaborative editing with iroh P2P transport. +#[wasm_bindgen] +pub struct JsCollabEditor { + // TODO: Implement collab editor + // - LoroTextBuffer for CRDT-backed text + // - iroh node for P2P transport + // - Session management callbacks + _marker: std::marker::PhantomData, +} + +#[wasm_bindgen] +impl JsCollabEditor { + /// Create a new collaborative editor. + #[wasm_bindgen(constructor)] + pub fn new() -> Result { + Err(JsError::new("CollabEditor not yet implemented")) + } +} + +impl Default for JsCollabEditor { + fn default() -> Self { + Self { + _marker: std::marker::PhantomData, + } + } +} + +// TODO: Implement these when ready: +// - from_snapshot / from_loro_doc +// - export_updates / import_updates +// - get_version +// - add_peer / remove_peer / get_connected_peers +// - Session callbacks (onSessionNeeded, onSessionRefresh, onSessionEnd, onPeersNeeded) diff --git a/crates/weaver-editor-js/src/editor.rs b/crates/weaver-editor-js/src/editor.rs new file mode 100644 index 0000000..56a62e7 --- /dev/null +++ b/crates/weaver-editor-js/src/editor.rs @@ -0,0 +1,344 @@ +//! JsEditor - the main editor wrapper for JavaScript. + +use std::collections::HashMap; + +use wasm_bindgen::prelude::*; +use web_sys::HtmlElement; + +use weaver_editor_browser::BrowserClipboard; +use weaver_editor_core::{ + EditorDocument, EditorRope, PlainEditor, RenderCache, UndoableBuffer, apply_formatting, + execute_action_with_clipboard, +}; + +use crate::actions::{ActionKind, parse_action}; +use crate::types::{EntryEmbeds, EntryJson, FinalizedImage, JsResolvedContent, PendingImage}; + +type InnerEditor = PlainEditor>; + +/// The main editor instance exposed to JavaScript. +/// +/// Wraps the core editor with WASM bindings for browser use. +#[wasm_bindgen] +pub struct JsEditor { + doc: InnerEditor, + cache: RenderCache, + resolved_content: weaver_common::ResolvedContent, + + // Metadata + title: String, + path: String, + tags: Vec, + created_at: String, + + // Image tracking + pending_images: HashMap, + finalized_images: HashMap, +} + +#[wasm_bindgen] +impl JsEditor { + /// Create a new empty editor. + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + let rope = EditorRope::new(); + let buffer = UndoableBuffer::new(rope, 100); + let doc = PlainEditor::new(buffer); + + Self { + doc, + cache: RenderCache::default(), + resolved_content: weaver_common::ResolvedContent::new(), + title: String::new(), + path: String::new(), + tags: Vec::new(), + created_at: now_iso(), + pending_images: HashMap::new(), + finalized_images: HashMap::new(), + } + } + + /// Create an editor from markdown content. + #[wasm_bindgen(js_name = fromMarkdown)] + pub fn from_markdown(content: &str) -> Self { + let rope = EditorRope::from_str(content); + let buffer = UndoableBuffer::new(rope, 100); + let doc = PlainEditor::new(buffer); + + Self { + doc, + cache: RenderCache::default(), + resolved_content: weaver_common::ResolvedContent::new(), + title: String::new(), + path: String::new(), + tags: Vec::new(), + created_at: now_iso(), + pending_images: HashMap::new(), + finalized_images: HashMap::new(), + } + } + + /// Create an editor from a snapshot (EntryJson). + #[wasm_bindgen(js_name = fromSnapshot)] + pub fn from_snapshot(snapshot: JsValue) -> Result { + let entry: EntryJson = serde_wasm_bindgen::from_value(snapshot) + .map_err(|e| JsError::new(&format!("Invalid snapshot: {}", e)))?; + + let rope = EditorRope::from_str(&entry.content); + let buffer = UndoableBuffer::new(rope, 100); + let doc = PlainEditor::new(buffer); + + Ok(Self { + doc, + cache: RenderCache::default(), + resolved_content: weaver_common::ResolvedContent::new(), + title: entry.title, + path: entry.path, + tags: entry.tags.unwrap_or_default(), + created_at: entry.created_at, + pending_images: HashMap::new(), + finalized_images: HashMap::new(), + }) + } + + /// Set pre-resolved embed content. + #[wasm_bindgen(js_name = setResolvedContent)] + pub fn set_resolved_content(&mut self, content: JsResolvedContent) { + self.resolved_content = content.into_inner(); + } + + // === Content access === + + /// Get the markdown content. + #[wasm_bindgen(js_name = getMarkdown)] + pub fn get_markdown(&self) -> String { + self.doc.content_string() + } + + /// Get the current state as a snapshot (EntryJson). + #[wasm_bindgen(js_name = getSnapshot)] + pub fn get_snapshot(&self) -> Result { + let entry = EntryJson { + title: self.title.clone(), + path: self.path.clone(), + content: self.doc.content_string(), + created_at: self.created_at.clone(), + updated_at: Some(now_iso()), + tags: if self.tags.is_empty() { + None + } else { + Some(self.tags.clone()) + }, + embeds: self.build_embeds(), + authors: None, + content_warnings: None, + rating: None, + }; + + serde_wasm_bindgen::to_value(&entry) + .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) + } + + /// Get the entry JSON, validating required fields. + /// + /// Throws if title or path is empty, or if there are pending images. + #[wasm_bindgen(js_name = toEntry)] + pub fn to_entry(&self) -> Result { + if self.title.is_empty() { + return Err(JsError::new("Title is required")); + } + if self.path.is_empty() { + return Err(JsError::new("Path is required")); + } + if !self.pending_images.is_empty() { + return Err(JsError::new( + "Pending images must be finalized before publishing", + )); + } + + self.get_snapshot() + } + + // === Metadata === + + /// Get the title. + #[wasm_bindgen(js_name = getTitle)] + pub fn get_title(&self) -> String { + self.title.clone() + } + + /// Set the title. + #[wasm_bindgen(js_name = setTitle)] + pub fn set_title(&mut self, title: &str) { + self.title = title.to_string(); + } + + /// Get the path. + #[wasm_bindgen(js_name = getPath)] + pub fn get_path(&self) -> String { + self.path.clone() + } + + /// Set the path. + #[wasm_bindgen(js_name = setPath)] + pub fn set_path(&mut self, path: &str) { + self.path = path.to_string(); + } + + /// Get the tags. + #[wasm_bindgen(js_name = getTags)] + pub fn get_tags(&self) -> Vec { + self.tags.clone() + } + + /// Set the tags. + #[wasm_bindgen(js_name = setTags)] + pub fn set_tags(&mut self, tags: Vec) { + self.tags = tags; + } + + // === Actions === + + /// Execute an editor action. + #[wasm_bindgen(js_name = executeAction)] + pub fn execute_action(&mut self, action: JsValue) -> Result<(), JsError> { + let js_action = parse_action(action)?; + let kind = js_action.to_action_kind(); + + let clipboard = BrowserClipboard::empty(); + match kind { + ActionKind::Editor(editor_action) => { + execute_action_with_clipboard(&mut self.doc, &editor_action, &clipboard); + } + ActionKind::Format(format_action) => { + apply_formatting(&mut self.doc, format_action); + } + } + + Ok(()) + } + + // === Image handling === + + /// Add a pending image (called when user adds an image). + #[wasm_bindgen(js_name = addPendingImage)] + pub fn add_pending_image(&mut self, image: JsValue) -> Result<(), JsError> { + let pending: PendingImage = serde_wasm_bindgen::from_value(image) + .map_err(|e| JsError::new(&format!("Invalid pending image: {}", e)))?; + + self.pending_images + .insert(pending.local_id.clone(), pending); + Ok(()) + } + + /// Finalize an image after upload. + #[wasm_bindgen(js_name = finalizeImage)] + pub fn finalize_image(&mut self, local_id: &str, finalized: JsValue) -> Result<(), JsError> { + let finalized: FinalizedImage = serde_wasm_bindgen::from_value(finalized) + .map_err(|e| JsError::new(&format!("Invalid finalized image: {}", e)))?; + + self.pending_images.remove(local_id); + self.finalized_images + .insert(local_id.to_string(), finalized); + Ok(()) + } + + /// Remove a pending image. + #[wasm_bindgen(js_name = removeImage)] + pub fn remove_image(&mut self, local_id: &str) { + self.pending_images.remove(local_id); + self.finalized_images.remove(local_id); + } + + /// Get pending images that need upload. + #[wasm_bindgen(js_name = getPendingImages)] + pub fn get_pending_images(&self) -> Result { + let pending: Vec<_> = self.pending_images.values().cloned().collect(); + serde_wasm_bindgen::to_value(&pending) + .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) + } + + /// Get staging URIs for cleanup after publish. + #[wasm_bindgen(js_name = getStagingUris)] + pub fn get_staging_uris(&self) -> Vec { + self.finalized_images + .values() + .map(|f| f.staging_uri.clone()) + .collect() + } + + // === Cursor/selection === + + /// Get the current cursor offset. + #[wasm_bindgen(js_name = getCursorOffset)] + pub fn get_cursor_offset(&self) -> usize { + self.doc.cursor_offset() + } + + /// Set the cursor offset. + #[wasm_bindgen(js_name = setCursorOffset)] + pub fn set_cursor_offset(&mut self, offset: usize) { + self.doc.set_cursor_offset(offset); + } + + /// Get the document length in characters. + #[wasm_bindgen(js_name = getLength)] + pub fn get_length(&self) -> usize { + self.doc.len_chars() + } + + // === Undo/redo === + + /// Check if undo is available. + #[wasm_bindgen(js_name = canUndo)] + pub fn can_undo(&self) -> bool { + self.doc.can_undo() + } + + /// Check if redo is available. + #[wasm_bindgen(js_name = canRedo)] + pub fn can_redo(&self) -> bool { + self.doc.can_redo() + } +} + +impl Default for JsEditor { + fn default() -> Self { + Self::new() + } +} + +impl JsEditor { + /// Build embeds from finalized images. + fn build_embeds(&self) -> Option { + if self.finalized_images.is_empty() { + return None; + } + + use crate::types::{ImageEmbed, ImagesEmbed}; + + let images: Vec = self + .finalized_images + .values() + .map(|f| ImageEmbed { + image: f.blob_ref.clone(), + alt: String::new(), // TODO: track alt text + aspect_ratio: None, + }) + .collect(); + + Some(EntryEmbeds { + images: Some(ImagesEmbed { images }), + records: None, + externals: None, + videos: None, + }) + } +} + +/// Get current time as ISO string. +fn now_iso() -> String { + // Use js_sys::Date for browser-compatible time + let date = js_sys::Date::new_0(); + date.to_iso_string().into() +} diff --git a/crates/weaver-editor-js/src/lib.rs b/crates/weaver-editor-js/src/lib.rs new file mode 100644 index 0000000..b1a933f --- /dev/null +++ b/crates/weaver-editor-js/src/lib.rs @@ -0,0 +1,30 @@ +//! WASM bindings for the weaver markdown editor. +//! +//! Provides embeddable editor components for JavaScript/TypeScript apps. +//! +//! # Features +//! +//! - `collab`: Enable collaborative editing via Loro CRDT + iroh P2P +//! - `syntax-highlighting`: Enable syntax highlighting for code blocks + +mod actions; +mod editor; +mod types; + +#[cfg(feature = "collab")] +mod collab; + +pub use actions::*; +pub use editor::*; +pub use types::*; + +#[cfg(feature = "collab")] +pub use collab::*; + +use wasm_bindgen::prelude::*; + +/// Initialize panic hook for better error messages in console. +#[wasm_bindgen(start)] +pub fn init() { + console_error_panic_hook::set_once(); +} diff --git a/crates/weaver-editor-js/src/types.rs b/crates/weaver-editor-js/src/types.rs new file mode 100644 index 0000000..22bc21c --- /dev/null +++ b/crates/weaver-editor-js/src/types.rs @@ -0,0 +1,225 @@ +//! Types exposed to JavaScript via wasm-bindgen. + +use serde::{Deserialize, Serialize}; +use tsify_next::Tsify; +use wasm_bindgen::prelude::*; + +/// Pending image waiting for upload. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct PendingImage { + pub local_id: String, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub data: Vec, + pub mime_type: String, + pub name: String, +} + +/// Finalized image with blob ref and staging URI. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct FinalizedImage { + pub blob_ref: JsBlobRef, + /// AT URI of the staging record (sh.weaver.publish.blob). + pub staging_uri: String, +} + +/// Blob reference matching AT Protocol blob format. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct JsBlobRef { + #[serde(rename = "$type")] + pub type_marker: String, // "blob" + pub r#ref: BlobLink, + pub mime_type: String, + pub size: u64, +} + +/// CID link for blob. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct BlobLink { + #[serde(rename = "$link")] + pub link: String, +} + +/// Entry JSON matching sh.weaver.notebook.entry lexicon. +/// +/// Used for snapshots (drafts) and final entry output. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct EntryJson { + pub title: String, + pub path: String, + pub content: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub embeds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub authors: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_warnings: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub rating: Option, +} + +/// Entry embeds container. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct EntryEmbeds { + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub records: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub externals: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub videos: Option, +} + +/// Image embed container. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct ImagesEmbed { + pub images: Vec, +} + +/// Single image embed. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct ImageEmbed { + pub image: JsBlobRef, + pub alt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, +} + +/// Aspect ratio for images/videos. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct AspectRatio { + pub width: u32, + pub height: u32, +} + +/// Record embed container. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct RecordsEmbed { + pub records: Vec, +} + +/// Single record embed (strong ref). +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct RecordEmbed { + pub uri: String, + pub cid: String, +} + +/// External link embed container. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct ExternalsEmbed { + pub externals: Vec, +} + +/// Single external link embed. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct ExternalEmbed { + pub uri: String, + pub title: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb: Option, +} + +/// Video embed container. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct VideosEmbed { + pub videos: Vec, +} + +/// Single video embed. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct VideoEmbed { + pub video: JsBlobRef, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, +} + +/// Author reference. +#[derive(Debug, Clone, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct Author { + pub did: String, +} + +/// Pre-rendered embed content for initial load. +#[wasm_bindgen] +pub struct JsResolvedContent { + inner: weaver_common::ResolvedContent, +} + +#[wasm_bindgen] +impl JsResolvedContent { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + inner: weaver_common::ResolvedContent::new(), + } + } + + /// Add pre-rendered HTML for an AT URI. + #[wasm_bindgen(js_name = addEmbed)] + pub fn add_embed(&mut self, at_uri: &str, html: &str) -> Result<(), JsError> { + use weaver_common::jacquard::{CowStr, IntoStatic, types::string::AtUri}; + + let uri = AtUri::new(at_uri) + .map_err(|e| JsError::new(&format!("Invalid AT URI: {}", e)))? + .into_static(); + + self.inner + .add_embed(uri, CowStr::from(html.to_string()), None); + Ok(()) + } +} + +impl Default for JsResolvedContent { + fn default() -> Self { + Self::new() + } +} + +impl JsResolvedContent { + pub fn into_inner(self) -> weaver_common::ResolvedContent { + self.inner + } + + pub fn inner_ref(&self) -> &weaver_common::ResolvedContent { + &self.inner + } +} + +/// Create an empty resolved content container. +#[wasm_bindgen] +pub fn create_resolved_content() -> JsResolvedContent { + JsResolvedContent::new() +} -- 2.51.2