${this.#esc(l.title)}
diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts
index 0f844872..c25ade89 100644
--- a/modules/rcart/components/folk-cart-shop.ts
+++ b/modules/rcart/components/folk-cart-shop.ts
@@ -38,7 +38,7 @@ class FolkCartShop extends HTMLElement {
private creatingPayment = false;
private creatingGroupBuy = false;
private _offlineUnsubs: (() => void)[] = [];
- private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts");
+ private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart");
private _stopPresence: (() => void) | null = null;
// Guided tour
@@ -91,9 +91,12 @@ class FolkCartShop extends HTMLElement {
setTimeout(() => this._tour.start(), 1200);
}
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcart', context: this.selectedCatalogItem?.title || this.view }));
+ window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
}
disconnectedCallback() {
+ this._history.destroy();
+ window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
this._stopPresence?.();
@@ -539,6 +542,14 @@ class FolkCartShop extends HTMLElement {
startTour() { this._tour.start(); }
+ private _onViewRestored = (e: CustomEvent) => {
+ if (e.detail?.moduleId !== 'rcart') return;
+ this.view = e.detail.view;
+ if (e.detail.view !== "cart-detail") this.selectedCartId = null;
+ if (e.detail.view !== "order-detail") this.selectedOrder = null;
+ this.render();
+ };
+
private goBack() {
const prev = this._history.back();
if (!prev) return;
diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts
index b28663ea..2e224c71 100644
--- a/modules/rchoices/components/folk-choices-dashboard.ts
+++ b/modules/rchoices/components/folk-choices-dashboard.ts
@@ -8,8 +8,10 @@
import { TourEngine } from "../../../shared/tour-engine";
import { ChoicesLocalFirstClient } from "../local-first-client";
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
+import { choicesSchema, choicesDocId } from "../schemas";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { getModuleApiBase, rspaceNavUrl, getCurrentSpace } from "../../../shared/url-helpers";
+import type { DocumentId } from "../../../shared/local-first/document";
// ── CrowdSurf types ──
interface CrowdSurfOption {
@@ -139,6 +141,7 @@ class FolkChoicesDashboard extends HTMLElement {
} catch (err) {
console.warn('[rChoices] Local-first init failed, falling back to API:', err);
}
+ this.subscribeCollabOverlay();
// Also load canvas-based choices
await this.loadChoices();
@@ -148,6 +151,15 @@ class FolkChoicesDashboard extends HTMLElement {
this.bindLiveEvents();
}
+ private async subscribeCollabOverlay() {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+ try {
+ const docId = choicesDocId(this.space) as DocumentId;
+ await runtime.subscribe(docId, choicesSchema);
+ } catch { /* runtime unavailable */ }
+ }
+
private extractSessions(doc: ChoicesDoc) {
this.sessions = doc.sessions ? Object.values(doc.sessions).sort((a, b) => b.createdAt - a.createdAt) : [];
// Pre-compute votes per session
diff --git a/modules/rdocs/browser-extension/background.js b/modules/rdocs/browser-extension/background.js
new file mode 100644
index 00000000..a07f8b3d
--- /dev/null
+++ b/modules/rdocs/browser-extension/background.js
@@ -0,0 +1,315 @@
+const DEFAULT_HOST = 'https://rnotes.online';
+
+// --- Context Menu Setup ---
+
+chrome.runtime.onInstalled.addListener(() => {
+ chrome.contextMenus.create({
+ id: 'clip-page',
+ title: 'Clip page to rNotes',
+ contexts: ['page'],
+ });
+
+ chrome.contextMenus.create({
+ id: 'save-link',
+ title: 'Save link to rNotes',
+ contexts: ['link'],
+ });
+
+ chrome.contextMenus.create({
+ id: 'save-image',
+ title: 'Save image to rNotes',
+ contexts: ['image'],
+ });
+
+ chrome.contextMenus.create({
+ id: 'clip-selection',
+ title: 'Clip selection to rNotes',
+ contexts: ['selection'],
+ });
+
+ chrome.contextMenus.create({
+ id: 'unlock-article',
+ title: 'Unlock & Clip article to rNotes',
+ contexts: ['page', 'link'],
+ });
+});
+
+// --- Helpers ---
+
+async function getSettings() {
+ const result = await chrome.storage.sync.get(['rnotesHost']);
+ return {
+ host: result.rnotesHost || DEFAULT_HOST,
+ };
+}
+
+async function getToken() {
+ const result = await chrome.storage.local.get(['encryptid_token']);
+ return result.encryptid_token || null;
+}
+
+async function getDefaultNotebook() {
+ const result = await chrome.storage.local.get(['lastNotebookId']);
+ return result.lastNotebookId || null;
+}
+
+function showNotification(title, message) {
+ chrome.notifications.create({
+ type: 'basic',
+ iconUrl: 'icons/icon-128.png',
+ title: title,
+ message: message,
+ });
+}
+
+async function createNote(data) {
+ const token = await getToken();
+ if (!token) {
+ showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.');
+ return;
+ }
+
+ const settings = await getSettings();
+ const notebookId = await getDefaultNotebook();
+
+ const body = {
+ title: data.title,
+ content: data.content,
+ type: data.type || 'CLIP',
+ url: data.url,
+ };
+
+ if (notebookId) body.notebookId = notebookId;
+ if (data.fileUrl) body.fileUrl = data.fileUrl;
+ if (data.mimeType) body.mimeType = data.mimeType;
+ if (data.fileSize) body.fileSize = data.fileSize;
+
+ const response = await fetch(`${settings.host}/api/notes`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`${response.status}: ${text}`);
+ }
+
+ return response.json();
+}
+
+async function uploadImage(imageUrl) {
+ const token = await getToken();
+ const settings = await getSettings();
+
+ // Fetch the image
+ const imgResponse = await fetch(imageUrl);
+ const blob = await imgResponse.blob();
+
+ // Extract filename
+ let filename;
+ try {
+ const urlPath = new URL(imageUrl).pathname;
+ filename = urlPath.split('/').pop() || `image-${Date.now()}.jpg`;
+ } catch {
+ filename = `image-${Date.now()}.jpg`;
+ }
+
+ // Upload to rNotes
+ const formData = new FormData();
+ formData.append('file', blob, filename);
+
+ const response = await fetch(`${settings.host}/api/uploads`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Upload failed: ${response.status} ${text}`);
+ }
+
+ return response.json();
+}
+
+async function unlockArticle(url) {
+ const token = await getToken();
+ if (!token) {
+ showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.');
+ return null;
+ }
+
+ const settings = await getSettings();
+ const response = await fetch(`${settings.host}/api/articles/unlock`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify({ url }),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Unlock failed: ${response.status} ${text}`);
+ }
+
+ return response.json();
+}
+
+// --- Context Menu Handler ---
+
+chrome.contextMenus.onClicked.addListener(async (info, tab) => {
+ try {
+ switch (info.menuItemId) {
+ case 'clip-page': {
+ // Get page HTML
+ let content = '';
+ try {
+ const [result] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: () => document.body.innerHTML,
+ });
+ content = result?.result || '';
+ } catch {
+ content = `
Clipped from ${tab.url}
`;
+ }
+
+ await createNote({
+ title: tab.title || 'Untitled Clip',
+ content: content,
+ type: 'CLIP',
+ url: tab.url,
+ });
+
+ showNotification('Page Clipped', `"${tab.title}" saved to rNotes`);
+ break;
+ }
+
+ case 'save-link': {
+ const linkUrl = info.linkUrl;
+ const linkText = info.selectionText || linkUrl;
+
+ await createNote({
+ title: linkText,
+ content: `
${linkText}
Found on: ${tab.title}
`,
+ type: 'BOOKMARK',
+ url: linkUrl,
+ });
+
+ showNotification('Link Saved', `Bookmark saved to rNotes`);
+ break;
+ }
+
+ case 'save-image': {
+ const imageUrl = info.srcUrl;
+
+ // Upload the image first
+ const upload = await uploadImage(imageUrl);
+
+ // Create IMAGE note with file reference
+ await createNote({
+ title: `Image from ${tab.title || 'page'}`,
+ content: `

Source: ${tab.title}
`,
+ type: 'IMAGE',
+ url: tab.url,
+ fileUrl: upload.url,
+ mimeType: upload.mimeType,
+ fileSize: upload.size,
+ });
+
+ showNotification('Image Saved', `Image saved to rNotes`);
+ break;
+ }
+
+ case 'unlock-article': {
+ const targetUrl = info.linkUrl || tab.url;
+ showNotification('Unlocking Article', `Finding readable version of ${new URL(targetUrl).hostname}...`);
+
+ const result = await unlockArticle(targetUrl);
+ if (result && result.success && result.archiveUrl) {
+ // Create a CLIP note with the archive URL
+ await createNote({
+ title: tab.title || 'Unlocked Article',
+ content: `
Unlocked via ${result.strategy}
Original: ${targetUrl}
Archive: ${result.archiveUrl}
`,
+ type: 'CLIP',
+ url: targetUrl,
+ });
+ showNotification('Article Unlocked', `Readable version found via ${result.strategy}`);
+ // Open the unlocked article in a new tab
+ chrome.tabs.create({ url: result.archiveUrl });
+ } else {
+ showNotification('Unlock Failed', result?.error || 'No archived version found');
+ }
+ break;
+ }
+
+ case 'clip-selection': {
+ // Get selection HTML
+ let content = '';
+ try {
+ const [result] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: () => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) return '';
+ const range = selection.getRangeAt(0);
+ const div = document.createElement('div');
+ div.appendChild(range.cloneContents());
+ return div.innerHTML;
+ },
+ });
+ content = result?.result || '';
+ } catch {
+ content = `
${info.selectionText || ''}
`;
+ }
+
+ if (!content && info.selectionText) {
+ content = `
${info.selectionText}
`;
+ }
+
+ await createNote({
+ title: `Selection from ${tab.title || 'page'}`,
+ content: content,
+ type: 'CLIP',
+ url: tab.url,
+ });
+
+ showNotification('Selection Clipped', `Saved to rNotes`);
+ break;
+ }
+ }
+ } catch (err) {
+ console.error('Context menu action failed:', err);
+ showNotification('rNotes Error', err.message || 'Failed to save');
+ }
+});
+
+// --- Keyboard shortcut handler ---
+
+chrome.commands.onCommand.addListener(async (command) => {
+ if (command === 'open-voice-recorder') {
+ const settings = await getSettings();
+ chrome.windows.create({
+ url: `${settings.host}/voice`,
+ type: 'popup',
+ width: 400,
+ height: 600,
+ focused: true,
+ });
+ }
+});
+
+// --- Message Handler (from popup) ---
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message.type === 'notify') {
+ showNotification(message.title, message.message);
+ }
+});
diff --git a/modules/rdocs/browser-extension/icons/icon-128.png b/modules/rdocs/browser-extension/icons/icon-128.png
new file mode 100644
index 00000000..1e296f93
Binary files /dev/null and b/modules/rdocs/browser-extension/icons/icon-128.png differ
diff --git a/modules/rdocs/browser-extension/icons/icon-16.png b/modules/rdocs/browser-extension/icons/icon-16.png
new file mode 100644
index 00000000..62b0620d
Binary files /dev/null and b/modules/rdocs/browser-extension/icons/icon-16.png differ
diff --git a/modules/rdocs/browser-extension/icons/icon-48.png b/modules/rdocs/browser-extension/icons/icon-48.png
new file mode 100644
index 00000000..3851e827
Binary files /dev/null and b/modules/rdocs/browser-extension/icons/icon-48.png differ
diff --git a/modules/rdocs/browser-extension/manifest.json b/modules/rdocs/browser-extension/manifest.json
new file mode 100644
index 00000000..95f6da23
--- /dev/null
+++ b/modules/rdocs/browser-extension/manifest.json
@@ -0,0 +1,50 @@
+{
+ "manifest_version": 3,
+ "name": "rNotes Web Clipper & Voice",
+ "version": "1.1.0",
+ "description": "Clip pages, text, links, and images to rNotes.online. Record voice notes with transcription.",
+ "permissions": [
+ "activeTab",
+ "contextMenus",
+ "storage",
+ "notifications",
+ "offscreen"
+ ],
+ "host_permissions": [
+ "https://rnotes.online/*",
+ "https://auth.ridentity.online/*",
+ "*://*/*"
+ ],
+ "action": {
+ "default_popup": "popup.html",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ }
+ },
+ "icons": {
+ "16": "icons/icon-16.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "background": {
+ "service_worker": "background.js"
+ },
+ "options_ui": {
+ "page": "options.html",
+ "open_in_tab": false
+ },
+ "content_security_policy": {
+ "extension_pages": "script-src 'self' https://esm.sh; object-src 'self'"
+ },
+ "commands": {
+ "open-voice-recorder": {
+ "suggested_key": {
+ "default": "Ctrl+Shift+V",
+ "mac": "Command+Shift+V"
+ },
+ "description": "Open rVoice recorder"
+ }
+ }
+}
diff --git a/modules/rdocs/browser-extension/options.html b/modules/rdocs/browser-extension/options.html
new file mode 100644
index 00000000..99468401
--- /dev/null
+++ b/modules/rdocs/browser-extension/options.html
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+
+
rNotes Web Clipper Settings
+
+
+
+
Connection
+
+
+
+
The URL of your rNotes instance
+
+
+
+
+
+
Authentication
+
+ Not signed in
+
+
+
+
+
+
+
Opens rNotes in a new tab. Sign in with your passkey.
+
+
+
+
+
After signing in, copy the extension token and paste it here.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Default Notebook
+
+
+
+
Pre-selected notebook when clipping
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/rdocs/browser-extension/options.js b/modules/rdocs/browser-extension/options.js
new file mode 100644
index 00000000..55858c52
--- /dev/null
+++ b/modules/rdocs/browser-extension/options.js
@@ -0,0 +1,179 @@
+const DEFAULT_HOST = 'https://rnotes.online';
+
+// --- Helpers ---
+
+function decodeToken(token) {
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ if (payload.exp && payload.exp * 1000 < Date.now()) {
+ return null;
+ }
+ return payload;
+ } catch {
+ return null;
+ }
+}
+
+function showStatus(message, type) {
+ const el = document.getElementById('status');
+ el.textContent = message;
+ el.className = `status ${type}`;
+ if (type === 'success') {
+ setTimeout(() => { el.className = 'status'; }, 3000);
+ }
+}
+
+// --- Auth UI ---
+
+async function updateAuthUI() {
+ const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
+ const claims = encryptid_token ? decodeToken(encryptid_token) : null;
+
+ const authStatus = document.getElementById('authStatus');
+ const loginSection = document.getElementById('loginSection');
+ const loggedInSection = document.getElementById('loggedInSection');
+
+ if (claims) {
+ const username = claims.username || claims.sub?.slice(0, 20) || 'Authenticated';
+ authStatus.textContent = `Signed in as ${username}`;
+ authStatus.className = 'auth-status authed';
+ loginSection.style.display = 'none';
+ loggedInSection.style.display = 'block';
+ } else {
+ authStatus.textContent = 'Not signed in';
+ authStatus.className = 'auth-status not-authed';
+ loginSection.style.display = 'block';
+ loggedInSection.style.display = 'none';
+ }
+}
+
+async function populateNotebooks() {
+ const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
+ if (!encryptid_token) return;
+
+ const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST;
+
+ try {
+ const response = await fetch(`${host}/api/notebooks`, {
+ headers: { 'Authorization': `Bearer ${encryptid_token}` },
+ });
+
+ if (!response.ok) return;
+
+ const notebooks = await response.json();
+ const select = document.getElementById('defaultNotebook');
+
+ // Clear existing options (keep first)
+ while (select.options.length > 1) {
+ select.remove(1);
+ }
+
+ for (const nb of notebooks) {
+ const option = document.createElement('option');
+ option.value = nb.id;
+ option.textContent = nb.title;
+ select.appendChild(option);
+ }
+
+ // Restore saved default
+ const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
+ if (lastNotebookId) {
+ select.value = lastNotebookId;
+ }
+ } catch (err) {
+ console.error('Failed to load notebooks:', err);
+ }
+}
+
+// --- Load settings ---
+
+async function loadSettings() {
+ const result = await chrome.storage.sync.get(['rnotesHost']);
+ document.getElementById('host').value = result.rnotesHost || DEFAULT_HOST;
+
+ await updateAuthUI();
+ await populateNotebooks();
+}
+
+// --- Event handlers ---
+
+// Open rNotes sign-in
+document.getElementById('openSigninBtn').addEventListener('click', () => {
+ const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST;
+ chrome.tabs.create({ url: `${host}/auth/signin?extension=true` });
+});
+
+// Save token
+document.getElementById('saveTokenBtn').addEventListener('click', async () => {
+ const tokenInput = document.getElementById('tokenInput').value.trim();
+
+ if (!tokenInput) {
+ showStatus('Please paste a token', 'error');
+ return;
+ }
+
+ const claims = decodeToken(tokenInput);
+ if (!claims) {
+ showStatus('Invalid or expired token', 'error');
+ return;
+ }
+
+ await chrome.storage.local.set({ encryptid_token: tokenInput });
+ document.getElementById('tokenInput').value = '';
+
+ showStatus(`Signed in as ${claims.username || claims.sub}`, 'success');
+ await updateAuthUI();
+ await populateNotebooks();
+});
+
+// Logout
+document.getElementById('logoutBtn').addEventListener('click', async () => {
+ await chrome.storage.local.remove(['encryptid_token']);
+ showStatus('Signed out', 'success');
+ await updateAuthUI();
+});
+
+// Save settings
+document.getElementById('saveBtn').addEventListener('click', async () => {
+ const host = document.getElementById('host').value.trim().replace(/\/+$/, '');
+ const notebookId = document.getElementById('defaultNotebook').value;
+
+ await chrome.storage.sync.set({ rnotesHost: host || DEFAULT_HOST });
+ await chrome.storage.local.set({ lastNotebookId: notebookId });
+
+ showStatus('Settings saved', 'success');
+});
+
+// Test connection
+document.getElementById('testBtn').addEventListener('click', async () => {
+ const host = document.getElementById('host').value.trim().replace(/\/+$/, '') || DEFAULT_HOST;
+ const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
+
+ try {
+ const headers = {};
+ if (encryptid_token) {
+ headers['Authorization'] = `Bearer ${encryptid_token}`;
+ }
+
+ const response = await fetch(`${host}/api/notebooks`, { headers });
+
+ if (response.ok) {
+ const data = await response.json();
+ showStatus(`Connected! Found ${data.length || 0} notebooks.`, 'success');
+ } else if (response.status === 401) {
+ showStatus('Connected but not authenticated. Sign in first.', 'error');
+ } else {
+ showStatus(`Connection failed: ${response.status}`, 'error');
+ }
+ } catch (err) {
+ showStatus(`Cannot connect: ${err.message}`, 'error');
+ }
+});
+
+// Default notebook change
+document.getElementById('defaultNotebook').addEventListener('change', async (e) => {
+ await chrome.storage.local.set({ lastNotebookId: e.target.value });
+});
+
+// Init
+document.addEventListener('DOMContentLoaded', loadSettings);
diff --git a/modules/rdocs/browser-extension/parakeet-offline.js b/modules/rdocs/browser-extension/parakeet-offline.js
new file mode 100644
index 00000000..2aa4443f
--- /dev/null
+++ b/modules/rdocs/browser-extension/parakeet-offline.js
@@ -0,0 +1,147 @@
+/**
+ * Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2).
+ * Loaded at runtime from CDN. Model ~634 MB (int8) on first download,
+ * cached in IndexedDB after. Works fully offline after first download.
+ *
+ * Port of src/lib/parakeetOffline.ts for the browser extension.
+ */
+
+const CACHE_KEY = 'parakeet-offline-cached';
+
+// Singleton model — don't reload on subsequent calls
+let cachedModel = null;
+let loadingPromise = null;
+
+/**
+ * Check if the Parakeet model has been downloaded before.
+ */
+function isModelCached() {
+ try {
+ return localStorage.getItem(CACHE_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Detect WebGPU availability.
+ */
+async function detectWebGPU() {
+ if (!navigator.gpu) return false;
+ try {
+ const adapter = await navigator.gpu.requestAdapter();
+ return !!adapter;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Get or create the Parakeet model singleton.
+ * @param {function} onProgress - callback({ status, progress, file, message })
+ */
+async function getModel(onProgress) {
+ if (cachedModel) return cachedModel;
+ if (loadingPromise) return loadingPromise;
+
+ loadingPromise = (async () => {
+ onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' });
+
+ // Dynamic import from CDN at runtime
+ const { fromHub } = await import('https://esm.sh/parakeet.js@1.1.2');
+
+ const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm';
+ const fileProgress = {};
+
+ const model = await fromHub('parakeet-tdt-0.6b-v2', {
+ backend,
+ progress: ({ file, loaded, total }) => {
+ fileProgress[file] = { loaded, total };
+
+ let totalBytes = 0;
+ let loadedBytes = 0;
+ for (const fp of Object.values(fileProgress)) {
+ totalBytes += fp.total || 0;
+ loadedBytes += fp.loaded || 0;
+ }
+
+ if (totalBytes > 0) {
+ const pct = Math.round((loadedBytes / totalBytes) * 100);
+ onProgress?.({
+ status: 'downloading',
+ progress: pct,
+ file,
+ message: `Downloading model... ${pct}%`,
+ });
+ }
+ },
+ });
+
+ localStorage.setItem(CACHE_KEY, 'true');
+ onProgress?.({ status: 'loading', message: 'Model loaded' });
+
+ cachedModel = model;
+ loadingPromise = null;
+ return model;
+ })();
+
+ return loadingPromise;
+}
+
+/**
+ * Decode an audio Blob to Float32Array at 16 kHz mono.
+ */
+async function decodeAudioBlob(blob) {
+ const arrayBuffer = await blob.arrayBuffer();
+ const audioCtx = new AudioContext({ sampleRate: 16000 });
+ try {
+ const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
+
+ if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) {
+ return audioBuffer.getChannelData(0);
+ }
+
+ // Resample via OfflineAudioContext
+ const numSamples = Math.ceil(audioBuffer.duration * 16000);
+ const offlineCtx = new OfflineAudioContext(1, numSamples, 16000);
+ const source = offlineCtx.createBufferSource();
+ source.buffer = audioBuffer;
+ source.connect(offlineCtx.destination);
+ source.start();
+ const resampled = await offlineCtx.startRendering();
+ return resampled.getChannelData(0);
+ } finally {
+ await audioCtx.close();
+ }
+}
+
+/**
+ * Transcribe an audio Blob offline using Parakeet in the browser.
+ * First call downloads the model (~634 MB). Subsequent calls use cached.
+ *
+ * @param {Blob} audioBlob
+ * @param {function} onProgress - callback({ status, progress, file, message })
+ * @returns {Promise
} transcribed text
+ */
+async function transcribeOffline(audioBlob, onProgress) {
+ const model = await getModel(onProgress);
+
+ onProgress?.({ status: 'transcribing', message: 'Transcribing audio...' });
+
+ const audioData = await decodeAudioBlob(audioBlob);
+
+ const result = await model.transcribe(audioData, 16000, {
+ returnTimestamps: false,
+ enableProfiling: false,
+ });
+
+ const text = result.utterance_text?.trim() || '';
+ onProgress?.({ status: 'done', message: 'Transcription complete' });
+ return text;
+}
+
+// Export for use in voice.js (loaded as ES module)
+window.ParakeetOffline = {
+ isModelCached,
+ transcribeOffline,
+};
diff --git a/modules/rdocs/browser-extension/popup.html b/modules/rdocs/browser-extension/popup.html
new file mode 100644
index 00000000..dcb72a9c
--- /dev/null
+++ b/modules/rdocs/browser-extension/popup.html
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/rdocs/browser-extension/popup.js b/modules/rdocs/browser-extension/popup.js
new file mode 100644
index 00000000..4a9f1f7d
--- /dev/null
+++ b/modules/rdocs/browser-extension/popup.js
@@ -0,0 +1,328 @@
+const DEFAULT_HOST = 'https://rnotes.online';
+
+let currentTab = null;
+let selectedText = '';
+let selectedHtml = '';
+
+// --- Helpers ---
+
+async function getSettings() {
+ const result = await chrome.storage.sync.get(['rnotesHost']);
+ return {
+ host: result.rnotesHost || DEFAULT_HOST,
+ };
+}
+
+async function getToken() {
+ const result = await chrome.storage.local.get(['encryptid_token']);
+ return result.encryptid_token || null;
+}
+
+function decodeToken(token) {
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ // Check expiry
+ if (payload.exp && payload.exp * 1000 < Date.now()) {
+ return null; // expired
+ }
+ return payload;
+ } catch {
+ return null;
+ }
+}
+
+function parseTags(tagString) {
+ if (!tagString || !tagString.trim()) return [];
+ return tagString.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
+}
+
+function showStatus(message, type) {
+ const el = document.getElementById('status');
+ el.textContent = message;
+ el.className = `status ${type}`;
+ if (type === 'success') {
+ setTimeout(() => { el.className = 'status'; }, 3000);
+ }
+}
+
+// --- API calls ---
+
+async function createNote(data) {
+ const token = await getToken();
+ const settings = await getSettings();
+
+ const body = {
+ title: data.title,
+ content: data.content,
+ type: data.type || 'CLIP',
+ url: data.url,
+ };
+
+ const notebookId = document.getElementById('notebook').value;
+ if (notebookId) body.notebookId = notebookId;
+
+ const tags = parseTags(document.getElementById('tags').value);
+ if (tags.length > 0) body.tags = tags;
+
+ const response = await fetch(`${settings.host}/api/notes`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`${response.status}: ${text}`);
+ }
+
+ return response.json();
+}
+
+async function fetchNotebooks() {
+ const token = await getToken();
+ const settings = await getSettings();
+
+ const response = await fetch(`${settings.host}/api/notebooks`, {
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) return [];
+ const data = await response.json();
+ return Array.isArray(data) ? data : [];
+}
+
+// --- UI ---
+
+async function populateNotebooks() {
+ const select = document.getElementById('notebook');
+ try {
+ const notebooks = await fetchNotebooks();
+ // Keep the "No notebook" option
+ for (const nb of notebooks) {
+ const option = document.createElement('option');
+ option.value = nb.id;
+ option.textContent = nb.title;
+ select.appendChild(option);
+ }
+
+ // Restore last used notebook
+ const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
+ if (lastNotebookId) {
+ select.value = lastNotebookId;
+ }
+ } catch (err) {
+ console.error('Failed to load notebooks:', err);
+ }
+}
+
+// Save last used notebook when changed
+function setupNotebookMemory() {
+ document.getElementById('notebook').addEventListener('change', (e) => {
+ chrome.storage.local.set({ lastNotebookId: e.target.value });
+ });
+}
+
+async function init() {
+ // Get current tab
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+ currentTab = tab;
+
+ // Display page info
+ document.getElementById('pageTitle').textContent = tab.title || 'Untitled';
+ document.getElementById('pageUrl').textContent = tab.url || '';
+
+ // Check auth
+ const token = await getToken();
+ const claims = token ? decodeToken(token) : null;
+
+ if (!claims) {
+ document.getElementById('userStatus').textContent = 'Not signed in';
+ document.getElementById('userStatus').classList.add('not-authed');
+ document.getElementById('authWarning').style.display = 'block';
+ return;
+ }
+
+ document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated';
+ document.getElementById('authWarning').style.display = 'none';
+
+ // Enable buttons
+ document.getElementById('clipPageBtn').disabled = false;
+ document.getElementById('unlockBtn').disabled = false;
+ document.getElementById('voiceBtn').disabled = false;
+
+ // Load notebooks
+ await populateNotebooks();
+ setupNotebookMemory();
+
+ // Detect text selection
+ try {
+ const [result] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: () => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
+ return { text: '', html: '' };
+ }
+ const range = selection.getRangeAt(0);
+ const div = document.createElement('div');
+ div.appendChild(range.cloneContents());
+ return { text: selection.toString(), html: div.innerHTML };
+ },
+ });
+
+ if (result?.result?.text) {
+ selectedText = result.result.text;
+ selectedHtml = result.result.html;
+ document.getElementById('clipSelectionBtn').disabled = false;
+ }
+ } catch (err) {
+ // Can't access some pages (chrome://, etc.)
+ console.warn('Cannot access page content:', err);
+ }
+}
+
+// --- Event handlers ---
+
+document.getElementById('clipPageBtn').addEventListener('click', async () => {
+ const btn = document.getElementById('clipPageBtn');
+ btn.disabled = true;
+ showStatus('Clipping page...', 'loading');
+
+ try {
+ // Get page HTML content
+ let pageContent = '';
+ try {
+ const [result] = await chrome.scripting.executeScript({
+ target: { tabId: currentTab.id },
+ func: () => document.body.innerHTML,
+ });
+ pageContent = result?.result || '';
+ } catch {
+ // Fallback: just use URL as content
+ pageContent = `Clipped from ${currentTab.url}
`;
+ }
+
+ const note = await createNote({
+ title: currentTab.title || 'Untitled Clip',
+ content: pageContent,
+ type: 'CLIP',
+ url: currentTab.url,
+ });
+
+ showStatus(`Clipped! Note saved.`, 'success');
+
+ // Notify background worker
+ chrome.runtime.sendMessage({
+ type: 'notify',
+ title: 'Page Clipped',
+ message: `"${currentTab.title}" saved to rNotes`,
+ });
+ } catch (err) {
+ showStatus(`Error: ${err.message}`, 'error');
+ } finally {
+ btn.disabled = false;
+ }
+});
+
+document.getElementById('clipSelectionBtn').addEventListener('click', async () => {
+ const btn = document.getElementById('clipSelectionBtn');
+ btn.disabled = true;
+ showStatus('Clipping selection...', 'loading');
+
+ try {
+ const content = selectedHtml || `${selectedText}
`;
+ const note = await createNote({
+ title: `Selection from ${currentTab.title || 'page'}`,
+ content: content,
+ type: 'CLIP',
+ url: currentTab.url,
+ });
+
+ showStatus(`Selection clipped!`, 'success');
+
+ chrome.runtime.sendMessage({
+ type: 'notify',
+ title: 'Selection Clipped',
+ message: `Saved to rNotes`,
+ });
+ } catch (err) {
+ showStatus(`Error: ${err.message}`, 'error');
+ } finally {
+ btn.disabled = false;
+ }
+});
+
+document.getElementById('unlockBtn').addEventListener('click', async () => {
+ const btn = document.getElementById('unlockBtn');
+ btn.disabled = true;
+ showStatus('Unlocking article...', 'loading');
+
+ try {
+ const token = await getToken();
+ const settings = await getSettings();
+
+ const response = await fetch(`${settings.host}/api/articles/unlock`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify({ url: currentTab.url }),
+ });
+
+ const result = await response.json();
+
+ if (result.success && result.archiveUrl) {
+ // Also save as a note
+ await createNote({
+ title: currentTab.title || 'Unlocked Article',
+ content: `Unlocked via ${result.strategy}
Original: ${currentTab.url}
Archive: ${result.archiveUrl}
`,
+ type: 'CLIP',
+ url: currentTab.url,
+ });
+
+ showStatus(`Unlocked via ${result.strategy}! Opening...`, 'success');
+
+ // Open archive in new tab
+ chrome.tabs.create({ url: result.archiveUrl });
+ } else {
+ showStatus(result.error || 'No archived version found', 'error');
+ }
+ } catch (err) {
+ showStatus(`Error: ${err.message}`, 'error');
+ } finally {
+ btn.disabled = false;
+ }
+});
+
+document.getElementById('voiceBtn').addEventListener('click', async () => {
+ // Open rVoice PWA page in a popup window (supports PiP pop-out)
+ const settings = await getSettings();
+ chrome.windows.create({
+ url: `${settings.host}/voice`,
+ type: 'popup',
+ width: 400,
+ height: 600,
+ focused: true,
+ });
+ // Close the current popup
+ window.close();
+});
+
+document.getElementById('optionsLink').addEventListener('click', (e) => {
+ e.preventDefault();
+ chrome.runtime.openOptionsPage();
+});
+
+document.getElementById('openSettings')?.addEventListener('click', (e) => {
+ e.preventDefault();
+ chrome.runtime.openOptionsPage();
+});
+
+// Init on load
+document.addEventListener('DOMContentLoaded', init);
diff --git a/modules/rdocs/browser-extension/voice.html b/modules/rdocs/browser-extension/voice.html
new file mode 100644
index 00000000..0da0f251
--- /dev/null
+++ b/modules/rdocs/browser-extension/voice.html
@@ -0,0 +1,414 @@
+
+
+
+
+
+
+
+
+
+
+ Sign in via rNotes Clipper settings first.
+
+
+
+
Ready
+
+
00:00
+
+
+ Live transcribe
+
+
+
+
+
+
+
+
+
Transcript
+
+ Transcribing...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Space to record · Esc to close · Offline ready
+
+
+
+
+
+
diff --git a/modules/rdocs/browser-extension/voice.js b/modules/rdocs/browser-extension/voice.js
new file mode 100644
index 00000000..9c94767f
--- /dev/null
+++ b/modules/rdocs/browser-extension/voice.js
@@ -0,0 +1,610 @@
+const DEFAULT_HOST = 'https://rnotes.online';
+
+// --- State ---
+let state = 'idle'; // idle | recording | processing | done
+let mediaRecorder = null;
+let audioChunks = [];
+let timerInterval = null;
+let startTime = 0;
+let audioBlob = null;
+let audioUrl = null;
+let transcript = '';
+let liveTranscript = ''; // accumulated from Web Speech API
+let uploadedFileUrl = '';
+let uploadedMimeType = '';
+let uploadedFileSize = 0;
+let duration = 0;
+
+// Web Speech API
+let recognition = null;
+let speechSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
+
+// --- DOM refs ---
+const recBtn = document.getElementById('recBtn');
+const timerEl = document.getElementById('timer');
+const statusLabel = document.getElementById('statusLabel');
+const transcriptArea = document.getElementById('transcriptArea');
+const transcriptText = document.getElementById('transcriptText');
+const liveIndicator = document.getElementById('liveIndicator');
+const audioPreview = document.getElementById('audioPreview');
+const audioPlayer = document.getElementById('audioPlayer');
+const notebookSelect = document.getElementById('notebook');
+const postActions = document.getElementById('postActions');
+const saveBtn = document.getElementById('saveBtn');
+const discardBtn = document.getElementById('discardBtn');
+const copyBtn = document.getElementById('copyBtn');
+const statusBar = document.getElementById('statusBar');
+const authWarning = document.getElementById('authWarning');
+const closeBtn = document.getElementById('closeBtn');
+
+// --- Helpers ---
+
+async function getSettings() {
+ const result = await chrome.storage.sync.get(['rnotesHost']);
+ return { host: result.rnotesHost || DEFAULT_HOST };
+}
+
+async function getToken() {
+ const result = await chrome.storage.local.get(['encryptid_token']);
+ return result.encryptid_token || null;
+}
+
+function decodeToken(token) {
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ if (payload.exp && payload.exp * 1000 < Date.now()) return null;
+ return payload;
+ } catch { return null; }
+}
+
+function formatTime(seconds) {
+ const m = Math.floor(seconds / 60).toString().padStart(2, '0');
+ const s = (seconds % 60).toString().padStart(2, '0');
+ return `${m}:${s}`;
+}
+
+function setStatusLabel(text, cls) {
+ statusLabel.textContent = text;
+ statusLabel.className = `status-label ${cls}`;
+}
+
+function showStatusBar(message, type) {
+ statusBar.textContent = message;
+ statusBar.className = `status-bar visible ${type}`;
+ if (type === 'success') {
+ setTimeout(() => { statusBar.className = 'status-bar'; }, 3000);
+ }
+}
+
+// --- Parakeet progress UI ---
+
+const progressArea = document.getElementById('progressArea');
+const progressLabel = document.getElementById('progressLabel');
+const progressFill = document.getElementById('progressFill');
+
+function showParakeetProgress(p) {
+ if (!progressArea) return;
+ progressArea.classList.add('visible');
+
+ if (p.message) {
+ progressLabel.textContent = p.message;
+ }
+
+ if (p.status === 'downloading' && p.progress !== undefined) {
+ progressFill.style.width = `${p.progress}%`;
+ } else if (p.status === 'transcribing') {
+ progressFill.style.width = '100%';
+ } else if (p.status === 'loading') {
+ progressFill.style.width = '0%';
+ }
+}
+
+function hideParakeetProgress() {
+ if (progressArea) {
+ progressArea.classList.remove('visible');
+ progressFill.style.width = '0%';
+ }
+}
+
+// --- Notebook loader ---
+
+async function loadNotebooks() {
+ const token = await getToken();
+ if (!token) return;
+ const settings = await getSettings();
+
+ try {
+ const res = await fetch(`${settings.host}/api/notebooks`, {
+ headers: { 'Authorization': `Bearer ${token}` },
+ });
+ if (!res.ok) return;
+ const notebooks = await res.json();
+
+ for (const nb of notebooks) {
+ const opt = document.createElement('option');
+ opt.value = nb.id;
+ opt.textContent = nb.title;
+ notebookSelect.appendChild(opt);
+ }
+
+ // Restore last used
+ const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
+ if (lastNotebookId) notebookSelect.value = lastNotebookId;
+ } catch (err) {
+ console.error('Failed to load notebooks:', err);
+ }
+}
+
+notebookSelect.addEventListener('change', (e) => {
+ chrome.storage.local.set({ lastNotebookId: e.target.value });
+});
+
+// --- Live transcription (Web Speech API) ---
+
+function startLiveTranscription() {
+ if (!speechSupported) return;
+
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+ recognition = new SpeechRecognition();
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ recognition.lang = 'en-US';
+
+ let finalizedText = '';
+
+ recognition.onresult = (event) => {
+ let interimText = '';
+ // Rebuild finalized text from all final results
+ finalizedText = '';
+ for (let i = 0; i < event.results.length; i++) {
+ const result = event.results[i];
+ if (result.isFinal) {
+ finalizedText += result[0].transcript.trim() + ' ';
+ } else {
+ interimText += result[0].transcript;
+ }
+ }
+
+ liveTranscript = finalizedText.trim();
+
+ // Update the live transcript display
+ updateLiveDisplay(finalizedText.trim(), interimText.trim());
+ };
+
+ recognition.onerror = (event) => {
+ if (event.error !== 'aborted' && event.error !== 'no-speech') {
+ console.warn('Speech recognition error:', event.error);
+ }
+ };
+
+ // Auto-restart on end (Chrome stops after ~60s of silence)
+ recognition.onend = () => {
+ if (state === 'recording' && recognition) {
+ try { recognition.start(); } catch {}
+ }
+ };
+
+ try {
+ recognition.start();
+ if (liveIndicator) liveIndicator.classList.add('visible');
+ } catch (err) {
+ console.warn('Could not start speech recognition:', err);
+ speechSupported = false;
+ }
+}
+
+function stopLiveTranscription() {
+ if (recognition) {
+ const ref = recognition;
+ recognition = null;
+ try { ref.stop(); } catch {}
+ }
+ if (liveIndicator) liveIndicator.classList.remove('visible');
+}
+
+function updateLiveDisplay(finalText, interimText) {
+ if (state !== 'recording') return;
+
+ // Show transcript area while recording
+ transcriptArea.classList.add('visible');
+
+ let html = '';
+ if (finalText) {
+ html += `${escapeHtml(finalText)}`;
+ }
+ if (interimText) {
+ html += `${escapeHtml(interimText)}`;
+ }
+ if (!finalText && !interimText) {
+ html = 'Listening...';
+ }
+ transcriptText.innerHTML = html;
+
+ // Auto-scroll
+ transcriptText.scrollTop = transcriptText.scrollHeight;
+}
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// --- Recording ---
+
+async function startRecording() {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
+ ? 'audio/webm;codecs=opus'
+ : 'audio/webm';
+
+ mediaRecorder = new MediaRecorder(stream, { mimeType });
+ audioChunks = [];
+ liveTranscript = '';
+
+ mediaRecorder.ondataavailable = (e) => {
+ if (e.data.size > 0) audioChunks.push(e.data);
+ };
+
+ mediaRecorder.start(1000);
+ startTime = Date.now();
+ state = 'recording';
+
+ // UI updates
+ recBtn.classList.add('recording');
+ timerEl.classList.add('recording');
+ setStatusLabel('Recording', 'recording');
+ postActions.style.display = 'none';
+ audioPreview.classList.remove('visible');
+ statusBar.className = 'status-bar';
+
+ // Show transcript area with listening placeholder
+ if (speechSupported) {
+ transcriptArea.classList.add('visible');
+ transcriptText.innerHTML = 'Listening...';
+ } else {
+ transcriptArea.classList.remove('visible');
+ }
+
+ timerInterval = setInterval(() => {
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
+ timerEl.textContent = formatTime(elapsed);
+ }, 1000);
+
+ // Start live transcription alongside recording
+ startLiveTranscription();
+
+ } catch (err) {
+ showStatusBar(err.message || 'Microphone access denied', 'error');
+ }
+}
+
+async function stopRecording() {
+ if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
+
+ clearInterval(timerInterval);
+ timerInterval = null;
+ duration = Math.floor((Date.now() - startTime) / 1000);
+
+ // Capture live transcript before stopping recognition
+ const capturedLiveTranscript = liveTranscript;
+
+ // Stop live transcription
+ stopLiveTranscription();
+
+ state = 'processing';
+ recBtn.classList.remove('recording');
+ timerEl.classList.remove('recording');
+ setStatusLabel('Processing...', 'processing');
+
+ // Stop recorder and collect blob
+ audioBlob = await new Promise((resolve) => {
+ mediaRecorder.onstop = () => {
+ mediaRecorder.stream.getTracks().forEach(t => t.stop());
+ resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType }));
+ };
+ mediaRecorder.stop();
+ });
+
+ // Show audio preview
+ if (audioUrl) URL.revokeObjectURL(audioUrl);
+ audioUrl = URL.createObjectURL(audioBlob);
+ audioPlayer.src = audioUrl;
+ audioPreview.classList.add('visible');
+
+ // Show live transcript while we process (if we have one)
+ transcriptArea.classList.add('visible');
+ if (capturedLiveTranscript) {
+ transcriptText.textContent = capturedLiveTranscript;
+ showStatusBar('Improving transcript...', 'loading');
+ } else {
+ transcriptText.innerHTML = 'Transcribing...';
+ showStatusBar('Uploading & transcribing...', 'loading');
+ }
+
+ // Upload audio file
+ const token = await getToken();
+ const settings = await getSettings();
+
+ try {
+ const uploadForm = new FormData();
+ uploadForm.append('file', audioBlob, 'voice-note.webm');
+
+ const uploadRes = await fetch(`${settings.host}/api/uploads`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ body: uploadForm,
+ });
+
+ if (!uploadRes.ok) throw new Error('Upload failed');
+
+ const uploadResult = await uploadRes.json();
+ uploadedFileUrl = uploadResult.url;
+ uploadedMimeType = uploadResult.mimeType;
+ uploadedFileSize = uploadResult.size;
+
+ // --- Three-tier transcription cascade ---
+
+ // Tier 1: Batch API (Whisper on server — highest quality)
+ let bestTranscript = '';
+ try {
+ showStatusBar('Transcribing via server...', 'loading');
+ const transcribeForm = new FormData();
+ transcribeForm.append('audio', audioBlob, 'voice-note.webm');
+
+ const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ body: transcribeForm,
+ });
+
+ if (transcribeRes.ok) {
+ const transcribeResult = await transcribeRes.json();
+ bestTranscript = transcribeResult.text || '';
+ }
+ } catch {
+ console.warn('Tier 1 (batch API) unavailable');
+ }
+
+ // Tier 2: Live transcript from Web Speech API (already captured)
+ if (!bestTranscript && capturedLiveTranscript) {
+ bestTranscript = capturedLiveTranscript;
+ }
+
+ // Tier 3: Offline Parakeet.js (NVIDIA, runs in browser)
+ if (!bestTranscript && window.ParakeetOffline) {
+ try {
+ showStatusBar('Transcribing offline (Parakeet)...', 'loading');
+ bestTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
+ showParakeetProgress(p);
+ });
+ hideParakeetProgress();
+ } catch (offlineErr) {
+ console.warn('Tier 3 (Parakeet offline) failed:', offlineErr);
+ hideParakeetProgress();
+ }
+ }
+
+ transcript = bestTranscript;
+
+ // Show transcript (editable)
+ if (transcript) {
+ transcriptText.textContent = transcript;
+ } else {
+ transcriptText.innerHTML = 'No transcript available - you can type one here';
+ }
+
+ state = 'done';
+ setStatusLabel('Done', 'done');
+ postActions.style.display = 'flex';
+ statusBar.className = 'status-bar';
+
+ } catch (err) {
+ // On upload error, try offline transcription directly
+ let fallbackTranscript = capturedLiveTranscript || '';
+
+ if (!fallbackTranscript && window.ParakeetOffline) {
+ try {
+ showStatusBar('Upload failed, transcribing offline...', 'loading');
+ fallbackTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
+ showParakeetProgress(p);
+ });
+ hideParakeetProgress();
+ } catch {
+ hideParakeetProgress();
+ }
+ }
+
+ transcript = fallbackTranscript;
+ if (transcript) {
+ transcriptText.textContent = transcript;
+ }
+
+ showStatusBar(`Error: ${err.message}`, 'error');
+ state = 'done';
+ setStatusLabel('Error', 'idle');
+ postActions.style.display = 'flex';
+ }
+}
+
+function toggleRecording() {
+ if (state === 'idle' || state === 'done') {
+ startRecording();
+ } else if (state === 'recording') {
+ stopRecording();
+ }
+ // Ignore clicks while processing
+}
+
+// --- Save to rNotes ---
+
+async function saveToRNotes() {
+ saveBtn.disabled = true;
+ showStatusBar('Saving to rNotes...', 'loading');
+
+ const token = await getToken();
+ const settings = await getSettings();
+
+ // Get current transcript text (user may have edited it)
+ const editedTranscript = transcriptText.textContent.trim();
+ const isPlaceholder = transcriptText.querySelector('.placeholder') !== null;
+ const finalTranscript = isPlaceholder ? '' : editedTranscript;
+
+ const now = new Date();
+ const timeStr = now.toLocaleString('en-US', {
+ month: 'short', day: 'numeric',
+ hour: 'numeric', minute: '2-digit',
+ hour12: true
+ });
+
+ const body = {
+ title: `Voice note - ${timeStr}`,
+ content: finalTranscript
+ ? `${finalTranscript.replace(/\n/g, '
')}
`
+ : 'Voice recording (no transcript)
',
+ type: 'AUDIO',
+ mimeType: uploadedMimeType || 'audio/webm',
+ fileUrl: uploadedFileUrl,
+ fileSize: uploadedFileSize,
+ duration: duration,
+ tags: ['voice'],
+ };
+
+ const notebookId = notebookSelect.value;
+ if (notebookId) body.notebookId = notebookId;
+
+ try {
+ const res = await fetch(`${settings.host}/api/notes`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`${res.status}: ${text}`);
+ }
+
+ showStatusBar('Saved to rNotes!', 'success');
+
+ // Notify
+ chrome.runtime.sendMessage({
+ type: 'notify',
+ title: 'Voice Note Saved',
+ message: `${formatTime(duration)} recording saved to rNotes`,
+ });
+
+ // Reset after short delay
+ setTimeout(resetState, 1500);
+
+ } catch (err) {
+ showStatusBar(`Save failed: ${err.message}`, 'error');
+ } finally {
+ saveBtn.disabled = false;
+ }
+}
+
+// --- Copy to clipboard ---
+
+async function copyTranscript() {
+ const text = transcriptText.textContent.trim();
+ if (!text || transcriptText.querySelector('.placeholder')) {
+ showStatusBar('No transcript to copy', 'error');
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(text);
+ showStatusBar('Copied to clipboard', 'success');
+ } catch {
+ showStatusBar('Copy failed', 'error');
+ }
+}
+
+// --- Discard ---
+
+function resetState() {
+ state = 'idle';
+ mediaRecorder = null;
+ audioChunks = [];
+ audioBlob = null;
+ transcript = '';
+ liveTranscript = '';
+ uploadedFileUrl = '';
+ uploadedMimeType = '';
+ uploadedFileSize = 0;
+ duration = 0;
+
+ stopLiveTranscription();
+
+ if (audioUrl) {
+ URL.revokeObjectURL(audioUrl);
+ audioUrl = null;
+ }
+
+ timerEl.textContent = '00:00';
+ timerEl.classList.remove('recording');
+ recBtn.classList.remove('recording');
+ setStatusLabel('Ready', 'idle');
+ postActions.style.display = 'none';
+ audioPreview.classList.remove('visible');
+ transcriptArea.classList.remove('visible');
+ hideParakeetProgress();
+ statusBar.className = 'status-bar';
+}
+
+// --- Keyboard shortcuts ---
+
+document.addEventListener('keydown', (e) => {
+ // Space bar: toggle recording (unless editing transcript)
+ if (e.code === 'Space' && document.activeElement !== transcriptText) {
+ e.preventDefault();
+ toggleRecording();
+ }
+ // Escape: close window
+ if (e.code === 'Escape') {
+ window.close();
+ }
+ // Ctrl+Enter: save (when in done state)
+ if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') {
+ e.preventDefault();
+ saveToRNotes();
+ }
+});
+
+// Clear placeholder on focus
+transcriptText.addEventListener('focus', () => {
+ const ph = transcriptText.querySelector('.placeholder');
+ if (ph) transcriptText.textContent = '';
+});
+
+// --- Event listeners ---
+
+recBtn.addEventListener('click', toggleRecording);
+saveBtn.addEventListener('click', saveToRNotes);
+discardBtn.addEventListener('click', resetState);
+copyBtn.addEventListener('click', copyTranscript);
+closeBtn.addEventListener('click', () => window.close());
+
+// --- Init ---
+
+async function init() {
+ const token = await getToken();
+ const claims = token ? decodeToken(token) : null;
+
+ if (!claims) {
+ authWarning.style.display = 'block';
+ recBtn.style.opacity = '0.3';
+ recBtn.style.pointerEvents = 'none';
+ return;
+ }
+
+ authWarning.style.display = 'none';
+ await loadNotebooks();
+}
+
+document.addEventListener('DOMContentLoaded', init);
diff --git a/modules/rdocs/components/comment-mark.ts b/modules/rdocs/components/comment-mark.ts
new file mode 100644
index 00000000..c27eec89
--- /dev/null
+++ b/modules/rdocs/components/comment-mark.ts
@@ -0,0 +1,49 @@
+/**
+ * TipTap mark extension for inline comments.
+ *
+ * Applies a highlight to selected text and associates it with a comment thread
+ * stored in Automerge. The mark position is synced via Yjs (as part of the doc content),
+ * while the thread data (messages, resolved state) lives in Automerge.
+ */
+
+import { Mark, mergeAttributes } from '@tiptap/core';
+
+export const CommentMark = Mark.create({
+ name: 'comment',
+
+ addAttributes() {
+ return {
+ threadId: { default: null },
+ resolved: { default: false },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-thread-id]',
+ getAttrs: (el) => {
+ const element = el as HTMLElement;
+ return {
+ threadId: element.getAttribute('data-thread-id'),
+ resolved: element.getAttribute('data-resolved') === 'true',
+ };
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'span',
+ mergeAttributes(
+ {
+ class: `comment-highlight${HTMLAttributes.resolved ? ' resolved' : ''}`,
+ 'data-thread-id': HTMLAttributes.threadId,
+ 'data-resolved': HTMLAttributes.resolved ? 'true' : 'false',
+ },
+ ),
+ 0,
+ ];
+ },
+});
diff --git a/modules/rdocs/components/comment-panel.ts b/modules/rdocs/components/comment-panel.ts
new file mode 100644
index 00000000..d7f711f6
--- /dev/null
+++ b/modules/rdocs/components/comment-panel.ts
@@ -0,0 +1,918 @@
+/**
+ * — Right sidebar panel for viewing/managing inline comments.
+ *
+ * Shows threaded comments anchored to highlighted text in the editor.
+ * Comment thread data is stored in Automerge, while the highlight mark
+ * position is stored in Yjs (part of the document content).
+ *
+ * Supports: demo mode (in-memory), emoji reactions, date reminders.
+ */
+
+import type { Editor } from '@tiptap/core';
+import type { DocumentId } from '../../../shared/local-first/document';
+import { getModuleApiBase } from "../../../shared/url-helpers";
+
+interface CommentMessage {
+ id: string;
+ authorId: string;
+ authorName: string;
+ text: string;
+ createdAt: number;
+}
+
+interface CommentThread {
+ id: string;
+ anchor: string;
+ resolved: boolean;
+ messages: CommentMessage[];
+ createdAt: number;
+ reactions?: Record;
+ reminderAt?: number;
+ reminderId?: string;
+}
+
+interface NotebookDoc {
+ items: Record;
+ [key: string]: any;
+ }>;
+ [key: string]: any;
+}
+
+const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥'];
+
+interface SuggestionEntry {
+ id: string;
+ type: 'insert' | 'delete';
+ text: string;
+ authorId: string;
+ authorName: string;
+ createdAt: number;
+}
+
+class NotesCommentPanel extends HTMLElement {
+ private shadow: ShadowRoot;
+ private _noteId: string | null = null;
+ private _doc: any = null;
+ private _subscribedDocId: string | null = null;
+ private _activeThreadId: string | null = null;
+ private _editor: Editor | null = null;
+ private _demoThreads: Record | null = null;
+ private _space = '';
+ private _suggestions: SuggestionEntry[] = [];
+
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: 'open' });
+ }
+
+ set noteId(v: string | null) { this._noteId = v; this.render(); }
+ set doc(v: any) { this._doc = v; this.render(); }
+ set subscribedDocId(v: string | null) { this._subscribedDocId = v; }
+ set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); }
+ set editor(v: Editor | null) { this._editor = v; }
+ set space(v: string) { this._space = v; }
+ set demoThreads(v: Record | null) {
+ this._demoThreads = v;
+ this.render();
+ }
+ set suggestions(v: SuggestionEntry[]) {
+ this._suggestions = v;
+ this.render();
+ }
+
+ private get isDemo(): boolean {
+ return this._space === 'demo';
+ }
+
+ private getSessionInfo(): { authorName: string; authorId: string } {
+ try {
+ const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
+ const c = sess?.claims;
+ return {
+ authorName: c?.username || c?.displayName || sess?.username || 'Anonymous',
+ authorId: c?.sub || sess?.userId || 'anon',
+ };
+ } catch {
+ return { authorName: 'Anonymous', authorId: 'anon' };
+ }
+ }
+
+ private getThreads(): CommentThread[] {
+ // Demo threads take priority
+ if (this._demoThreads) {
+ return Object.values(this._demoThreads).sort((a, b) => a.createdAt - b.createdAt);
+ }
+ if (!this._doc || !this._noteId) return [];
+ const item = this._doc.items?.[this._noteId];
+ if (!item?.comments) return [];
+ return Object.values(item.comments as Record)
+ .sort((a, b) => a.createdAt - b.createdAt);
+ }
+
+ private dispatchDemoMutation() {
+ if (!this._demoThreads || !this._noteId) return;
+ this.dispatchEvent(new CustomEvent('comment-demo-mutation', {
+ detail: { noteId: this._noteId, threads: { ...this._demoThreads } },
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ private render() {
+ const threads = this.getThreads();
+ const suggestions = this._suggestions || [];
+ if (threads.length === 0 && suggestions.length === 0 && !this._activeThreadId) {
+ this.shadow.innerHTML = '';
+ return;
+ }
+
+ const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
+ const timeAgo = (ts: number) => {
+ const diff = Date.now() - ts;
+ if (diff < 60000) return 'just now';
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
+ return `${Math.floor(diff / 86400000)}d ago`;
+ };
+ const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
+ const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo();
+ const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?';
+ const avatarColor = (id: string) => {
+ let h = 0;
+ for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h);
+ return `hsl(${Math.abs(h) % 360}, 55%, 55%)`;
+ };
+
+ this.shadow.innerHTML = `
+
+
+ `;
+
+ this.wireEvents();
+
+ // Auto-focus new comment textarea
+ requestAnimationFrame(() => {
+ const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement;
+ if (newInput) newInput.focus();
+ });
+ }
+
+ private wireEvents() {
+ // Suggestion accept/reject
+ this.shadow.querySelectorAll('[data-accept-suggestion]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const id = (btn as HTMLElement).dataset.acceptSuggestion;
+ if (id) this.dispatchEvent(new CustomEvent('suggestion-accept', { detail: { suggestionId: id }, bubbles: true, composed: true }));
+ });
+ });
+ this.shadow.querySelectorAll('[data-reject-suggestion]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const id = (btn as HTMLElement).dataset.rejectSuggestion;
+ if (id) this.dispatchEvent(new CustomEvent('suggestion-reject', { detail: { suggestionId: id }, bubbles: true, composed: true }));
+ });
+ });
+
+ // Click suggestion card to scroll editor to it
+ this.shadow.querySelectorAll('.suggestion-card[data-suggestion-id]').forEach(el => {
+ el.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('button')) return;
+ const id = (el as HTMLElement).dataset.suggestionId;
+ if (!id || !this._editor) return;
+ this._editor.state.doc.descendants((node, pos) => {
+ if (!node.isText) return;
+ const mark = node.marks.find(m =>
+ (m.type.name === 'suggestionInsert' || m.type.name === 'suggestionDelete') &&
+ m.attrs.suggestionId === id
+ );
+ if (mark) {
+ this._editor!.commands.setTextSelection(pos);
+ this._editor!.commands.scrollIntoView();
+ return false;
+ }
+ });
+ });
+ });
+
+ // Collapse/expand panel
+ const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]');
+ if (collapseBtn) {
+ collapseBtn.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('.thread, input, textarea, button:not(.collapse-btn)')) return;
+ const panel = this.shadow.getElementById('comment-panel');
+ if (panel) panel.classList.toggle('collapsed');
+ });
+ }
+
+ // Click thread to scroll editor to it
+ this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
+ el.addEventListener('click', (e) => {
+ // Don't handle clicks on inputs/buttons/textareas
+ const target = e.target as HTMLElement;
+ if (target.closest('input, textarea, button')) return;
+ const threadId = (el as HTMLElement).dataset.thread;
+ if (!threadId || !this._editor) return;
+ this._activeThreadId = threadId;
+ this._editor.state.doc.descendants((node, pos) => {
+ if (!node.isText) return;
+ const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
+ if (mark) {
+ this._editor!.commands.setTextSelection(pos);
+ this._editor!.commands.scrollIntoView();
+ return false;
+ }
+ });
+ this.render();
+ });
+ });
+
+ // New comment submit (thread with no messages yet)
+ this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.submitNew;
+ if (!threadId) return;
+ const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement;
+ const text = textarea?.value?.trim();
+ if (!text) return;
+ this.addReply(threadId, text);
+ });
+ });
+
+ // New comment cancel — delete the empty thread
+ this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.cancelNew;
+ if (threadId) this.deleteThread(threadId);
+ });
+ });
+
+ // New comment textarea — Ctrl+Enter to submit, Escape to cancel
+ this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => {
+ textarea.addEventListener('keydown', (e) => {
+ const ke = e as KeyboardEvent;
+ if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) {
+ e.stopPropagation();
+ const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
+ const text = (textarea as HTMLTextAreaElement).value.trim();
+ if (threadId && text) this.addReply(threadId, text);
+ } else if (ke.key === 'Escape') {
+ e.stopPropagation();
+ const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
+ if (threadId) this.deleteThread(threadId);
+ }
+ });
+ textarea.addEventListener('click', (e) => e.stopPropagation());
+ });
+
+ // Reply
+ this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.reply;
+ if (!threadId) return;
+ const input = this.shadow.querySelector(`input[data-thread="${threadId}"]`) as HTMLInputElement;
+ const text = input?.value?.trim();
+ if (!text) return;
+ this.addReply(threadId, text);
+ input.value = '';
+ });
+ });
+
+ // Reply on Enter
+ this.shadow.querySelectorAll('.reply-input').forEach(input => {
+ input.addEventListener('keydown', (e) => {
+ if ((e as KeyboardEvent).key === 'Enter') {
+ e.stopPropagation();
+ const threadId = (input as HTMLInputElement).dataset.thread;
+ const text = (input as HTMLInputElement).value.trim();
+ if (threadId && text) {
+ this.addReply(threadId, text);
+ (input as HTMLInputElement).value = '';
+ }
+ }
+ });
+ input.addEventListener('click', (e) => e.stopPropagation());
+ });
+
+ // Resolve / re-open
+ this.shadow.querySelectorAll('[data-resolve]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.resolve;
+ if (threadId) this.toggleResolve(threadId);
+ });
+ });
+
+ // Delete
+ this.shadow.querySelectorAll('[data-delete]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.delete;
+ if (threadId) this.deleteThread(threadId);
+ });
+ });
+
+ // Reaction pill toggle (existing reaction)
+ this.shadow.querySelectorAll('[data-react-thread]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const el = btn as HTMLElement;
+ this.toggleReaction(el.dataset.reactThread!, el.dataset.reactEmoji!);
+ });
+ });
+
+ // Reaction add "+" button — toggle emoji picker
+ this.shadow.querySelectorAll('[data-react-add]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.reactAdd!;
+ const picker = this.shadow.querySelector(`[data-picker="${threadId}"]`);
+ if (picker) picker.classList.toggle('open');
+ });
+ });
+
+ // Emoji picker buttons
+ this.shadow.querySelectorAll('[data-pick-thread]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const el = btn as HTMLElement;
+ this.toggleReaction(el.dataset.pickThread!, el.dataset.pickEmoji!);
+ // Close picker
+ const picker = this.shadow.querySelector(`[data-picker="${el.dataset.pickThread}"]`);
+ if (picker) picker.classList.remove('open');
+ });
+ });
+
+ // Reminder "set" button
+ this.shadow.querySelectorAll('[data-remind-set]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.remindSet!;
+ const input = this.shadow.querySelector(`[data-remind-input="${threadId}"]`) as HTMLInputElement;
+ if (input) {
+ input.style.display = input.style.display === 'none' ? 'inline-block' : 'none';
+ if (input.style.display !== 'none') input.focus();
+ }
+ });
+ });
+
+ // Reminder date change
+ this.shadow.querySelectorAll('[data-remind-input]').forEach(input => {
+ input.addEventListener('click', (e) => e.stopPropagation());
+ input.addEventListener('change', (e) => {
+ e.stopPropagation();
+ const threadId = (input as HTMLInputElement).dataset.remindInput!;
+ const val = (input as HTMLInputElement).value;
+ if (val) this.setReminder(threadId, new Date(val + 'T09:00:00').getTime());
+ });
+ });
+
+ // Reminder clear
+ this.shadow.querySelectorAll('[data-remind-clear]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.remindClear!;
+ this.clearReminder(threadId);
+ });
+ });
+ }
+
+ private addReply(threadId: string, text: string) {
+ const { authorName, authorId } = this.getSessionInfo();
+ const msg: CommentMessage = {
+ id: `m_${Date.now()}`,
+ authorId,
+ authorName,
+ text,
+ createdAt: Date.now(),
+ };
+
+ if (this._demoThreads) {
+ const thread = this._demoThreads[threadId];
+ if (!thread) return;
+ if (!thread.messages) thread.messages = [];
+ thread.messages.push(msg);
+ this.dispatchDemoMutation();
+ this.render();
+ return;
+ }
+
+ if (!this._noteId || !this._subscribedDocId) return;
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ const noteId = this._noteId;
+ runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => {
+ const item = d.items[noteId];
+ if (!item?.comments?.[threadId]) return;
+ const thread = item.comments[threadId] as any;
+ if (!thread.messages) thread.messages = [];
+ thread.messages.push(msg);
+ });
+ this._doc = runtime.get(this._subscribedDocId as DocumentId);
+ this.render();
+ }
+
+ private toggleReaction(threadId: string, emoji: string) {
+ const { authorId } = this.getSessionInfo();
+
+ if (this._demoThreads) {
+ const thread = this._demoThreads[threadId];
+ if (!thread) return;
+ if (!thread.reactions) thread.reactions = {};
+ if (!thread.reactions[emoji]) thread.reactions[emoji] = [];
+ const idx = thread.reactions[emoji].indexOf(authorId);
+ if (idx >= 0) thread.reactions[emoji].splice(idx, 1);
+ else thread.reactions[emoji].push(authorId);
+ this.dispatchDemoMutation();
+ this.render();
+ return;
+ }
+
+ if (!this._noteId || !this._subscribedDocId) return;
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ const noteId = this._noteId;
+ runtime.change(this._subscribedDocId as DocumentId, 'Toggle reaction', (d: NotebookDoc) => {
+ const item = d.items[noteId];
+ if (!item?.comments?.[threadId]) return;
+ const thread = item.comments[threadId] as any;
+ if (!thread.reactions) thread.reactions = {};
+ if (!thread.reactions[emoji]) thread.reactions[emoji] = [];
+ const users: string[] = thread.reactions[emoji];
+ const idx = users.indexOf(authorId);
+ if (idx >= 0) users.splice(idx, 1);
+ else users.push(authorId);
+ });
+ this._doc = runtime.get(this._subscribedDocId as DocumentId);
+ this.render();
+ }
+
+ private async setReminder(threadId: string, reminderAt: number) {
+ // Set reminder on thread
+ let reminderId: string | undefined;
+
+ // Try creating a reminder via rSchedule API (non-demo only)
+ if (!this.isDemo && this._space) {
+ try {
+ const res = await fetch(`${getModuleApiBase("rschedule")}/api/reminders`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
+ body: JSON.stringify({
+ title: `Comment reminder`,
+ remindAt: new Date(reminderAt).toISOString(),
+ allDay: true,
+ sourceModule: 'rnotes',
+ sourceEntityId: threadId,
+ }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ reminderId = data.id;
+ }
+ } catch {}
+ }
+
+ if (this._demoThreads) {
+ const thread = this._demoThreads[threadId];
+ if (thread) {
+ thread.reminderAt = reminderAt;
+ if (reminderId) thread.reminderId = reminderId;
+ }
+ this.dispatchDemoMutation();
+ this.render();
+ return;
+ }
+
+ if (!this._noteId || !this._subscribedDocId) return;
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ const noteId = this._noteId;
+ runtime.change(this._subscribedDocId as DocumentId, 'Set comment reminder', (d: NotebookDoc) => {
+ const item = d.items[noteId];
+ if (!item?.comments?.[threadId]) return;
+ const thread = item.comments[threadId] as any;
+ thread.reminderAt = reminderAt;
+ if (reminderId) thread.reminderId = reminderId;
+ });
+ this._doc = runtime.get(this._subscribedDocId as DocumentId);
+ this.render();
+ }
+
+ private async clearReminder(threadId: string) {
+ // Get existing reminderId before clearing
+ const threads = this.getThreads();
+ const thread = threads.find(t => t.id === threadId);
+ const reminderId = thread?.reminderId;
+
+ // Delete from rSchedule if exists
+ if (reminderId && !this.isDemo && this._space) {
+ try {
+ await fetch(`${getModuleApiBase("rschedule")}/api/reminders/${reminderId}`, {
+ method: 'DELETE',
+ headers: this.authHeaders(),
+ });
+ } catch {}
+ }
+
+ if (this._demoThreads) {
+ const t = this._demoThreads[threadId];
+ if (t) { delete t.reminderAt; delete t.reminderId; }
+ this.dispatchDemoMutation();
+ this.render();
+ return;
+ }
+
+ if (!this._noteId || !this._subscribedDocId) return;
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ const noteId = this._noteId;
+ runtime.change(this._subscribedDocId as DocumentId, 'Clear comment reminder', (d: NotebookDoc) => {
+ const item = d.items[noteId];
+ if (!item?.comments?.[threadId]) return;
+ const t = item.comments[threadId] as any;
+ delete t.reminderAt;
+ delete t.reminderId;
+ });
+ this._doc = runtime.get(this._subscribedDocId as DocumentId);
+ this.render();
+ }
+
+ private authHeaders(): Record {
+ try {
+ const s = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
+ if (s?.accessToken) return { 'Authorization': 'Bearer ' + s.accessToken };
+ } catch {}
+ return {};
+ }
+
+ private toggleResolve(threadId: string) {
+ if (this._demoThreads) {
+ const thread = this._demoThreads[threadId];
+ if (thread) thread.resolved = !thread.resolved;
+ this.dispatchDemoMutation();
+ // Update editor mark
+ this.updateEditorResolveMark(threadId, this._demoThreads[threadId]?.resolved ?? false);
+ this.render();
+ return;
+ }
+
+ if (!this._noteId || !this._subscribedDocId) return;
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ const noteId = this._noteId;
+ runtime.change(this._subscribedDocId as DocumentId, 'Toggle comment resolve', (d: NotebookDoc) => {
+ const item = d.items[noteId];
+ if (!item?.comments?.[threadId]) return;
+ (item.comments[threadId] as any).resolved = !(item.comments[threadId] as any).resolved;
+ });
+ this._doc = runtime.get(this._subscribedDocId as DocumentId);
+
+ const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId];
+ if (thread) this.updateEditorResolveMark(threadId, thread.resolved);
+ this.render();
+ }
+
+ private updateEditorResolveMark(threadId: string, resolved: boolean) {
+ if (!this._editor) return;
+ this._editor.state.doc.descendants((node, pos) => {
+ if (!node.isText) return;
+ const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
+ if (mark) {
+ const { tr } = this._editor!.state;
+ tr.removeMark(pos, pos + node.nodeSize, mark);
+ tr.addMark(pos, pos + node.nodeSize,
+ this._editor!.schema.marks.comment.create({ threadId, resolved })
+ );
+ this._editor!.view.dispatch(tr);
+ return false;
+ }
+ });
+ }
+
+ private deleteThread(threadId: string) {
+ if (this._demoThreads) {
+ delete this._demoThreads[threadId];
+ this.dispatchDemoMutation();
+ this.removeEditorCommentMark(threadId);
+ if (this._activeThreadId === threadId) this._activeThreadId = null;
+ this.render();
+ return;
+ }
+
+ if (!this._noteId || !this._subscribedDocId) return;
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ const noteId = this._noteId;
+ runtime.change(this._subscribedDocId as DocumentId, 'Delete comment thread', (d: NotebookDoc) => {
+ const item = d.items[noteId];
+ if (item?.comments?.[threadId]) {
+ delete (item.comments as any)[threadId];
+ }
+ });
+ this._doc = runtime.get(this._subscribedDocId as DocumentId);
+
+ this.removeEditorCommentMark(threadId);
+ if (this._activeThreadId === threadId) this._activeThreadId = null;
+ this.render();
+ }
+
+ private removeEditorCommentMark(threadId: string) {
+ if (!this._editor) return;
+ const { state } = this._editor;
+ const { tr } = state;
+ state.doc.descendants((node, pos) => {
+ if (!node.isText) return;
+ const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
+ if (mark) {
+ tr.removeMark(pos, pos + node.nodeSize, mark);
+ }
+ });
+ if (tr.docChanged) {
+ this._editor.view.dispatch(tr);
+ }
+ }
+}
+
+customElements.define('notes-comment-panel', NotesCommentPanel);
diff --git a/modules/rdocs/components/folk-docs-app.ts b/modules/rdocs/components/folk-docs-app.ts
new file mode 100644
index 00000000..b4607dde
--- /dev/null
+++ b/modules/rdocs/components/folk-docs-app.ts
@@ -0,0 +1,4373 @@
+/**
+ * — notebook and note management.
+ *
+ * Browse notebooks, create/edit notes with rich text (Tiptap),
+ * search, tag management.
+ *
+ * Notebook list: REST (GET /api/notebooks)
+ * Notebook detail + notes: Automerge sync via WebSocket
+ * Search: REST (GET /api/notes?q=...)
+ */
+
+import * as Automerge from '@automerge/automerge';
+import { makeDraggableAll } from '../../../shared/draggable';
+import { notebookSchema } from '../schemas';
+import type { DocumentId } from '../../../shared/local-first/document';
+import { getAccessToken } from '../../../shared/components/rstack-identity';
+import { broadcastPresence as sharedBroadcastPresence, startPresenceHeartbeat } from '../../../shared/collab-presence';
+import { Editor } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import Image from '@tiptap/extension-image';
+import TaskList from '@tiptap/extension-task-list';
+import TaskItem from '@tiptap/extension-task-item';
+import Placeholder from '@tiptap/extension-placeholder';
+import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
+import Typography from '@tiptap/extension-typography';
+import Underline from '@tiptap/extension-underline';
+import { common, createLowlight } from 'lowlight';
+import { createSlashCommandPlugin } from './slash-command';
+import type { ImportExportDialog } from './import-export-dialog';
+import { SpeechDictation } from '../../../lib/speech-dictation';
+import { Markdown } from 'tiptap-markdown';
+import { TourEngine } from '../../../shared/tour-engine';
+import * as Y from 'yjs';
+import { IndexeddbPersistence } from 'y-indexeddb';
+import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap';
+import { RSpaceYjsProvider } from '../yjs-ws-provider';
+import { CommentMark } from './comment-mark';
+import { SuggestionInsertMark, SuggestionDeleteMark } from './suggestion-marks';
+import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion } from './suggestion-plugin';
+import './comment-panel';
+
+const lowlight = createLowlight(common);
+
+/** Inline SVG icons for toolbar buttons (16×16, stroke-based, currentColor) */
+const ICONS: Record = {
+ bold: '',
+ italic: '',
+ underline: '',
+ strike: '',
+ code: '',
+ bulletList: '',
+ orderedList: '',
+ taskList: '',
+ blockquote: '',
+ codeBlock: '',
+ horizontalRule: '',
+ link: '',
+ image: '',
+ undo: '',
+ redo: '',
+ mic: '',
+ summarize: '',
+};
+
+interface Notebook {
+ id: string;
+ title: string;
+ description: string;
+ cover_color: string;
+ note_count: string;
+ updated_at: string;
+}
+
+interface Note {
+ id: string;
+ title: string;
+ content: string;
+ content_plain: string;
+ content_format?: 'html' | 'tiptap-json';
+ type: string;
+ tags: string[] | null;
+ is_pinned: boolean;
+ url?: string | null;
+ language?: string | null;
+ fileUrl?: string | null;
+ mimeType?: string | null;
+ duration?: number | null;
+ source_ref?: { source: string; syncStatus?: string; lastSyncedAt?: number };
+ sort_order?: number;
+ created_at: string;
+ updated_at: string;
+}
+
+type NoteType = 'NOTE' | 'CODE' | 'BOOKMARK' | 'CLIP' | 'IMAGE' | 'AUDIO' | 'FILE';
+
+interface CreateNoteOpts {
+ type?: NoteType;
+ title?: string;
+ url?: string;
+ fileUrl?: string;
+ mimeType?: string;
+ duration?: number;
+ language?: string;
+ content?: string;
+ tags?: string[];
+}
+
+/** Shape of Automerge notebook doc (matches PG→Automerge migration) */
+interface NotebookDoc {
+ meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
+ notebook: {
+ id: string; title: string; slug: string; description: string;
+ coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number;
+ };
+ items: Record;
+}
+
+class FolkDocsApp extends HTMLElement {
+ private shadow!: ShadowRoot;
+ private space = "";
+ private notebooks: Notebook[] = [];
+ private selectedNotebook: (Notebook & { notes: Note[] }) | null = null;
+ private selectedNote: Note | null = null;
+ private searchQuery = "";
+ private searchResults: Note[] = [];
+ private typeFilter: NoteType | '' = '';
+ private loading = false;
+ private error = "";
+
+ // Sidebar state
+ private expandedNotebooks = new Set();
+ private notebookNotes = new Map();
+ private sidebarOpen = true;
+ private mobileEditing = false;
+ private _resizeHandler: (() => void) | null = null;
+ private _suggestionSyncTimer: any = null;
+
+ // Zone-based rendering
+ private navZone!: HTMLDivElement;
+ private contentZone!: HTMLDivElement;
+ private metaZone!: HTMLDivElement;
+
+ // Guided tour
+ private _tour!: TourEngine;
+ private static readonly TOUR_STEPS = [
+ { target: '#create-notebook', title: "Create a Notebook", message: "Notebooks organise your notes by topic. Click '+ New Notebook' to create one.", advanceOnClick: true },
+ { target: '.sbt-nb-add', title: "Create a Note", message: "Each notebook has a '+' button to add new notes. Click it to create one.", advanceOnClick: true },
+ { target: '#editor-toolbar', title: "Editor Toolbar", message: "Format text with the toolbar — bold, lists, code blocks, headings, and more. Click Next to continue.", advanceOnClick: false },
+ { target: '[data-cmd="mic"]', title: "Voice Notes", message: "Record voice notes with live transcription. Your words appear as you speak — no uploads needed.", advanceOnClick: false },
+ ];
+
+ // Tiptap editor
+ private editor: Editor | null = null;
+ private editorNoteId: string | null = null;
+ private isRemoteUpdate = false;
+ private editorUpdateTimer: ReturnType | null = null;
+ private dictation: SpeechDictation | null = null;
+
+ // Yjs collaboration state
+ private ydoc: Y.Doc | null = null;
+ private yjsProvider: RSpaceYjsProvider | null = null;
+ private yIndexedDb: IndexeddbPersistence | null = null;
+ private yjsPlainTextTimer: ReturnType | null = null;
+
+ // Comments/suggestions state
+ private suggestingMode = false;
+
+ // Audio recording (AUDIO note view)
+ private audioRecorder: MediaRecorder | null = null;
+ private audioSegments: { id: string; text: string; timestamp: number; isFinal: boolean }[] = [];
+ private audioRecordingStart = 0;
+ private audioRecordingTimer: ReturnType | null = null;
+ private audioRecordingDictation: SpeechDictation | null = null;
+
+ // Automerge sync state (via shared runtime)
+ private doc: Automerge.Doc | null = null;
+ private subscribedDocId: string | null = null;
+ private syncConnected = false;
+ private _offlineUnsub: (() => void) | null = null;
+ private _offlineNotebookUnsubs: (() => void)[] = [];
+
+ // ── Presence indicators ──
+ private _presencePeers: Map = new Map();
+ private _stopPresence: (() => void) | null = null;
+ private _presenceUnsub: (() => void) | null = null;
+ private _presenceGC: ReturnType | null = null;
+
+ // ── Demo data ──
+ private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
+ private _demoThreads = new Map>();
+
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: "open", delegatesFocus: true });
+ this._tour = new TourEngine(
+ this.shadow,
+ FolkDocsApp.TOUR_STEPS,
+ "rdocs_tour_done",
+ () => this.shadow.host as HTMLElement,
+ );
+ }
+
+ connectedCallback() {
+ this.space = this.getAttribute("space") || "demo";
+ this.setupShadow();
+ if (this.space === "demo") { this.loadDemoData(); }
+ else { this.subscribeOfflineRuntime(); this.loadNotebooks(); this.setupPresence(); }
+ // Auto-start tour on first visit
+ if (!localStorage.getItem("rdocs_tour_done")) {
+ setTimeout(() => this._tour.start(), 1200);
+ }
+
+ // Mobile resize handler — sync mobile-editing state on viewport change
+ this._resizeHandler = () => {
+ if (window.innerWidth > 768) {
+ // Switched to desktop — remove mobile-editing so both panels show
+ this.setMobileEditing(false);
+ } else if (this.selectedNote && this.editor) {
+ // Went back to mobile with a note open — restore editor screen
+ this.setMobileEditing(true);
+ }
+ };
+ window.addEventListener('resize', this._resizeHandler);
+ }
+
+ private async subscribeOfflineRuntime() {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ try {
+ // Discover all cached notebooks for this space
+ const docs = await runtime.subscribeModule('notes', 'notebooks', notebookSchema);
+ this.syncConnected = runtime.isOnline;
+
+ // Listen for connection state changes
+ this._offlineUnsub = runtime.onStatusChange((status: string) => {
+ this.syncConnected = status === 'online';
+ });
+
+ // Populate notebook list from cached docs if REST hasn't loaded
+ if (docs.size > 0 && this.notebooks.length === 0) {
+ const fromDocs: Notebook[] = [];
+ for (const [, doc] of docs) {
+ const d = doc as NotebookDoc;
+ if (!d?.notebook?.id) continue;
+ fromDocs.push({
+ id: d.notebook.id, title: d.notebook.title,
+ description: d.notebook.description || '',
+ cover_color: d.notebook.coverColor || '#3b82f6',
+ note_count: String(Object.keys(d.items || {}).length),
+ updated_at: d.notebook.updatedAt ? new Date(d.notebook.updatedAt).toISOString() : new Date().toISOString(),
+ });
+ }
+ if (fromDocs.length > 0) {
+ this.notebooks = fromDocs;
+ this.renderNav();
+ }
+ }
+ } catch {
+ // Runtime unavailable — REST fallback handles data
+ }
+ }
+
+ private setupShadow() {
+ const style = document.createElement('style');
+ style.textContent = this.getStyles();
+
+ const layout = document.createElement('div');
+ layout.id = 'notes-layout';
+
+ this.navZone = document.createElement('div');
+ this.navZone.id = 'nav-zone';
+
+ const rightCol = document.createElement('div');
+ rightCol.className = 'notes-right-col';
+
+ this.contentZone = document.createElement('div');
+ this.contentZone.id = 'content-zone';
+ this.metaZone = document.createElement('div');
+ this.metaZone.id = 'meta-zone';
+
+ rightCol.appendChild(this.contentZone);
+ rightCol.appendChild(this.metaZone);
+
+ // Sidebar reopen tab (lives on layout, outside navZone so it's visible when collapsed)
+ const reopenBtn = document.createElement('button');
+ reopenBtn.id = 'sidebar-reopen';
+ reopenBtn.className = 'sidebar-reopen';
+ reopenBtn.title = 'Show sidebar';
+ reopenBtn.textContent = '\u203A';
+ reopenBtn.addEventListener('click', () => this.toggleSidebar(true));
+
+ layout.appendChild(this.navZone);
+ layout.appendChild(reopenBtn);
+ layout.appendChild(rightCol);
+
+ this.shadow.appendChild(style);
+ this.shadow.appendChild(layout);
+ }
+
+ // ── Demo data ──
+
+ private loadDemoData() {
+ const now = Date.now();
+ const hour = 3600000;
+ const day = 86400000;
+
+ const tripPlanningNotes: Note[] = [
+ {
+ id: "demo-note-1", title: "Pre-trip Preparation",
+ content: `Pre-trip Preparation
Flights & Transfers
- Jul 6: Fly Geneva, shuttle to Chamonix (~1.5h)
- Jul 14: Train Zermatt to Dolomites (Bernina Express, ~6h scenic route)
- Jul 20: Fly home from Innsbruck
Book the Aiguille du Midi cable car tickets at least 2 weeks in advance -- they sell out fast in July.
Travel Documents
- Passports (valid 6+ months)
- EU health insurance cards (EHIC)
- Travel insurance policy (ref: WA-2026-7891)
- Hut reservation confirmations (printed copies)
- Drone registration for Italy
Budget Overview
Total budget: EUR 4,000 across 4 travelers.
Transport: EUR 800 (20%)
+Accommodation: EUR 1200 (30%)
+Activities: EUR 1000 (25%)
+Food: EUR 600 (15%)
+Gear: EUR 400 (10%)
Maya is tracking expenses in rFlows. Current spend: EUR 1,203.
`,
+ content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.",
+ content_format: 'html',
+ type: "NOTE", tags: ["planning", "budget", "transport"], is_pinned: true,
+ created_at: new Date(now - 14 * day).toISOString(), updated_at: new Date(now - hour).toISOString(),
+ },
+ {
+ id: "demo-note-2", title: "Accommodation Research",
+ content: `Accommodation Research
Chamonix (Jul 6-10)
- Refuge du Lac Blanc -- Jul 7, 4 beds, conf #LB2026-234
- Airbnb in town for other nights (~EUR 120/night for 4 pax)
- Consider Hotel Le Morgane if Airbnb falls through
Zermatt (Jul 10-14)
- Hornlihutte (Matterhorn base) -- Waitlisted for Jul 12
- Main accommodation: Apartment near Bahnhofstrasse
- Car-free village, arrive by Glacier Express
Zermatt is expensive. Budget EUR 80-100pp/night minimum. The apartment saves us about 40% vs hotels.
Dolomites (Jul 14-20)
- Rifugio Locatelli -- Jul 15, 4 beds, conf #TRE2026-089
- Val Gardena base: Ortisei area
- Look for agriturismo options for authentic experience
`,
+ content_plain: "Accommodation research for all three destinations: Chamonix, Zermatt, and Dolomites. Includes confirmed bookings, waitlists, and budget estimates.",
+ content_format: 'html',
+ type: "NOTE", tags: ["accommodation", "budget"], is_pinned: false,
+ created_at: new Date(now - 12 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(),
+ },
+ {
+ id: "demo-note-3", title: "Activity Planning",
+ content: `Activity Planning
Hiking Routes
- Lac Blanc (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain.
- Gornergrat Sunrise (Jul 11) -- Take the first train up at 7am, hike down.
- Matterhorn Base Camp (Jul 12) -- Full day trek to Hornlihutte. 1500m gain.
- Tre Cime di Lavaredo (Jul 15) -- Classic loop, ~4h.
- Seceda Ridgeline (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei.
Adventure Activities
- Via Ferrata at Aiguille du Midi (Jul 8) -- Rent harness + lanyard + helmet, ~EUR 25/day
- Paragliding over Zermatt (Jul 13) -- Tandem flights ~EUR 180pp
- Kayaking at Lago di Braies (Jul 16) -- Turquoise glacial lake, ~EUR 15/hour
Rest Days
- Jul 9: Explore Chamonix town, gear shopping
- Jul 19: Free day before flying home, packing
`,
+ content_plain: "Detailed activity planning including hiking routes with difficulty ratings, adventure activities with costs, and rest day plans.",
+ content_format: 'html',
+ type: "NOTE", tags: ["hiking", "activities", "adventure"], is_pinned: false,
+ created_at: new Date(now - 10 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(),
+ },
+ {
+ id: "demo-note-4", title: "Gear Research",
+ content: `Gear Research
Via Ferrata Kit
Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person.
Camera & Drone
- Bring the DJI Mini 4 Pro for Tre Cime and Seceda
- Check Italian drone regulations! Need ENAC registration for flights over 250g
- ND filters for long exposure water shots at Lago di Braies
- Extra batteries (3x) -- cold altitude drains them fast
Personal Gear Checklist
- Hiking boots (broken in!)
- Rain jacket (waterproof, not just resistant)
- Headlamp + spare batteries
- Trekking poles (collapsible for flights)
- Sunscreen SPF 50 + lip balm
- Wool base layers for hut nights
`,
+ content_plain: "Gear research including Via Ferrata rental, camera and drone regulations, shared group gear status, and personal gear checklist.",
+ content_format: 'html',
+ type: "NOTE", tags: ["gear", "equipment", "budget"], is_pinned: false,
+ created_at: new Date(now - 8 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
+ },
+ {
+ id: "demo-note-5", title: "Emergency Contacts & Safety",
+ content: `Emergency Contacts & Safety
Emergency Numbers
- France: 112 (EU general), PGHM Mountain Rescue: +33 4 50 53 16 89
- Switzerland: 1414 (REGA air rescue), 144 (ambulance)
- Italy: 118 (medical), 112 (general emergency)
Insurance
- Policy #: WA-2026-7891
- Emergency line: +1-800-555-0199
- Covers: mountain rescue, helicopter evacuation, medical repatriation
Altitude Sickness Protocol
- Acclimatize in Chamonix (1,035m) for 2 days before going high
- Stay hydrated -- minimum 3L water per day above 2,500m
- Watch for symptoms: headache, nausea, dizziness
- Descend immediately if symptoms worsen
`,
+ content_plain: "Emergency contacts for France, Switzerland, and Italy. Insurance details, altitude sickness protocol, weather contingency plans.",
+ content_format: 'html',
+ type: "NOTE", tags: ["safety", "emergency", "contacts"], is_pinned: false,
+ created_at: new Date(now - 7 * day).toISOString(), updated_at: new Date(now - 6 * hour).toISOString(),
+ },
+ {
+ id: "demo-note-6", title: "Photo Spots & Creative Plan",
+ content: `Photo Spots & Creative Plan
Must-Capture Locations
- Lac Blanc -- Reflection of Mont Blanc at sunrise. Arrive by 5:30am. Tripod essential.
- Gornergrat Panorama -- 360-degree view with Matterhorn. Golden hour is best.
- Tre Cime from Rifugio Locatelli -- The iconic three peaks at golden hour. Drone shots here.
- Seceda Ridgeline -- Dramatic Dolomite spires. Best drone footage location.
- Lago di Braies -- Turquoise water, use ND filters for long exposure reflections.
Zine Plan (Maya)
We are making an Alpine Explorer Zine after the trip:
- Format: A5 risograph, 50 copies
- Print at Chamonix Print Collective
- Content: best photos, trail notes, hand-drawn maps
- Price: EUR 12 per copy on rCart
`,
+ content_plain: "Photography and creative plan including must-capture locations, drone shot list, zine production details, and video plan.",
+ content_format: 'html',
+ type: "NOTE", tags: ["photography", "creative", "planning"], is_pinned: false,
+ created_at: new Date(now - 5 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(),
+ },
+ ];
+
+ // Typed demo notes
+ tripPlanningNotes.push(
+ {
+ id: "demo-note-code-1", title: "Expense Tracker Script",
+ content: `const expenses = [\n { item: "Flights", amount: 800, category: "transport" },\n { item: "Lac Blanc Hut", amount: 120, category: "accommodation" },\n { item: "Via Ferrata Rental", amount: 100, category: "activities" },\n];\n\nconst total = expenses.reduce((sum, e) => sum + e.amount, 0);\nconsole.log(\`Total: EUR \${total}\`);`,
+ content_plain: "Expense tracker script for the trip budget",
+ content_format: 'html',
+ type: "CODE", tags: ["budget", "code"], is_pinned: false,
+ language: "javascript",
+ created_at: new Date(now - 2 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
+ } as Note,
+ {
+ id: "demo-note-bookmark-1", title: "Chamonix Weather Forecast",
+ content: "Live weather forecast for the Chamonix valley. Check daily before hikes.
",
+ content_plain: "Live weather forecast for the Chamonix valley",
+ content_format: 'html',
+ type: "BOOKMARK", tags: ["weather", "chamonix"], is_pinned: false,
+ url: "https://www.chamonix.com/weather",
+ created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(),
+ } as Note,
+ );
+
+ const packingNotes: Note[] = [
+ {
+ id: "demo-note-7", title: "Packing Checklist",
+ content: `Packing Checklist
Footwear
- Hiking boots (broken in!)
- Camp sandals / flip-flops
- Extra laces
Clothing
- Rain jacket (Gore-Tex)
- Down jacket for hut nights
- 3x wool base layers
- 2x hiking pants
- Sun hat + warm beanie
Gear
- Headlamp + spare batteries
- Trekking poles (collapsible)
- First aid kit
- Sunscreen SPF 50
- Water filter (Sawyer Squeeze)
`,
+ content_plain: "Complete packing checklist organized by category: footwear, clothing, gear, electronics, documents, and food.",
+ content_format: 'html',
+ type: "NOTE", tags: ["packing", "gear", "checklist"], is_pinned: true,
+ created_at: new Date(now - 6 * day).toISOString(), updated_at: new Date(now - hour).toISOString(),
+ },
+ {
+ id: "demo-note-8", title: "Food & Cooking Plan",
+ content: `Food & Cooking Plan
Hut Meals (Half-Board)
Lac Blanc and Locatelli include dinner + breakfast. Budget EUR 0 for those nights.
Self-Catering Days
We have a kitchen in the Chamonix Airbnb and Zermatt apartment.
Trail Lunches
Pack these the night before each hike:
- Sandwiches (baguette + cheese + ham)
- Energy bars (2 per person)
- Nuts and dried fruit
- Chocolate (the altitude calls for it)
- 1.5L water minimum
`,
+ content_plain: "Food and cooking plan covering hut meals, self-catering, trail lunches, special restaurant meals, and dietary notes.",
+ content_format: 'html',
+ type: "NOTE", tags: ["food", "planning", "budget"], is_pinned: false,
+ created_at: new Date(now - 4 * day).toISOString(), updated_at: new Date(now - 8 * hour).toISOString(),
+ },
+ {
+ id: "demo-note-9", title: "Transport & Logistics",
+ content: `Transport & Logistics
Getting There
- Jul 6: Fly to Geneva (everyone arrives by 14:00)
- Geneva to Chamonix shuttle: EUR 186 for 4 pax
Between Destinations
- Jul 10: Chamonix to Zermatt -- Train via Martigny (~3.5h, scenic)
- Jul 14: Zermatt to Dolomites -- Bernina Express (6 hours but spectacular)
Local Transport
- Chamonix: Free local bus with guest card
- Zermatt: Car-free! Electric taxis + Gornergrat railway
- Dolomites: Need rental car or local bus (limited schedule)
`,
+ content_plain: "Transport and logistics plan covering flights, inter-city transfers, local transport options, return journey, and timetables.",
+ content_format: 'html',
+ type: "NOTE", tags: ["transport", "logistics"], is_pinned: false,
+ created_at: new Date(now - 9 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(),
+ },
+ ];
+
+ const itineraryNotes: Note[] = [
+ {
+ id: "demo-note-10", title: "Full Itinerary -- Alpine Explorer 2026",
+ content: `Full Itinerary -- Alpine Explorer 2026
Jul 6-20 | France, Switzerland, Italy
Week 1: Chamonix, France (Jul 6-10)
- Jul 6: Fly Geneva, shuttle to Chamonix
- Jul 7: Acclimatization hike -- Lac Blanc
- Jul 8: Via Ferrata -- Aiguille du Midi
- Jul 9: Rest day / Chamonix town
- Jul 10: Train to Zermatt
Week 2: Zermatt, Switzerland (Jul 10-14)
- Jul 10: Arrive Zermatt, settle in
- Jul 11: Gornergrat sunrise hike
- Jul 12: Matterhorn base camp trek
- Jul 13: Paragliding over Zermatt
- Jul 14: Transfer to Dolomites
Week 3: Dolomites, Italy (Jul 14-20)
- Jul 14: Arrive Val Gardena
- Jul 15: Tre Cime di Lavaredo loop
- Jul 16: Lago di Braies kayaking
- Jul 17: Seceda ridgeline hike
- Jul 18: Cooking class in Bolzano
- Jul 19: Free day -- shopping & packing
- Jul 20: Fly home from Innsbruck
`,
+ content_plain: "Complete day-by-day itinerary for the Alpine Explorer 2026 trip covering three weeks across Chamonix, Zermatt, and the Dolomites.",
+ content_format: 'html',
+ type: "NOTE", tags: ["itinerary", "planning"], is_pinned: true,
+ created_at: new Date(now - 15 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
+ },
+ {
+ id: "demo-note-11", title: "Mountain Hut Reservations",
+ content: `Mountain Hut Reservations
Confirmed
- Refuge du Lac Blanc (Jul 7) -- 4 beds, half-board, conf #LB2026-234
- Rifugio Locatelli (Jul 15) -- 4 beds, half-board, conf #TRE2026-089
Waitlisted
- Hornlihutte (Matterhorn base, Jul 12) -- will know by Jul 1
Hut Etiquette Reminders
- Arrive before 17:00 if possible
- Remove boots at entrance (bring hut shoes or thick socks)
- Lights out by 22:00
- Pack out all trash
- Tip is appreciated but not required
`,
+ content_plain: "Mountain hut reservations with confirmation numbers, check-in details, and hut etiquette reminders.",
+ content_format: 'html',
+ type: "NOTE", tags: ["accommodation", "hiking"], is_pinned: false,
+ created_at: new Date(now - 11 * day).toISOString(), updated_at: new Date(now - day).toISOString(),
+ },
+ {
+ id: "demo-note-12", title: "Group Decisions & Votes",
+ content: `Group Decisions & Votes
Decided
- Camera Gear: DJI Mini 4 Pro (Liam's decision matrix: 8.5/10)
- First Night Dinner in Zermatt: Fondue at Chez Vrony (won 5-4 over pizza)
- Day 5 Activity: Via Ferrata at Aiguille du Midi (won 7-3 over kayaking)
Active Votes (in rVote)
- Zermatt to Dolomites transfer: Train vs rental car -- Train leading 3-2
Pending Decisions
- Val Gardena accommodation (agriturismo vs apartment)
- Whether to rent the Starlink Mini (EUR 200, needs funding)
- Trip zine print run size (50 vs 100 copies)
`,
+ content_plain: "Summary of group decisions made and active votes. Covers camera gear, dining, activities, and pending decisions.",
+ content_format: 'html',
+ type: "NOTE", tags: ["decisions", "planning"], is_pinned: false,
+ created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(),
+ },
+ ];
+
+ this.demoNotebooks = [
+ {
+ id: "demo-nb-1", title: "Alpine Explorer Planning", description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy",
+ cover_color: "#f59e0b", note_count: "8", updated_at: new Date(now - hour).toISOString(),
+ notes: tripPlanningNotes,
+ } as any,
+ {
+ id: "demo-nb-2", title: "Packing & Logistics", description: "Checklists, food plans, and transport details",
+ cover_color: "#22c55e", note_count: "3", updated_at: new Date(now - hour).toISOString(),
+ notes: packingNotes,
+ } as any,
+ {
+ id: "demo-nb-3", title: "Itinerary & Decisions", description: "Day-by-day schedule, hut reservations, and group votes",
+ cover_color: "#6366f1", note_count: "3", updated_at: new Date(now - 2 * hour).toISOString(),
+ notes: itineraryNotes,
+ } as any,
+ ];
+
+ this.notebooks = this.demoNotebooks.map(({ notes, ...nb }) => nb as Notebook);
+ // Populate sidebar note cache and expand first notebook
+ for (const nb of this.demoNotebooks) {
+ this.notebookNotes.set(nb.id, nb.notes);
+ }
+ if (this.demoNotebooks.length > 0) {
+ this.expandedNotebooks.add(this.demoNotebooks[0].id);
+ }
+ this.loading = false;
+ this.render();
+ }
+
+ private demoSearchNotes(query: string) {
+ if (!query.trim()) {
+ this.searchResults = [];
+ this.renderNav();
+ return;
+ }
+ const q = query.toLowerCase();
+ const results: Note[] = [];
+ for (const nb of this.demoNotebooks) {
+ for (const n of nb.notes) {
+ if (n.title.toLowerCase().includes(q) ||
+ n.content_plain.toLowerCase().includes(q) ||
+ (n.tags && n.tags.some(t => t.toLowerCase().includes(q)))) {
+ results.push(Object.assign({}, n, { notebook_id: nb.id }) as any);
+ }
+ }
+ }
+ this.searchResults = results;
+ this.renderNav();
+ }
+
+ private demoLoadNote(id: string) {
+ // Find the note and its parent notebook
+ for (const nb of this.demoNotebooks) {
+ const note = nb.notes.find(n => n.id === id);
+ if (note) {
+ this.selectedNote = note;
+ this.selectedNotebook = { ...nb };
+ if (!this.expandedNotebooks.has(nb.id)) {
+ this.expandedNotebooks.add(nb.id);
+ }
+ this.renderNav();
+ this.renderMeta();
+ this.mountEditor(this.selectedNote);
+ return;
+ }
+ }
+ }
+
+ private demoCreateNotebook() {
+ const now = Date.now();
+ const nbId = `demo-nb-${now}`;
+ const noteId = `demo-note-${now}`;
+ const newNote: Note = {
+ id: noteId, title: "Untitled Note", content: "", content_plain: "",
+ content_format: 'tiptap-json',
+ type: "NOTE", tags: null, is_pinned: false, sort_order: 0,
+ created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
+ };
+ const nb = {
+ id: nbId, title: "Untitled Notebook", description: "",
+ cover_color: "#8b5cf6", note_count: "1",
+ updated_at: new Date(now).toISOString(), notes: [newNote],
+ } as any;
+ this.demoNotebooks.push(nb);
+ this.notebooks = this.demoNotebooks.map(({ notes, ...rest }) => rest as Notebook);
+ this.notebookNotes.set(nbId, [newNote]);
+ this.expandedNotebooks.add(nbId);
+ this.selectedNotebook = { ...nb };
+ // Auto-open the note for editing
+ this.selectedNote = newNote;
+ this.renderNav();
+ this.renderMeta();
+ this.mountEditor(newNote);
+ }
+
+ private demoCreateNote(opts: CreateNoteOpts = {}) {
+ if (!this.selectedNotebook) return;
+ const now = Date.now();
+ const noteId = `demo-note-${now}`;
+ const type = opts.type || 'NOTE';
+ const title = opts.title || FolkDocsApp.typeDefaultTitle(type);
+ const newNote: Note = {
+ id: noteId, title, content: opts.content || "", content_plain: "",
+ content_format: type === 'CODE' ? 'html' : 'tiptap-json',
+ type, tags: opts.tags || null, is_pinned: false, sort_order: 0,
+ url: opts.url || null, language: opts.language || null,
+ fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
+ duration: opts.duration ?? null,
+ created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
+ };
+ const nbId = this.selectedNotebook.id;
+ const demoNb = this.demoNotebooks.find(n => n.id === nbId);
+ if (demoNb) {
+ demoNb.notes.push(newNote);
+ demoNb.note_count = String(demoNb.notes.length);
+ }
+ this.selectedNotebook.notes.push(newNote);
+ this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length);
+ // Update sidebar cache
+ const cached = this.notebookNotes.get(nbId) || [];
+ cached.unshift(newNote);
+ this.notebookNotes.set(nbId, cached);
+ const nbIdx = this.notebooks.findIndex(n => n.id === nbId);
+ if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(cached.length);
+ this.selectedNote = newNote;
+ this.renderNav();
+ this.renderMeta();
+ this.mountEditor(newNote);
+ }
+
+ // ── Mobile stack navigation ──
+
+ private setMobileEditing(editing: boolean) {
+ this.mobileEditing = editing;
+ this.shadow.getElementById('notes-layout')?.classList.toggle('mobile-editing', editing);
+ }
+
+ private mobileGoBack() {
+ this.setMobileEditing(false);
+ }
+
+ private mobileBackBarHtml(): string {
+ const title = this.selectedNotebook?.title || 'Notes';
+ return ``;
+ }
+
+ private isMobile(): boolean {
+ return window.innerWidth <= 768;
+ }
+
+ disconnectedCallback() {
+ this.destroyEditor();
+ this.cleanupPresence();
+ if (this._resizeHandler) {
+ window.removeEventListener('resize', this._resizeHandler);
+ this._resizeHandler = null;
+ }
+ this._offlineUnsub?.();
+ this._offlineUnsub = null;
+ for (const unsub of this._offlineNotebookUnsubs) unsub();
+ this._offlineNotebookUnsubs = [];
+ }
+
+ // ── Sync (via shared runtime) ──
+
+ private async subscribeNotebook(notebookId: string) {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ // Resolve scope: rdocs is globally-scoped, so use 'global' prefix
+ const dataSpace = runtime?.isInitialized
+ ? (runtime.resolveDocSpace?.('rdocs') || this.space)
+ : this.space;
+ this.subscribedDocId = `${dataSpace}:notes:notebooks:${notebookId}`;
+
+ if (runtime?.isInitialized) {
+ try {
+ const docId = this.subscribedDocId as DocumentId;
+ const doc = await runtime.subscribe(docId, notebookSchema);
+ this.doc = doc;
+ this.renderFromDoc();
+
+ const unsub = runtime.onChange(docId, (updated: any) => {
+ this.doc = updated;
+ this.renderFromDoc();
+ });
+ this._offlineNotebookUnsubs.push(unsub);
+ } catch {
+ // Fallback: initialize empty doc for fresh notebook
+ this.doc = Automerge.init();
+ }
+ } else {
+ // No runtime — initialize empty doc
+ this.doc = Automerge.init();
+ }
+ }
+
+ private unsubscribeNotebook() {
+ if (this.subscribedDocId) {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (runtime?.isInitialized) {
+ runtime.unsubscribe(this.subscribedDocId as DocumentId);
+ }
+ }
+ this.subscribedDocId = null;
+ this.doc = null;
+ for (const unsub of this._offlineNotebookUnsubs) unsub();
+ this._offlineNotebookUnsubs = [];
+ }
+
+ /** Extract notebook + notes from Automerge doc into component state */
+ private renderFromDoc() {
+ if (!this.doc) return;
+
+ const nb = this.doc.notebook;
+ const items = this.doc.items;
+
+ if (!nb) return;
+
+ // Build notebook data from doc
+ const notes: Note[] = [];
+ if (items) {
+ for (const [, item] of Object.entries(items)) {
+ notes.push({
+ id: item.id,
+ title: item.title || "Untitled",
+ content: item.content || "",
+ content_plain: item.contentPlain || "",
+ content_format: (item.contentFormat as Note['content_format']) || undefined,
+ type: item.type || "NOTE",
+ tags: item.tags?.length ? Array.from(item.tags) : null,
+ is_pinned: item.isPinned || false,
+ url: item.url || null,
+ language: item.language || null,
+ fileUrl: item.fileUrl || null,
+ mimeType: item.mimeType || null,
+ duration: item.duration ?? null,
+ sort_order: item.sortOrder || 0,
+ created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
+ updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
+ });
+ }
+ }
+
+ this.sortNotes(notes);
+
+ this.selectedNotebook = {
+ id: nb.id,
+ title: nb.title,
+ description: nb.description || "",
+ cover_color: nb.coverColor || "#3b82f6",
+ note_count: String(notes.length),
+ updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(),
+ notes,
+ };
+
+ // Update sidebar note cache
+ this.notebookNotes.set(nb.id, notes);
+ const nbIdx = this.notebooks.findIndex(n => n.id === nb.id);
+ if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(notes.length);
+
+ // If editor is mounted for a note, update editor content from remote
+ if (this.selectedNote && this.editor && this.editorNoteId === this.selectedNote.id) {
+ const noteItem = items?.[this.selectedNote.id];
+ if (noteItem) {
+ this.selectedNote = {
+ id: noteItem.id,
+ title: noteItem.title || "Untitled",
+ content: noteItem.content || "",
+ content_plain: noteItem.contentPlain || "",
+ content_format: (noteItem.contentFormat as Note['content_format']) || undefined,
+ type: noteItem.type || "NOTE",
+ tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
+ is_pinned: noteItem.isPinned || false,
+ url: noteItem.url || null,
+ language: noteItem.language || null,
+ fileUrl: noteItem.fileUrl || null,
+ mimeType: noteItem.mimeType || null,
+ duration: noteItem.duration ?? null,
+ sort_order: noteItem.sortOrder || 0,
+ created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
+ updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
+ };
+
+ // Skip content replacement when Yjs is active — Yjs handles content sync
+ if (!this.ydoc) {
+ // Legacy mode: update editor content if different (remote change)
+ const remoteContent = noteItem.content || "";
+ const currentContent = noteItem.contentFormat === 'tiptap-json'
+ ? JSON.stringify(this.editor.getJSON())
+ : this.editor.getHTML();
+
+ if (remoteContent !== currentContent) {
+ this.isRemoteUpdate = true;
+ try {
+ if (noteItem.contentFormat === 'tiptap-json') {
+ try {
+ this.editor.commands.setContent(JSON.parse(remoteContent), { emitUpdate: false });
+ } catch {
+ this.editor.commands.setContent(remoteContent, { emitUpdate: false });
+ }
+ } else {
+ this.editor.commands.setContent(remoteContent, { emitUpdate: false });
+ }
+ } finally {
+ this.isRemoteUpdate = false;
+ }
+ }
+ }
+
+ // Update title input if it exists
+ const titleInput = this.shadow.querySelector('#note-title-input') as HTMLInputElement;
+ if (titleInput && document.activeElement !== titleInput && titleInput !== this.shadow.activeElement) {
+ titleInput.value = noteItem.title || "Untitled";
+ }
+
+ // Only update nav/meta, skip contentZone
+ this.renderNav();
+ this.renderMeta();
+ this.loading = false;
+ return;
+ }
+ }
+
+ // If a note is selected but editor not mounted yet, update selectedNote
+ if (this.selectedNote) {
+ const noteItem = items?.[this.selectedNote.id];
+ if (noteItem) {
+ this.selectedNote = {
+ id: noteItem.id,
+ title: noteItem.title || "Untitled",
+ content: noteItem.content || "",
+ content_plain: noteItem.contentPlain || "",
+ content_format: (noteItem.contentFormat as Note['content_format']) || undefined,
+ type: noteItem.type || "NOTE",
+ tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null,
+ is_pinned: noteItem.isPinned || false,
+ url: noteItem.url || null,
+ language: noteItem.language || null,
+ fileUrl: noteItem.fileUrl || null,
+ mimeType: noteItem.mimeType || null,
+ duration: noteItem.duration ?? null,
+ sort_order: noteItem.sortOrder || 0,
+ created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(),
+ updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
+ };
+ }
+ }
+
+ this.loading = false;
+ this.render();
+ }
+
+ // ── Automerge mutations ──
+
+ private static typeDefaultTitle(type: NoteType): string {
+ switch (type) {
+ case 'CODE': return 'Untitled Code Snippet';
+ case 'BOOKMARK': return 'Untitled Bookmark';
+ case 'CLIP': return 'Untitled Clip';
+ case 'IMAGE': return 'Untitled Image';
+ case 'AUDIO': return 'Voice Note';
+ case 'FILE': return 'Untitled File';
+ default: return 'Untitled Note';
+ }
+ }
+
+ private createNoteViaSync(opts: CreateNoteOpts = {}) {
+ if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return;
+
+ const noteId = crypto.randomUUID();
+ const now = Date.now();
+ const notebookId = this.selectedNotebook.id;
+ const type = opts.type || 'NOTE';
+ const title = opts.title || FolkDocsApp.typeDefaultTitle(type);
+ const contentFormat = type === 'CODE' ? 'html' : 'tiptap-json';
+
+ const itemData: any = {
+ id: noteId, notebookId, title,
+ content: opts.content || "", contentPlain: "", contentFormat,
+ type, tags: opts.tags || [], isPinned: false, sortOrder: 0,
+ createdAt: now, updatedAt: now,
+ url: opts.url || null, language: opts.language || null,
+ fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
+ duration: opts.duration ?? null,
+ };
+
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (runtime?.isInitialized) {
+ runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => {
+ if (!d.items) (d as any).items = {};
+ d.items[noteId] = itemData;
+ });
+ this.doc = runtime.get(this.subscribedDocId as DocumentId);
+ } else {
+ this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
+ if (!d.items) (d as any).items = {};
+ d.items[noteId] = itemData;
+ });
+ }
+
+ this.renderFromDoc();
+
+ // Open the new note
+ this.selectedNote = {
+ id: noteId, title, content: opts.content || "", content_plain: "",
+ content_format: contentFormat,
+ type, tags: opts.tags || null, is_pinned: false, sort_order: 0,
+ url: opts.url || null, language: opts.language || null,
+ fileUrl: opts.fileUrl || null, mimeType: opts.mimeType || null,
+ duration: opts.duration ?? null,
+ created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
+ };
+ this.renderNav();
+ this.renderMeta();
+ this.mountEditor(this.selectedNote);
+ }
+
+ private updateNoteField(noteId: string, field: string, value: string) {
+ if (!this.doc || !this.doc.items?.[noteId] || !this.subscribedDocId) return;
+
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (runtime?.isInitialized) {
+ runtime.change(this.subscribedDocId as DocumentId, `Update ${field}`, (d: NotebookDoc) => {
+ (d.items[noteId] as any)[field] = value;
+ d.items[noteId].updatedAt = Date.now();
+ });
+ this.doc = runtime.get(this.subscribedDocId as DocumentId);
+ } else {
+ this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
+ (d.items[noteId] as any)[field] = value;
+ d.items[noteId].updatedAt = Date.now();
+ });
+ }
+ }
+
+ /** Sort notes: pinned first, then by sort_order (if any are set), then by updated_at desc. */
+ private sortNotes(notes: Note[]) {
+ const hasSortOrder = notes.some(n => (n.sort_order || 0) > 0);
+ notes.sort((a, b) => {
+ if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
+ if (hasSortOrder) {
+ const sa = a.sort_order || 0;
+ const sb = b.sort_order || 0;
+ if (sa !== sb) return sa - sb;
+ }
+ return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
+ });
+ }
+
+ /** Reorder a note within a notebook's sidebar list. */
+ private reorderNote(noteId: string, notebookId: string, targetIndex: number) {
+ const notes = this.notebookNotes.get(notebookId);
+ if (!notes) return;
+
+ const srcIdx = notes.findIndex(n => n.id === noteId);
+ if (srcIdx < 0 || srcIdx === targetIndex) return;
+
+ // Move in the local array
+ const [note] = notes.splice(srcIdx, 1);
+ notes.splice(targetIndex, 0, note);
+
+ // Assign sort_order based on new positions
+ notes.forEach((n, i) => { n.sort_order = i + 1; });
+ this.notebookNotes.set(notebookId, notes);
+
+ // Persist to Automerge
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ const dataSpace = runtime?.isInitialized ? (runtime.resolveDocSpace?.('rdocs') || this.space) : this.space;
+ const docId = `${dataSpace}:notes:notebooks:${notebookId}` as DocumentId;
+
+ if (runtime?.isInitialized) {
+ runtime.change(docId, `Reorder notes`, (d: NotebookDoc) => {
+ for (const n of notes) {
+ if (d.items[n.id]) d.items[n.id].sortOrder = n.sort_order!;
+ }
+ });
+ this.doc = runtime.get(this.subscribedDocId as DocumentId);
+ } else if (this.doc && this.subscribedDocId === docId) {
+ this.doc = Automerge.change(this.doc, `Reorder notes`, (d: NotebookDoc) => {
+ for (const n of notes) {
+ if (d.items[n.id]) d.items[n.id].sortOrder = n.sort_order!;
+ }
+ });
+ }
+
+ // Also update selectedNotebook if it matches
+ if (this.selectedNotebook?.id === notebookId) {
+ this.selectedNotebook.notes = [...notes];
+ }
+
+ this.renderNav();
+ }
+
+ /** Move a note from one notebook to another via Automerge docs. */
+ private async moveNoteToNotebook(noteId: string, sourceNotebookId: string, targetNotebookId: string) {
+ if (sourceNotebookId === targetNotebookId) return;
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+
+ const dataSpace = runtime.resolveDocSpace?.('rdocs') || this.space;
+ const sourceDocId = `${dataSpace}:notes:notebooks:${sourceNotebookId}` as DocumentId;
+ const targetDocId = `${dataSpace}:notes:notebooks:${targetNotebookId}` as DocumentId;
+
+ // Get the note data from source
+ const sourceDoc = runtime.get(sourceDocId) as NotebookDoc | undefined;
+ if (!sourceDoc?.items?.[noteId]) return;
+
+ // Deep-clone the note item (plain object from Automerge)
+ const noteItem = JSON.parse(JSON.stringify(sourceDoc.items[noteId]));
+ noteItem.notebookId = targetNotebookId;
+ noteItem.updatedAt = Date.now();
+
+ // Subscribe to target doc if needed, add the note, then unsubscribe
+ let targetDoc: NotebookDoc | undefined;
+ try {
+ targetDoc = await runtime.subscribe(targetDocId, notebookSchema);
+ } catch {
+ return; // target notebook not accessible
+ }
+
+ // Add to target
+ runtime.change(targetDocId, `Move note ${noteId}`, (d: NotebookDoc) => {
+ if (!d.items) (d as any).items = {};
+ d.items[noteId] = noteItem;
+ });
+
+ // Remove from source
+ runtime.change(sourceDocId, `Move note ${noteId} out`, (d: NotebookDoc) => {
+ delete d.items[noteId];
+ });
+
+ // If we're viewing the source notebook, refresh
+ if (this.subscribedDocId === sourceDocId) {
+ this.doc = runtime.get(sourceDocId);
+ this.renderFromDoc();
+ }
+
+ // Update sidebar counts
+ const srcNb = this.notebooks.find(n => n.id === sourceNotebookId);
+ const tgtNb = this.notebooks.find(n => n.id === targetNotebookId);
+ if (srcNb) srcNb.note_count = String(Math.max(0, parseInt(srcNb.note_count) - 1));
+ if (tgtNb) tgtNb.note_count = String(parseInt(tgtNb.note_count) + 1);
+
+ // Refresh sidebar note cache for source
+ const srcNotes = this.notebookNotes.get(sourceNotebookId);
+ if (srcNotes) this.notebookNotes.set(sourceNotebookId, srcNotes.filter(n => n.id !== noteId));
+
+ // If target is expanded, refresh its notes
+ if (this.expandedNotebooks.has(targetNotebookId)) {
+ const tgtDoc = runtime.get(targetDocId) as NotebookDoc | undefined;
+ if (tgtDoc?.items) {
+ const notes: Note[] = Object.values(tgtDoc.items).map((item: any) => ({
+ id: item.id, title: item.title || 'Untitled', content: item.content || '',
+ content_plain: item.contentPlain || '', type: item.type || 'NOTE',
+ tags: item.tags?.length ? Array.from(item.tags) : null,
+ is_pinned: item.isPinned || false, url: item.url || null,
+ language: item.language || null, fileUrl: item.fileUrl || null,
+ mimeType: item.mimeType || null, duration: item.duration ?? null,
+ sort_order: item.sortOrder || 0,
+ created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
+ updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
+ }));
+ this.notebookNotes.set(targetNotebookId, notes);
+ }
+ }
+
+ // Unsubscribe from target if it's not the active notebook
+ if (this.subscribedDocId !== targetDocId) {
+ runtime.unsubscribe(targetDocId);
+ }
+
+ // Close editor if we were editing the moved note
+ if (this.selectedNote?.id === noteId) {
+ this.selectedNote = null;
+ this.renderContent();
+ }
+
+ this.renderNav();
+ }
+
+ // ── Note summarization ──
+
+ private async summarizeNote(btn: HTMLElement) {
+ const noteId = this.editorNoteId || this.selectedNote?.id;
+ if (!noteId) return;
+
+ // Get note content (plain text)
+ const item = this.doc?.items?.[noteId];
+ const content = item?.contentPlain || item?.content || this.selectedNote?.content_plain || '';
+ if (!content?.trim()) return;
+
+ // Show loading state on button
+ btn.classList.add('active');
+ btn.style.pointerEvents = 'none';
+
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notes/summarize`, {
+ method: 'POST',
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ content, model: 'gemini-flash', length: 'medium' }),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ console.error('[Notes] Summarize error:', err);
+ return;
+ }
+
+ const data = await res.json() as { summary: string; model: string };
+
+ // Save to Automerge doc
+ if (this.space !== 'demo') {
+ this.updateNoteField(noteId, 'summary', data.summary);
+ this.updateNoteField(noteId, 'summaryModel', data.model);
+ }
+
+ // Update local selected note for immediate display
+ if (this.selectedNote && this.selectedNote.id === noteId) {
+ (this.selectedNote as any).summary = data.summary;
+ (this.selectedNote as any).summaryModel = data.model;
+ }
+
+ this.renderMeta();
+ } catch (err) {
+ console.error('[Notes] Summarize failed:', err);
+ } finally {
+ btn.classList.remove('active');
+ btn.style.pointerEvents = '';
+ }
+ }
+
+ private async sendToOpenNotebook() {
+ const noteId = this.editorNoteId || this.selectedNote?.id;
+ if (!noteId) return;
+
+ const item = this.doc?.items?.[noteId];
+ const content = item?.contentPlain || item?.content || this.selectedNote?.content_plain || '';
+ const title = item?.title || this.selectedNote?.title || 'Untitled';
+ if (!content?.trim()) return;
+
+ // Disable button during request
+ const btn = this.metaZone.querySelector('[data-action="send-to-notebook"]') as HTMLButtonElement;
+ if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; }
+
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notes/send-to-notebook`, {
+ method: 'POST',
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ noteId, title, content }),
+ });
+
+ if (!res.ok) {
+ console.error('[Notes] Send to notebook error:', res.status);
+ if (btn) { btn.disabled = false; btn.textContent = 'Send to Notebook'; }
+ return;
+ }
+
+ const data = await res.json() as { sourceId: string };
+ this.updateNoteField(noteId, 'openNotebookSourceId', data.sourceId);
+
+ if (this.selectedNote && this.selectedNote.id === noteId) {
+ (this.selectedNote as any).openNotebookSourceId = data.sourceId;
+ }
+
+ this.renderMeta();
+ } catch (err) {
+ console.error('[Notes] Send to notebook failed:', err);
+ if (btn) { btn.disabled = false; btn.textContent = 'Send to Notebook'; }
+ }
+ }
+
+ // ── REST (notebook list + search) ──
+
+ private getApiBase(): string {
+ const path = window.location.pathname;
+ const match = path.match(/^(\/[^/]+)?\/rdocs/);
+ return match ? match[0] : "";
+ }
+
+ private authHeaders(extra?: Record): Record {
+ const headers: Record = { ...extra };
+ const token = getAccessToken();
+ if (token) headers["Authorization"] = `Bearer ${token}`;
+ return headers;
+ }
+
+ private async loadNotebooks() {
+ this.loading = true;
+ this.render();
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notebooks`, { headers: this.authHeaders() });
+ const data = await res.json();
+ this.notebooks = data.notebooks || [];
+ } catch {
+ this.error = "Failed to load notebooks";
+ }
+ this.loading = false;
+ this.render();
+ }
+
+ private async loadNotebook(id: string) {
+ this.unsubscribeNotebook();
+ await this.subscribeNotebook(id);
+ this.broadcastPresence();
+
+ // REST fallback if Automerge doc is empty after 5s
+ setTimeout(() => {
+ if (this.subscribedDocId && (!this.doc?.items || Object.keys(this.doc.items).length === 0)) {
+ this.loadNotebookREST(id);
+ }
+ }, 5000);
+ }
+
+ private async loadNotebookREST(id: string) {
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notebooks/${id}`, { headers: this.authHeaders() });
+ const data = await res.json();
+ this.selectedNotebook = data;
+ if (data?.notes) {
+ this.sortNotes(data.notes);
+ this.notebookNotes.set(id, data.notes);
+ }
+ } catch {
+ this.error = "Failed to load notebook";
+ }
+ this.loading = false;
+ this.render();
+ }
+
+ /** Fetch notes for a notebook (sidebar display). */
+ private async fetchNotebookNotes(id: string) {
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notebooks/${id}`, { headers: this.authHeaders() });
+ const data = await res.json();
+ if (data?.notes) {
+ this.sortNotes(data.notes);
+ this.notebookNotes.set(id, data.notes);
+ const nbIdx = this.notebooks.findIndex(n => n.id === id);
+ if (nbIdx >= 0) this.notebooks[nbIdx].note_count = String(data.notes.length);
+ }
+ } catch {
+ // Silently fail — sidebar stays empty for this notebook
+ }
+ this.renderNav();
+ }
+
+ /** Toggle notebook expansion in sidebar. */
+ private expandNotebook(id: string) {
+ if (this.expandedNotebooks.has(id)) {
+ this.expandedNotebooks.delete(id);
+ this.renderNav();
+ return;
+ }
+ this.expandedNotebooks.add(id);
+ if (!this.notebookNotes.has(id)) {
+ if (this.space === "demo") {
+ const nb = this.demoNotebooks.find(n => n.id === id);
+ if (nb) this.notebookNotes.set(id, nb.notes);
+ this.renderNav();
+ } else {
+ this.fetchNotebookNotes(id);
+ }
+ } else {
+ this.renderNav();
+ }
+ }
+
+ /** Open a note for editing from the sidebar. */
+ private async openNote(noteId: string, notebookId: string) {
+ const isDemo = this.space === "demo";
+
+ // Mobile: slide to editor screen
+ if (this.isMobile()) {
+ this.setMobileEditing(true);
+ }
+
+ // Expand notebook if not expanded
+ if (!this.expandedNotebooks.has(notebookId)) {
+ this.expandedNotebooks.add(notebookId);
+ }
+
+ if (isDemo) {
+ this.demoLoadNote(noteId);
+ return;
+ }
+
+ // Set selected notebook
+ const nb = this.notebooks.find(n => n.id === notebookId);
+ if (nb) {
+ this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(notebookId) || [] };
+ }
+
+ // Subscribe to Automerge if needed
+ const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${notebookId}`);
+ if (needSubscribe) {
+ await this.loadNotebook(notebookId);
+ }
+
+ this.loadNote(noteId);
+ }
+
+ /** Add a new note to a notebook via the sidebar '+' button. */
+ private async addNoteToNotebook(notebookId: string) {
+ const isDemo = this.space === "demo";
+
+ // Ensure expanded
+ if (!this.expandedNotebooks.has(notebookId)) {
+ this.expandedNotebooks.add(notebookId);
+ }
+
+ // Set selected notebook
+ const nb = this.notebooks.find(n => n.id === notebookId);
+ if (!nb) return;
+ this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(notebookId) || [] };
+
+ if (isDemo) {
+ this.demoCreateNote();
+ return;
+ }
+
+ // Ensure subscribed
+ const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${notebookId}`);
+ if (needSubscribe) {
+ await this.loadNotebook(notebookId);
+ }
+ this.createNoteViaSync();
+ }
+
+ /** Show a small dropdown menu near the "+" button with note creation options. */
+ private showAddNoteMenu(nbId: string, anchorEl: HTMLElement) {
+ // Remove any existing menu
+ this.shadow.querySelector('.add-note-menu')?.remove();
+
+ const menu = document.createElement('div');
+ menu.className = 'add-note-menu';
+
+ // Position near the anchor
+ const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
+ const anchorRect = anchorEl.getBoundingClientRect();
+ menu.style.left = `${anchorRect.left - hostRect.left}px`;
+ menu.style.top = `${anchorRect.bottom - hostRect.top + 4}px`;
+
+ menu.innerHTML = `
+
+
+
+ `;
+
+ this.shadow.appendChild(menu);
+
+ const close = () => menu.remove();
+
+ menu.querySelectorAll('.add-note-menu-item').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const action = (btn as HTMLElement).dataset.action;
+ close();
+ if (action === 'note') this.addNoteToNotebook(nbId);
+ else if (action === 'url') this.createNoteFromUrl(nbId);
+ else if (action === 'upload') this.createNoteFromFile(nbId);
+ });
+ });
+
+ // Close on outside click
+ const onOutside = (e: Event) => {
+ if (!menu.contains(e.target as Node)) {
+ close();
+ this.shadow.removeEventListener('click', onOutside);
+ }
+ };
+ requestAnimationFrame(() => this.shadow.addEventListener('click', onOutside));
+ }
+
+ /** Prompt for a URL and create a BOOKMARK note. */
+ private async createNoteFromUrl(nbId: string) {
+ const url = prompt('Enter URL:');
+ if (!url) return;
+
+ let title: string;
+ try { title = new URL(url).hostname; } catch { title = url; }
+
+ // Ensure notebook is selected and subscribed
+ const nb = this.notebooks.find(n => n.id === nbId);
+ if (!nb) return;
+ this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] };
+ if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId);
+
+ if (this.space === 'demo') {
+ this.demoCreateNote();
+ return;
+ }
+
+ const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`);
+ if (needSubscribe) await this.loadNotebook(nbId);
+
+ this.createNoteViaSync({ type: 'BOOKMARK', url, title });
+ }
+
+ /** Open a file picker, upload the file, and create a FILE or IMAGE note. */
+ private createNoteFromFile(nbId: string) {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.md,.csv,.json';
+
+ input.addEventListener('change', async () => {
+ const file = input.files?.[0];
+ if (!file) return;
+
+ // Upload the file
+ const base = this.getApiBase();
+ const fd = new FormData();
+ fd.append('file', file, file.name);
+
+ try {
+ const uploadRes = await fetch(`${base}/api/uploads`, {
+ method: 'POST', headers: this.authHeaders(), body: fd,
+ });
+ if (!uploadRes.ok) throw new Error('Upload failed');
+ const uploadData = await uploadRes.json();
+ const fileUrl = uploadData.url || uploadData.path;
+
+ // Determine note type
+ const mime = file.type || '';
+ const type: NoteType = mime.startsWith('image/') ? 'IMAGE'
+ : mime.startsWith('audio/') ? 'AUDIO' : 'FILE';
+
+ // Ensure notebook ready
+ const nb = this.notebooks.find(n => n.id === nbId);
+ if (!nb) return;
+ this.selectedNotebook = { ...nb, notes: this.notebookNotes.get(nbId) || [] };
+ if (!this.expandedNotebooks.has(nbId)) this.expandedNotebooks.add(nbId);
+
+ if (this.space === 'demo') {
+ this.demoCreateNote();
+ return;
+ }
+
+ const needSubscribe = !this.subscribedDocId || !this.subscribedDocId.endsWith(`:${nbId}`);
+ if (needSubscribe) await this.loadNotebook(nbId);
+
+ this.createNoteViaSync({ type, fileUrl, mimeType: mime, title: file.name });
+ } catch (err) {
+ console.error('File upload failed:', err);
+ alert('Failed to upload file. Please try again.');
+ }
+ });
+
+ input.click();
+ }
+
+ private loadNote(id: string) {
+ // Note is already in the Automerge doc
+ if (this.doc?.items?.[id]) {
+ const item = this.doc.items[id];
+ this.selectedNote = {
+ id: item.id,
+ title: item.title || "Untitled",
+ content: item.content || "",
+ content_plain: item.contentPlain || "",
+ content_format: (item.contentFormat as Note['content_format']) || undefined,
+ type: item.type || "NOTE",
+ tags: item.tags?.length ? Array.from(item.tags) : null,
+ is_pinned: item.isPinned || false,
+ url: item.url || null,
+ language: item.language || null,
+ fileUrl: item.fileUrl || null,
+ mimeType: item.mimeType || null,
+ duration: item.duration ?? null,
+ sort_order: item.sortOrder || 0,
+ created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(),
+ updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(),
+ };
+ } else if (this.selectedNotebook?.notes) {
+ this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null;
+ }
+
+ // Fallback: try sidebar note cache
+ if (!this.selectedNote) {
+ for (const [, notes] of this.notebookNotes) {
+ const found = notes.find(n => n.id === id);
+ if (found) { this.selectedNote = found; break; }
+ }
+ }
+
+ if (this.selectedNote) {
+ this.renderNav();
+ this.renderMeta();
+ this.mountEditor(this.selectedNote);
+ this.broadcastPresence();
+ }
+ }
+
+ private async searchNotes(query: string) {
+ if (!query.trim()) {
+ this.searchResults = [];
+ this.renderNav();
+ return;
+ }
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`, { headers: this.authHeaders() });
+ const data = await res.json();
+ this.searchResults = data.notes || [];
+ } catch {
+ this.searchResults = [];
+ }
+ this.renderNav();
+ }
+
+ private async createNotebook() {
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notebooks`, {
+ method: "POST",
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify({ title: "Untitled Notebook" }),
+ });
+ const nb = await res.json();
+ if (nb?.id) {
+ await this.loadNotebooks(); // Refresh list
+ this.notebookNotes.set(nb.id, []);
+ this.expandedNotebooks.add(nb.id);
+ this.render();
+ } else {
+ await this.loadNotebooks();
+ }
+ } catch {
+ this.error = "Failed to create notebook";
+ this.render();
+ }
+ }
+
+ // ── Tiptap Editor ──
+
+ private mountEditor(note: Note) {
+ this.destroyEditor();
+ this.editorNoteId = note.id;
+
+ const isDemo = this.space === "demo";
+ const isAutomerge = !!(this.doc?.items?.[note.id]);
+ const isEditable = isAutomerge || isDemo;
+
+ // Branch on note type
+ switch (note.type) {
+ case 'CODE': this.mountCodeEditor(note, isEditable, isDemo); break;
+ case 'BOOKMARK':
+ case 'CLIP': this.mountBookmarkView(note, isEditable, isDemo); break;
+ case 'IMAGE': this.mountImageView(note, isEditable, isDemo); break;
+ case 'AUDIO': this.mountAudioView(note, isEditable, isDemo); break;
+ default: this.mountTiptapEditor(note, isEditable, isDemo); break;
+ }
+
+ // Mobile: inject back bar and slide to editor
+ this.contentZone.insertAdjacentHTML('afterbegin', this.mobileBackBarHtml());
+ this.contentZone.querySelector('.mobile-back-btn')?.addEventListener('click', () => this.mobileGoBack());
+ if (this.isMobile()) this.setMobileEditing(true);
+ }
+
+ private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) {
+ const useYjs = !isDemo && isEditable;
+
+ this.contentZone.innerHTML = `
+
+ `;
+
+ const container = this.shadow.getElementById('tiptap-container');
+ if (!container) return;
+
+ if (useYjs) {
+ this.mountTiptapWithYjs(note, container);
+ } else {
+ this.mountTiptapLegacy(note, isEditable, isDemo, container);
+ }
+
+ this.editor!.registerPlugin(createSlashCommandPlugin(this.editor!, this.shadow));
+
+ container.addEventListener('slash-insert-image', () => {
+ if (!this.editor) return;
+ const { from } = this.editor.view.state.selection;
+ const coords = this.editor.view.coordsAtPos(from);
+ const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
+ this.showUrlPopover(rect, 'Enter image URL...').then(url => {
+ if (url) this.editor!.chain().focus().setImage({ src: url }).run();
+ });
+ });
+
+ container.addEventListener('slash-create-typed-note', ((e: CustomEvent) => {
+ const { type } = e.detail || {};
+ if (type && this.selectedNotebook) {
+ this.createNoteViaSync({ type });
+ }
+ }) as EventListener);
+
+ this.wireTitleInput(note, isEditable, isDemo);
+ this.attachToolbarListeners();
+ this.wireCommentHighlightClicks();
+ }
+
+ /** Mount TipTap with Yjs collaboration (real-time co-editing). */
+ private mountTiptapWithYjs(note: Note, container: HTMLElement) {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ const spaceSlug = runtime?.space || this.space;
+ const roomName = `rdocs:${spaceSlug}:${note.id}`;
+
+ // Create Y.Doc
+ this.ydoc = new Y.Doc();
+ const fragment = this.ydoc.getXmlFragment('content');
+
+ // IndexedDB persistence for offline
+ this.yIndexedDb = new IndexeddbPersistence(roomName, this.ydoc);
+
+ // Set awareness identity BEFORE connecting provider (avoids anonymous ghost)
+ const sessionForAwareness = this.getSessionInfo();
+
+ // Connect Yjs provider over rSpace WebSocket
+ if (runtime?.isInitialized) {
+ this.yjsProvider = new RSpaceYjsProvider(note.id, this.ydoc, runtime);
+ // Pre-set user so the first awareness broadcast has the correct name
+ this.yjsProvider.awareness.setLocalStateField('user', {
+ name: sessionForAwareness.username || 'Anonymous',
+ color: this.userColor(sessionForAwareness.userId || 'anon'),
+ });
+ }
+
+ // Content migration: if Y.Doc fragment is empty and Automerge has content
+ this.yIndexedDb.on('synced', () => {
+ if (fragment.length === 0 && note.content) {
+ // Migrate existing content into Yjs by creating a temp editor, setting content, then destroying
+ let content: any = '';
+ if (note.content_format === 'tiptap-json') {
+ try { content = JSON.parse(note.content); } catch { content = note.content; }
+ } else {
+ content = note.content;
+ }
+ if (this.editor && content) {
+ this.editor.commands.setContent(content, { emitUpdate: false });
+ }
+ // Mark as collab-enabled in Automerge
+ if (this.doc?.items?.[note.id]) {
+ this.updateNoteField(note.id, 'collabEnabled', 'true');
+ }
+ }
+ });
+
+ // Create editor with Yjs sync/undo plugins registered directly
+ this.editor = new Editor({
+ element: container,
+ editable: true,
+ extensions: [
+ StarterKit.configure({
+ codeBlock: false,
+ heading: { levels: [1, 2, 3, 4] },
+ undoRedo: false, // Yjs has its own undo/redo
+ link: false,
+ underline: false,
+ }),
+ Link.configure({ openOnClick: false }),
+ Image, TaskList, TaskItem.configure({ nested: true }),
+ Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
+ CodeBlockLowlight.configure({ lowlight }), Typography, Underline,
+ Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
+ CommentMark,
+ SuggestionInsertMark,
+ SuggestionDeleteMark,
+ ],
+ onSelectionUpdate: () => { this.updateToolbarState(); },
+ });
+
+ // Register Yjs sync and undo plugins
+ this.editor.registerPlugin(ySyncPlugin(fragment));
+ this.editor.registerPlugin(yUndoPlugin());
+
+ // Register suggestion plugin for track-changes mode
+ const suggestionPlugin = createSuggestionPlugin(
+ () => this.suggestingMode,
+ () => {
+ const s = this.getSessionInfo();
+ return { authorId: s.userId, authorName: s.username };
+ },
+ );
+ this.editor.registerPlugin(suggestionPlugin);
+
+ // Register cursor presence plugin
+ if (this.yjsProvider) {
+ const cursorPlugin = yCursorPlugin(
+ this.yjsProvider.awareness,
+ { cursorBuilder: this.buildCollabCursor.bind(this) }
+ );
+ this.editor.registerPlugin(cursorPlugin);
+
+ // Update collab status bar when peers change
+ this.yjsProvider.awareness.on('update', () => {
+ this.updatePeersIndicator();
+ });
+ }
+
+ // Initial collab status bar update
+ this.updatePeersIndicator();
+
+ // Periodic plaintext sync to Automerge (for search indexing)
+ this.yjsPlainTextTimer = setInterval(() => {
+ if (!this.editor || !this.editorNoteId) return;
+ const plain = this.editor.getText();
+ this.updateNoteField(this.editorNoteId, 'contentPlain', plain);
+ }, 5000);
+ }
+
+ /** Mount TipTap without Yjs (demo mode / read-only). */
+ private mountTiptapLegacy(note: Note, isEditable: boolean, isDemo: boolean, container: HTMLElement) {
+ let content: any = '';
+ if (note.content) {
+ if (note.content_format === 'tiptap-json') {
+ try { content = JSON.parse(note.content); } catch { content = note.content; }
+ } else {
+ content = note.content;
+ }
+ }
+
+ this.editor = new Editor({
+ element: container,
+ editable: isEditable,
+ extensions: [
+ StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] }, link: false, underline: false }),
+ Link.configure({ openOnClick: false }),
+ Image, TaskList, TaskItem.configure({ nested: true }),
+ Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
+ CodeBlockLowlight.configure({ lowlight }), Typography, Underline,
+ Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
+ CommentMark,
+ SuggestionInsertMark,
+ SuggestionDeleteMark,
+ ],
+ content,
+ onUpdate: ({ editor }) => {
+ if (this.isRemoteUpdate) return;
+ if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
+ this.editorUpdateTimer = setTimeout(() => {
+ const json = JSON.stringify(editor.getJSON());
+ const plain = editor.getText();
+ const noteId = this.editorNoteId;
+ if (!noteId) return;
+ if (isDemo) {
+ this.demoUpdateNoteField(noteId, "content", json);
+ this.demoUpdateNoteField(noteId, "content_plain", plain);
+ this.demoUpdateNoteField(noteId, "content_format", 'tiptap-json');
+ } else {
+ this.updateNoteField(noteId, "content", json);
+ this.updateNoteField(noteId, "contentPlain", plain);
+ this.updateNoteField(noteId, "contentFormat", 'tiptap-json');
+ }
+ }, 800);
+ },
+ onSelectionUpdate: () => { this.updateToolbarState(); },
+ });
+ }
+
+ /** Build a DOM element for remote collaborator cursor. */
+ private buildCollabCursor(user: { name: string; color: string }) {
+ const cursor = document.createElement('span');
+ cursor.className = 'collab-cursor';
+ cursor.style.borderLeftColor = user.color;
+
+ const label = document.createElement('span');
+ label.className = 'collab-cursor-label';
+ label.style.backgroundColor = user.color;
+ label.textContent = user.name;
+ cursor.appendChild(label);
+
+ return cursor;
+ }
+
+ /** Derive a stable color from a user ID string. */
+ private userColor(id: string): string {
+ let hash = 0;
+ for (let i = 0; i < id.length; i++) {
+ hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
+ }
+ const hue = Math.abs(hash) % 360;
+ return `hsl(${hue}, 70%, 50%)`;
+ }
+
+ /** Get session info for cursor display. */
+ private getSessionInfo(): { username: string; userId: string } {
+ try {
+ const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
+ const c = sess?.claims;
+ return {
+ username: c?.username || c?.displayName || sess?.username || 'Anonymous',
+ userId: c?.sub || sess?.userId || 'anon',
+ };
+ } catch {
+ return { username: 'Anonymous', userId: 'anon' };
+ }
+ }
+
+ // ── Presence indicators ──
+
+ /** Start presence broadcasting and listening. Called once when runtime is available. */
+ private setupPresence() {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (this._presenceUnsub) return;
+ if (!runtime?.isInitialized) {
+ setTimeout(() => this.setupPresence(), 2000);
+ return;
+ }
+
+ // Listen for presence messages from peers (for sidebar notebook/note dots)
+ this._presenceUnsub = runtime.onCustomMessage('presence', (msg: any) => {
+ if (msg.module !== 'rdocs' || !msg.peerId) return;
+ this._presencePeers.set(msg.peerId, {
+ peerId: msg.peerId,
+ username: msg.username || 'Anonymous',
+ color: msg.color || '#888',
+ notebookId: msg.notebookId || null,
+ noteId: msg.noteId || null,
+ lastSeen: Date.now(),
+ });
+ this.renderPresenceIndicators();
+ });
+
+ // Use shared heartbeat for broadcasting
+ this._stopPresence = startPresenceHeartbeat(() => ({
+ module: 'rdocs',
+ context: this.selectedNote
+ ? `${this.selectedNotebook?.title || 'Notebook'} > ${this.selectedNote.title}`
+ : this.selectedNotebook?.title || '',
+ notebookId: this.selectedNotebook?.id,
+ noteId: this.selectedNote?.id,
+ }));
+
+ // GC: remove stale peers every 15s (for sidebar dots)
+ this._presenceGC = setInterval(() => {
+ const cutoff = Date.now() - 20_000;
+ let changed = false;
+ for (const [id, peer] of this._presencePeers) {
+ if (peer.lastSeen < cutoff) {
+ this._presencePeers.delete(id);
+ changed = true;
+ }
+ }
+ if (changed) this.renderPresenceIndicators();
+ }, 15_000);
+ }
+
+ /** Broadcast current user position to peers. */
+ private broadcastPresence() {
+ sharedBroadcastPresence({
+ module: 'rdocs',
+ context: this.selectedNote
+ ? `${this.selectedNotebook?.title || 'Notebook'} > ${this.selectedNote.title}`
+ : this.selectedNotebook?.title || '',
+ notebookId: this.selectedNotebook?.id,
+ noteId: this.selectedNote?.id,
+ });
+ }
+
+ /** Patch presence dots onto sidebar notebook headers and note items. */
+ private renderPresenceIndicators() {
+ // Remove all existing presence dots
+ this.shadow.querySelectorAll('.presence-dots').forEach(el => el.remove());
+
+ // Notebook headers in sidebar
+ this.shadow.querySelectorAll('.sbt-notebook-header[data-toggle-notebook]').forEach(header => {
+ const nbId = header.dataset.toggleNotebook;
+ const peers = Array.from(this._presencePeers.values()).filter(p => p.notebookId === nbId);
+ if (peers.length === 0) return;
+ header.appendChild(this.buildPresenceDots(peers));
+ });
+
+ // Note items in sidebar
+ this.shadow.querySelectorAll('.sbt-note[data-note]').forEach(item => {
+ const noteId = item.dataset.note;
+ const peers = Array.from(this._presencePeers.values()).filter(p => p.noteId === noteId);
+ if (peers.length === 0) return;
+ item.appendChild(this.buildPresenceDots(peers));
+ });
+ }
+
+ /** Build a presence-dots container for a set of peers. */
+ private buildPresenceDots(peers: { username: string; color: string }[]): HTMLSpanElement {
+ const container = document.createElement('span');
+ container.className = 'presence-dots';
+ const show = peers.slice(0, 3);
+ for (const p of show) {
+ const dot = document.createElement('span');
+ dot.className = 'presence-dot';
+ dot.style.background = p.color;
+ dot.title = p.username;
+ container.appendChild(dot);
+ }
+ if (peers.length > 3) {
+ const more = document.createElement('span');
+ more.className = 'presence-dot-more';
+ more.textContent = `+${peers.length - 3}`;
+ container.appendChild(more);
+ }
+ return container;
+ }
+
+ /** Tear down presence listeners and timers. */
+ private cleanupPresence() {
+ this._stopPresence?.();
+ this._stopPresence = null;
+ this._presenceUnsub?.();
+ this._presenceUnsub = null;
+ if (this._presenceGC) { clearInterval(this._presenceGC); this._presenceGC = null; }
+ this._presencePeers.clear();
+ }
+
+ private mountCodeEditor(note: Note, isEditable: boolean, isDemo: boolean) {
+ const languages = ['javascript', 'typescript', 'python', 'rust', 'go', 'html', 'css', 'json', 'sql', 'bash', 'c', 'cpp', 'java', 'ruby', 'php', 'markdown', 'yaml', 'toml', 'other'];
+ const currentLang = note.language || 'javascript';
+
+ this.contentZone.innerHTML = `
+
+
+
+
+
+
+
+ `;
+
+ const textarea = this.shadow.getElementById('code-textarea') as HTMLTextAreaElement;
+ const langSelect = this.shadow.getElementById('code-lang-select') as HTMLSelectElement;
+
+ if (textarea && isEditable) {
+ let timer: any;
+ textarea.addEventListener('input', () => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ if (isDemo) {
+ this.demoUpdateNoteField(note.id, "content", textarea.value);
+ this.demoUpdateNoteField(note.id, "content_plain", textarea.value);
+ } else {
+ this.updateNoteField(note.id, "content", textarea.value);
+ this.updateNoteField(note.id, "contentPlain", textarea.value);
+ }
+ }, 800);
+ });
+ // Tab inserts a tab character
+ textarea.addEventListener('keydown', (e) => {
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ const start = textarea.selectionStart;
+ textarea.value = textarea.value.substring(0, start) + '\t' + textarea.value.substring(textarea.selectionEnd);
+ textarea.selectionStart = textarea.selectionEnd = start + 1;
+ textarea.dispatchEvent(new Event('input'));
+ }
+ });
+ }
+
+ if (langSelect && isEditable) {
+ langSelect.addEventListener('change', () => {
+ if (isDemo) {
+ this.demoUpdateNoteField(note.id, "language", langSelect.value);
+ } else {
+ this.updateNoteField(note.id, "language", langSelect.value);
+ }
+ });
+ }
+
+ this.wireTitleInput(note, isEditable, isDemo);
+ }
+
+ private mountBookmarkView(note: Note, isEditable: boolean, isDemo: boolean) {
+ const hostname = note.url ? (() => { try { return new URL(note.url).hostname; } catch { return note.url; } })() : '';
+ const favicon = note.url ? `https://www.google.com/s2/favicons?sz=32&domain=${hostname}` : '';
+
+ this.contentZone.innerHTML = `
+
+
+
+ ${favicon ? `

` : ''}
+
+
+
+
+ `;
+
+ // Mount tiptap for the excerpt/notes
+ const container = this.shadow.getElementById('tiptap-container');
+ if (!container) return;
+
+ let content: any = '';
+ if (note.content) {
+ if (note.content_format === 'tiptap-json') {
+ try { content = JSON.parse(note.content); } catch { content = note.content; }
+ } else { content = note.content; }
+ }
+
+ this.editor = new Editor({
+ element: container, editable: isEditable,
+ extensions: [
+ StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] }, link: false, underline: false }),
+ Link.configure({ openOnClick: false }), Image,
+ Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }),
+ Typography, Underline, Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
+ ],
+ content,
+ onUpdate: ({ editor }) => {
+ if (this.isRemoteUpdate) return;
+ if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
+ this.editorUpdateTimer = setTimeout(() => {
+ const json = JSON.stringify(editor.getJSON());
+ const plain = editor.getText();
+ const noteId = this.editorNoteId;
+ if (!noteId) return;
+ if (isDemo) {
+ this.demoUpdateNoteField(noteId, "content", json);
+ this.demoUpdateNoteField(noteId, "content_plain", plain);
+ } else {
+ this.updateNoteField(noteId, "content", json);
+ this.updateNoteField(noteId, "contentPlain", plain);
+ }
+ }, 800);
+ },
+ });
+
+ // Wire URL input
+ const urlInput = this.shadow.getElementById('bookmark-url-input') as HTMLInputElement;
+ if (urlInput && isEditable) {
+ let timer: any;
+ urlInput.addEventListener('input', () => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ if (isDemo) {
+ this.demoUpdateNoteField(note.id, "url", urlInput.value);
+ } else {
+ this.updateNoteField(note.id, "url", urlInput.value);
+ }
+ }, 500);
+ });
+ }
+
+ this.wireTitleInput(note, isEditable, isDemo);
+ }
+
+ private mountImageView(note: Note, isEditable: boolean, isDemo: boolean) {
+ this.contentZone.innerHTML = `
+
+
+ ${note.fileUrl
+ ? `
`
+ : `
+
+
`
+ }
+
+
+ `;
+
+ // Mount tiptap for caption/notes
+ const container = this.shadow.getElementById('tiptap-container');
+ if (container) {
+ let content: any = '';
+ if (note.content) {
+ if (note.content_format === 'tiptap-json') {
+ try { content = JSON.parse(note.content); } catch { content = note.content; }
+ } else { content = note.content; }
+ }
+ this.editor = new Editor({
+ element: container, editable: isEditable,
+ extensions: [
+ StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
+ Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline,
+ Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
+ ],
+ content,
+ onUpdate: ({ editor }) => {
+ if (this.isRemoteUpdate) return;
+ if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
+ this.editorUpdateTimer = setTimeout(() => {
+ const json = JSON.stringify(editor.getJSON());
+ const plain = editor.getText();
+ const noteId = this.editorNoteId;
+ if (!noteId) return;
+ if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); }
+ else { this.updateNoteField(noteId, "content", json); this.updateNoteField(noteId, "contentPlain", plain); }
+ }, 800);
+ },
+ });
+ }
+
+ // Wire image URL input
+ const imgUrlInput = this.shadow.getElementById('image-url-input') as HTMLInputElement;
+ if (imgUrlInput && isEditable) {
+ let timer: any;
+ imgUrlInput.addEventListener('input', () => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ if (isDemo) { this.demoUpdateNoteField(note.id, "fileUrl", imgUrlInput.value); }
+ else { this.updateNoteField(note.id, "fileUrl", imgUrlInput.value); }
+ }, 500);
+ });
+ }
+
+ this.wireTitleInput(note, isEditable, isDemo);
+ }
+
+ private mountAudioView(note: Note, isEditable: boolean, isDemo: boolean) {
+ const durationStr = note.duration ? `${Math.floor(note.duration / 60)}:${String(note.duration % 60).padStart(2, '0')}` : '';
+
+ this.contentZone.innerHTML = `
+
+
+ ${note.fileUrl
+ ? `
+
+ ${durationStr ? `
${durationStr}` : ''}
+
`
+ : `
+
+
`
+ }
+
+
+ `;
+
+ // Mount tiptap for transcript
+ const container = this.shadow.getElementById('tiptap-container');
+ if (container) {
+ let content: any = '';
+ if (note.content) {
+ if (note.content_format === 'tiptap-json') {
+ try { content = JSON.parse(note.content); } catch { content = note.content; }
+ } else { content = note.content; }
+ }
+ this.editor = new Editor({
+ element: container, editable: isEditable,
+ extensions: [
+ StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
+ Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline,
+ Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
+ ],
+ content,
+ onUpdate: ({ editor }) => {
+ if (this.isRemoteUpdate) return;
+ if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
+ this.editorUpdateTimer = setTimeout(() => {
+ const json = JSON.stringify(editor.getJSON());
+ const plain = editor.getText();
+ const noteId = this.editorNoteId;
+ if (!noteId) return;
+ if (isDemo) { this.demoUpdateNoteField(noteId, "content", json); this.demoUpdateNoteField(noteId, "content_plain", plain); }
+ else { this.updateNoteField(noteId, "content", json); this.updateNoteField(noteId, "contentPlain", plain); }
+ }, 800);
+ },
+ });
+ }
+
+ this.wireTitleInput(note, isEditable, isDemo);
+
+ // Wire record button
+ this.shadow.getElementById('btn-start-recording')?.addEventListener('click', () => {
+ this.startAudioRecording(note, isDemo);
+ });
+ }
+
+ private async startAudioRecording(note: Note, isDemo: boolean) {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
+ ? 'audio/webm;codecs=opus'
+ : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
+
+ const audioChunks: Blob[] = [];
+ this.audioSegments = [];
+ this.audioRecorder = new MediaRecorder(stream, { mimeType });
+
+ this.audioRecorder.ondataavailable = (e) => {
+ if (e.data.size > 0) audioChunks.push(e.data);
+ };
+
+ // Replace placeholder with recording UI
+ const placeholder = this.shadow.querySelector('.audio-record-placeholder');
+ if (placeholder) {
+ placeholder.innerHTML = `
+
+ `;
+ }
+
+ // Start timer
+ this.audioRecordingStart = Date.now();
+ let elapsed = 0;
+ this.audioRecordingTimer = setInterval(() => {
+ elapsed = Math.floor((Date.now() - this.audioRecordingStart) / 1000);
+ const timerEl = this.shadow.querySelector('.audio-recording-timer');
+ if (timerEl) timerEl.textContent = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`;
+ }, 1000);
+
+ // Start speech dictation with segment tracking
+ if (SpeechDictation.isSupported()) {
+ this.audioRecordingDictation = new SpeechDictation({
+ onInterim: (text) => {
+ const idx = this.audioSegments.findIndex(s => !s.isFinal);
+ const ts = Math.floor((Date.now() - this.audioRecordingStart) / 1000);
+ if (idx >= 0) {
+ this.audioSegments[idx].text = text;
+ } else {
+ this.audioSegments.push({ id: crypto.randomUUID(), text, timestamp: ts, isFinal: false });
+ }
+ this.renderAudioSegments();
+ },
+ onFinal: (text) => {
+ const idx = this.audioSegments.findIndex(s => !s.isFinal);
+ const ts = Math.floor((Date.now() - this.audioRecordingStart) / 1000);
+ if (idx >= 0) {
+ this.audioSegments[idx] = { ...this.audioSegments[idx], text, isFinal: true };
+ } else {
+ this.audioSegments.push({ id: crypto.randomUUID(), text, timestamp: ts, isFinal: true });
+ }
+ this.renderAudioSegments();
+ },
+ });
+ this.audioRecordingDictation.start();
+ }
+
+ this.audioRecorder.start(1000);
+
+ // Wire stop button
+ this.shadow.getElementById('btn-stop-recording')?.addEventListener('click', async () => {
+ // Stop everything
+ if (this.audioRecordingTimer) { clearInterval(this.audioRecordingTimer); this.audioRecordingTimer = null; }
+ this.audioRecordingDictation?.stop();
+ this.audioRecordingDictation?.destroy();
+ this.audioRecordingDictation = null;
+
+ this.audioRecorder!.onstop = async () => {
+ stream.getTracks().forEach(t => t.stop());
+ const blob = new Blob(audioChunks, { type: mimeType });
+ const duration = Math.floor((Date.now() - this.audioRecordingStart) / 1000);
+
+ // Upload audio
+ let fileUrl = '';
+ if (!isDemo) {
+ try {
+ const base = this.getApiBase();
+ const fd = new FormData();
+ fd.append('file', blob, 'recording.webm');
+ const uploadRes = await fetch(`${base}/api/uploads`, {
+ method: 'POST', headers: this.authHeaders(), body: fd,
+ });
+ if (uploadRes.ok) { fileUrl = (await uploadRes.json()).url; }
+ } catch { /* continue without file */ }
+ }
+
+ // Convert segments to Tiptap JSON
+ const finalSegments = this.audioSegments.filter(s => s.isFinal);
+ const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+ let tiptapContent: any = { type: 'doc', content: [{ type: 'paragraph' }] };
+ if (finalSegments.length > 0) {
+ tiptapContent = {
+ type: 'doc',
+ content: finalSegments.map(seg => ({
+ type: 'paragraph',
+ content: [
+ { type: 'text', marks: [{ type: 'code' }], text: `[${fmt(seg.timestamp)}]` },
+ { type: 'text', text: ` ${seg.text}` },
+ ],
+ })),
+ };
+ }
+
+ const contentJson = JSON.stringify(tiptapContent);
+ const contentPlain = finalSegments.map(s => s.text).join(' ');
+
+ // Update note fields
+ const noteId = note.id;
+ if (isDemo) {
+ if (fileUrl) this.demoUpdateNoteField(noteId, 'fileUrl', fileUrl);
+ this.demoUpdateNoteField(noteId, 'content', contentJson);
+ this.demoUpdateNoteField(noteId, 'content_plain', contentPlain);
+ } else {
+ if (fileUrl) this.updateNoteField(noteId, 'fileUrl', fileUrl);
+ this.updateNoteField(noteId, 'duration', String(duration));
+ this.updateNoteField(noteId, 'content', contentJson);
+ this.updateNoteField(noteId, 'contentPlain', contentPlain);
+ this.updateNoteField(noteId, 'contentFormat', 'tiptap-json');
+ }
+
+ // Update local note for immediate display
+ (note as any).fileUrl = fileUrl || note.fileUrl;
+ (note as any).duration = duration;
+ (note as any).content = contentJson;
+ (note as any).content_plain = contentPlain;
+ (note as any).content_format = 'tiptap-json';
+
+ // Re-mount audio view
+ this.audioRecorder = null;
+ this.audioSegments = [];
+ this.destroyEditor();
+ this.editorNoteId = noteId;
+ this.mountAudioView(note, true, isDemo);
+ };
+
+ if (this.audioRecorder?.state === 'recording') {
+ this.audioRecorder.stop();
+ }
+ });
+ } catch (err) {
+ console.error('Failed to start audio recording:', err);
+ }
+ }
+
+ private renderAudioSegments() {
+ const container = this.shadow.querySelector('.audio-live-segments');
+ if (!container) return;
+ const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
+ const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+
+ container.innerHTML = this.audioSegments.map(seg => `
+
+ [${fmt(seg.timestamp)}]
+ ${esc(seg.text)}
+
+ `).join('');
+ container.scrollTop = container.scrollHeight;
+ }
+
+ /** Shared title input wiring for all editor types */
+ private wireTitleInput(note: Note, _isEditable: boolean, isDemo: boolean) {
+ const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
+ if (titleInput) {
+ let titleTimeout: any;
+ titleInput.addEventListener("input", () => {
+ clearTimeout(titleTimeout);
+ titleTimeout = setTimeout(() => {
+ if (isDemo) { this.demoUpdateNoteField(note.id, "title", titleInput.value); }
+ else { this.updateNoteField(note.id, "title", titleInput.value); }
+ }, 500);
+ });
+ }
+ }
+
+ private destroyEditor() {
+ if (this.editorUpdateTimer) {
+ clearTimeout(this.editorUpdateTimer);
+ this.editorUpdateTimer = null;
+ }
+ if (this.yjsPlainTextTimer) {
+ clearInterval(this.yjsPlainTextTimer);
+ this.yjsPlainTextTimer = null;
+ }
+ if (this.yjsProvider) {
+ this.yjsProvider.destroy();
+ this.yjsProvider = null;
+ }
+ if (this.yIndexedDb) {
+ this.yIndexedDb.destroy();
+ this.yIndexedDb = null;
+ }
+ if (this.ydoc) {
+ this.ydoc.destroy();
+ this.ydoc = null;
+ }
+ if (this.dictation) {
+ this.dictation.destroy();
+ this.dictation = null;
+ }
+ if (this.audioRecordingTimer) {
+ clearInterval(this.audioRecordingTimer);
+ this.audioRecordingTimer = null;
+ }
+ if (this.audioRecordingDictation) {
+ this.audioRecordingDictation.destroy();
+ this.audioRecordingDictation = null;
+ }
+ if (this.audioRecorder?.state === 'recording') {
+ this.audioRecorder.stop();
+ }
+ this.audioRecorder = null;
+ this.suggestingMode = false;
+ if (this.editor) {
+ this.editor.destroy();
+ this.editor = null;
+ }
+ this.editorNoteId = null;
+ }
+
+ private renderToolbar(): string {
+ const btn = (cmd: string, title: string) =>
+ ``;
+ return `
+ `;
+ }
+
+ private showUrlPopover(anchorRect: DOMRect, placeholder: string): Promise {
+ return new Promise((resolve) => {
+ this.shadow.querySelector('.url-popover')?.remove();
+
+ const popover = document.createElement('div');
+ popover.className = 'url-popover';
+
+ const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
+ if (window.innerWidth > 640) {
+ popover.style.left = `${anchorRect.left - hostRect.left}px`;
+ popover.style.top = `${anchorRect.bottom - hostRect.top + 4}px`;
+ }
+
+ const input = document.createElement('input');
+ input.type = 'url';
+ input.placeholder = placeholder;
+ input.className = 'url-popover__input';
+
+ const insertBtn = document.createElement('button');
+ insertBtn.textContent = 'Insert';
+ insertBtn.className = 'url-popover__btn url-popover__btn--insert';
+
+ const cancelBtn = document.createElement('button');
+ cancelBtn.textContent = 'Cancel';
+ cancelBtn.className = 'url-popover__btn url-popover__btn--cancel';
+
+ const btnRow = document.createElement('div');
+ btnRow.className = 'url-popover__actions';
+ btnRow.append(cancelBtn, insertBtn);
+
+ popover.append(input, btnRow);
+ this.shadow.appendChild(popover);
+ input.focus();
+
+ const cleanup = (value: string | null) => {
+ popover.remove();
+ resolve(value);
+ };
+
+ insertBtn.addEventListener('click', () => {
+ const val = input.value.trim();
+ cleanup(val || null);
+ });
+
+ cancelBtn.addEventListener('click', () => cleanup(null));
+
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const val = input.value.trim();
+ cleanup(val || null);
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ cleanup(null);
+ }
+ });
+ });
+ }
+
+ private attachToolbarListeners() {
+ const toolbar = this.shadow.getElementById('editor-toolbar');
+ if (!toolbar || !this.editor) return;
+
+ // Button clicks via event delegation
+ toolbar.addEventListener('click', (e) => {
+ const btn = (e.target as HTMLElement).closest('[data-cmd]') as HTMLElement;
+ if (!btn || btn.tagName === 'SELECT') return;
+ e.preventDefault();
+ const cmd = btn.dataset.cmd;
+ if (!this.editor) return;
+
+ switch (cmd) {
+ case 'bold': this.editor.chain().focus().toggleBold().run(); break;
+ case 'italic': this.editor.chain().focus().toggleItalic().run(); break;
+ case 'underline': this.editor.chain().focus().toggleUnderline().run(); break;
+ case 'strike': this.editor.chain().focus().toggleStrike().run(); break;
+ case 'code': this.editor.chain().focus().toggleCode().run(); break;
+ case 'bulletList': this.editor.chain().focus().toggleBulletList().run(); break;
+ case 'orderedList': this.editor.chain().focus().toggleOrderedList().run(); break;
+ case 'taskList': this.editor.chain().focus().toggleTaskList().run(); break;
+ case 'blockquote': this.editor.chain().focus().toggleBlockquote().run(); break;
+ case 'codeBlock': this.editor.chain().focus().toggleCodeBlock().run(); break;
+ case 'horizontalRule': this.editor.chain().focus().setHorizontalRule().run(); break;
+ case 'link': {
+ const rect = btn.getBoundingClientRect();
+ this.showUrlPopover(rect, 'Enter link URL...').then(url => {
+ if (url) this.editor!.chain().focus().setLink({ href: url }).run();
+ else this.editor!.chain().focus().run();
+ });
+ break;
+ }
+ case 'image': {
+ const rect = btn.getBoundingClientRect();
+ this.showUrlPopover(rect, 'Enter image URL...').then(url => {
+ if (url) this.editor!.chain().focus().setImage({ src: url }).run();
+ else this.editor!.chain().focus().run();
+ });
+ break;
+ }
+ case 'undo': this.editor.chain().focus().undo().run(); break;
+ case 'redo': this.editor.chain().focus().redo().run(); break;
+ case 'mic': this.toggleDictation(btn); break;
+ case 'summarize': this.summarizeNote(btn); break;
+ case 'comment': this.addComment(); break;
+ case 'toggleSuggesting': this.toggleSuggestingMode(btn); break;
+ }
+ });
+
+ // Heading select
+ const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement;
+ if (headingSelect) {
+ headingSelect.addEventListener('change', () => {
+ if (!this.editor) return;
+ const val = headingSelect.value;
+ if (val === 'paragraph') {
+ this.editor.chain().focus().setParagraph().run();
+ } else {
+ this.editor.chain().focus().setHeading({ level: parseInt(val) as 1 | 2 | 3 | 4 }).run();
+ }
+ });
+ }
+ }
+
+ /** Add a comment on the current selection. */
+ private addComment() {
+ if (!this.editor) return;
+ const { from, to, empty } = this.editor.state.selection;
+ if (empty) return; // Need selected text
+
+ const threadId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ const session = this.getSessionInfo();
+
+ // Apply comment mark to the selection
+ this.editor.chain().focus()
+ .setMark('comment', { threadId, resolved: false })
+ .run();
+
+ // Create thread in Automerge or demo storage
+ const noteId = this.editorNoteId;
+ if (noteId && this.doc?.items?.[noteId] && this.subscribedDocId) {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (runtime?.isInitialized) {
+ runtime.change(this.subscribedDocId as DocumentId, 'Add comment thread', (d: NotebookDoc) => {
+ const item = d.items[noteId] as any;
+ if (!item.comments) item.comments = {};
+ item.comments[threadId] = {
+ id: threadId,
+ anchor: `${from}-${to}`,
+ resolved: false,
+ messages: [],
+ createdAt: Date.now(),
+ };
+ });
+ this.doc = runtime.get(this.subscribedDocId as DocumentId);
+ }
+ } else if (this.space === 'demo' && noteId) {
+ if (!this._demoThreads.has(noteId)) this._demoThreads.set(noteId, {});
+ this._demoThreads.get(noteId)![threadId] = {
+ id: threadId,
+ anchor: `${from}-${to}`,
+ resolved: false,
+ messages: [],
+ createdAt: Date.now(),
+ };
+ }
+
+ // Open comment panel
+ this.showCommentPanel(threadId);
+ }
+
+ /** Toggle between editing and suggesting modes. */
+ private toggleSuggestingMode(btn: HTMLElement) {
+ this.suggestingMode = !this.suggestingMode;
+ btn.classList.toggle('active', this.suggestingMode);
+ btn.title = this.suggestingMode ? 'Switch to Editing Mode' : 'Switch to Suggesting Mode';
+
+ // Update editor's editable state to reflect mode
+ const container = this.shadow.getElementById('tiptap-container');
+ if (container) {
+ container.classList.toggle('suggesting-mode', this.suggestingMode);
+ }
+
+ // Show/hide suggestion review bar
+ this.updateSuggestionReviewBar();
+ }
+
+ /** Show a review bar when there are pending suggestions. */
+ private updateSuggestionReviewBar() {
+ let bar = this.shadow.getElementById('suggestion-review-bar');
+ if (!this.editor) {
+ bar?.remove();
+ return;
+ }
+
+ // Count suggestions
+ const ids = new Set();
+ this.editor.state.doc.descendants((node: any) => {
+ if (!node.isText) return;
+ for (const mark of node.marks) {
+ if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
+ ids.add(mark.attrs.suggestionId);
+ }
+ }
+ });
+
+ if (ids.size === 0 && !this.suggestingMode) {
+ bar?.remove();
+ return;
+ }
+
+ if (!bar) {
+ bar = document.createElement('div');
+ bar.id = 'suggestion-review-bar';
+ bar.className = 'suggestion-review-bar';
+ // Insert after toolbar
+ const toolbar = this.shadow.getElementById('editor-toolbar');
+ if (toolbar?.parentNode) {
+ toolbar.parentNode.insertBefore(bar, toolbar.nextSibling);
+ }
+ }
+
+ bar.innerHTML = `
+ ${this.suggestingMode ? 'Suggesting' : 'Editing'}
+ ${ids.size > 0 ? `
+ ${ids.size} suggestion${ids.size !== 1 ? 's' : ''} — review in sidebar
+ ` : 'Start typing to suggest changes'}
+ `;
+
+ // Open sidebar to show suggestions when there are any
+ if (ids.size > 0) this.showCommentPanel();
+ }
+
+ /** Show an accept/reject popover near a clicked suggestion mark. */
+ private showSuggestionPopover(suggestionId: string, authorName: string, type: 'insert' | 'delete', rect: DOMRect) {
+ // Remove any existing popover
+ this.shadow.querySelector('.suggestion-popover')?.remove();
+
+ const pop = document.createElement('div');
+ pop.className = 'suggestion-popover';
+
+ const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
+ pop.style.left = `${rect.left - hostRect.left}px`;
+ pop.style.top = `${rect.bottom - hostRect.top + 4}px`;
+
+ pop.innerHTML = `
+
+
+
+
+
+ `;
+
+ pop.querySelector('.sp-accept')!.addEventListener('click', () => {
+ if (this.editor) {
+ acceptSuggestion(this.editor, suggestionId);
+ this.updateSuggestionReviewBar();
+ this.syncSuggestionsToPanel(true);
+ }
+ pop.remove();
+ });
+ pop.querySelector('.sp-reject')!.addEventListener('click', () => {
+ if (this.editor) {
+ rejectSuggestion(this.editor, suggestionId);
+ this.updateSuggestionReviewBar();
+ this.syncSuggestionsToPanel(true);
+ }
+ pop.remove();
+ });
+
+ this.shadow.appendChild(pop);
+
+ // Close on click outside
+ const close = (e: Event) => {
+ if (!pop.contains((e as MouseEvent).target as Node)) {
+ pop.remove();
+ this.shadow.removeEventListener('click', close);
+ }
+ };
+ setTimeout(() => this.shadow.addEventListener('click', close), 0);
+ }
+
+ /** Collect all pending suggestions from the editor doc. */
+ private collectSuggestions(): { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }[] {
+ if (!this.editor) return [];
+ const map = new Map();
+ this.editor.state.doc.descendants((node: any) => {
+ if (!node.isText) return;
+ for (const mark of node.marks) {
+ if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
+ const id = mark.attrs.suggestionId;
+ const existing = map.get(id);
+ if (existing) {
+ existing.text += node.text || '';
+ } else {
+ map.set(id, {
+ id,
+ type: mark.type.name === 'suggestionInsert' ? 'insert' : 'delete',
+ text: node.text || '',
+ authorId: mark.attrs.authorId || '',
+ authorName: mark.attrs.authorName || 'Unknown',
+ createdAt: mark.attrs.createdAt || Date.now(),
+ });
+ }
+ }
+ }
+ });
+ return Array.from(map.values());
+ }
+
+ /** Push current suggestions to the comment panel (debounced to avoid letter-by-letter flicker). */
+ private syncSuggestionsToPanel(immediate = false) {
+ clearTimeout(this._suggestionSyncTimer);
+ const flush = () => {
+ const panel = this.shadow.querySelector('notes-comment-panel') as any;
+ if (!panel) return;
+ const suggestions = this.collectSuggestions();
+ panel.suggestions = suggestions;
+ const sidebar = this.shadow.getElementById('comment-sidebar');
+ if (sidebar && suggestions.length > 0) {
+ sidebar.classList.add('has-comments');
+ }
+ };
+ if (immediate) {
+ flush();
+ } else {
+ this._suggestionSyncTimer = setTimeout(flush, 400);
+ }
+ }
+
+ /** Show comment panel for a specific thread. */
+ private showCommentPanel(threadId?: string) {
+ const sidebar = this.shadow.getElementById('comment-sidebar');
+ if (!sidebar) return;
+
+ let panel = this.shadow.querySelector('notes-comment-panel') as any;
+ if (!panel) {
+ panel = document.createElement('notes-comment-panel');
+ sidebar.appendChild(panel);
+ // Listen for demo thread mutations from comment panel
+ panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => {
+ const { noteId, threads } = e.detail;
+ if (noteId) this._demoThreads.set(noteId, threads);
+ });
+ // Listen for suggestion accept/reject from comment panel
+ panel.addEventListener('suggestion-accept', (e: CustomEvent) => {
+ if (this.editor && e.detail?.suggestionId) {
+ acceptSuggestion(this.editor, e.detail.suggestionId);
+ this.updateSuggestionReviewBar();
+ this.syncSuggestionsToPanel(true);
+ }
+ });
+ panel.addEventListener('suggestion-reject', (e: CustomEvent) => {
+ if (this.editor && e.detail?.suggestionId) {
+ rejectSuggestion(this.editor, e.detail.suggestionId);
+ this.updateSuggestionReviewBar();
+ this.syncSuggestionsToPanel(true);
+ }
+ });
+ }
+ panel.noteId = this.editorNoteId;
+ panel.doc = this.doc;
+ panel.subscribedDocId = this.subscribedDocId;
+ panel.activeThreadId = threadId || null;
+ panel.editor = this.editor;
+ panel.space = this.space;
+ // Pass demo threads if in demo mode
+ if (this.space === 'demo' && this.editorNoteId) {
+ panel.demoThreads = this._demoThreads.get(this.editorNoteId) ?? null;
+ } else {
+ panel.demoThreads = null;
+ }
+ // Pass suggestions
+ panel.suggestions = this.collectSuggestions();
+
+ // Show sidebar when there are comments or suggestions
+ sidebar.classList.add('has-comments');
+ }
+
+ /** Hide comment sidebar when no comments exist. */
+ private hideCommentPanel() {
+ const sidebar = this.shadow.getElementById('comment-sidebar');
+ if (sidebar) sidebar.classList.remove('has-comments');
+ }
+
+ /** Wire click handling on comment highlights and suggestion marks in the editor. */
+ private wireCommentHighlightClicks() {
+ if (!this.editor) return;
+
+ // On selection change, check if cursor is inside a comment mark
+ this.editor.on('selectionUpdate', () => {
+ if (!this.editor) return;
+ const { $from } = this.editor.state.selection;
+ const commentMark = $from.marks().find(m => m.type.name === 'comment');
+ if (commentMark) {
+ const threadId = commentMark.attrs.threadId;
+ if (threadId) this.showCommentPanel(threadId);
+ }
+ });
+
+ // On any change, update the suggestion review bar + sidebar panel (debounced)
+ this.editor.on('update', () => {
+ this.updateSuggestionReviewBar();
+ this.syncSuggestionsToPanel(); // debounced — avoids letter-by-letter flicker
+ });
+
+ // Direct click on comment highlight or suggestion marks in the DOM
+ const container = this.shadow.getElementById('tiptap-container');
+ if (container) {
+ container.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+
+ // Comment highlights
+ const highlight = target.closest?.('.comment-highlight') as HTMLElement;
+ if (highlight) {
+ const threadId = highlight.getAttribute('data-thread-id');
+ if (threadId) this.showCommentPanel(threadId);
+ return;
+ }
+
+ // Suggestion marks — show accept/reject popover
+ const suggestionEl = target.closest?.('.suggestion-insert, .suggestion-delete') as HTMLElement;
+ if (suggestionEl) {
+ const suggestionId = suggestionEl.getAttribute('data-suggestion-id');
+ const authorName = suggestionEl.getAttribute('data-author-name') || 'Unknown';
+ const type = suggestionEl.classList.contains('suggestion-insert') ? 'insert' : 'delete';
+ if (suggestionId) {
+ const rect = suggestionEl.getBoundingClientRect();
+ this.showSuggestionPopover(suggestionId, authorName, type as 'insert' | 'delete', rect);
+ }
+ return;
+ }
+ });
+ }
+ }
+
+ private toggleDictation(btn: HTMLElement) {
+ if (this.dictation?.isRecording) {
+ this.dictation.stop();
+ btn.classList.remove('recording');
+ this.removeDictationPreview();
+ return;
+ }
+ if (!this.dictation) {
+ this.dictation = new SpeechDictation({
+ onInterim: (text) => {
+ this.updateDictationPreview(text);
+ },
+ onFinal: (text) => {
+ this.removeDictationPreview();
+ if (this.editor) {
+ this.editor.chain().focus().insertContent(text + ' ').run();
+ }
+ },
+ onStateChange: (recording) => {
+ btn.classList.toggle('recording', recording);
+ if (!recording) this.removeDictationPreview();
+ },
+ onError: (err) => {
+ console.warn('[Dictation]', err);
+ btn.classList.remove('recording');
+ this.removeDictationPreview();
+ },
+ });
+ }
+ this.dictation.start();
+ }
+
+ private updateDictationPreview(text: string) {
+ let preview = this.shadow.getElementById('dictation-preview');
+ if (!preview) {
+ preview = document.createElement('div');
+ preview.id = 'dictation-preview';
+ preview.className = 'dictation-preview';
+ const toolbar = this.shadow.getElementById('editor-toolbar');
+ if (toolbar) toolbar.insertAdjacentElement('afterend', preview);
+ else return;
+ }
+ preview.textContent = text;
+ }
+
+ private removeDictationPreview() {
+ this.shadow.getElementById('dictation-preview')?.remove();
+ }
+
+ private updateToolbarState() {
+ if (!this.editor) return;
+ const toolbar = this.shadow.getElementById('editor-toolbar');
+ if (!toolbar) return;
+
+ // Toggle active class on buttons
+ toolbar.querySelectorAll('.toolbar-btn[data-cmd]').forEach((btn) => {
+ const cmd = (btn as HTMLElement).dataset.cmd!;
+ let isActive = false;
+ switch (cmd) {
+ case 'bold': isActive = this.editor!.isActive('bold'); break;
+ case 'italic': isActive = this.editor!.isActive('italic'); break;
+ case 'underline': isActive = this.editor!.isActive('underline'); break;
+ case 'strike': isActive = this.editor!.isActive('strike'); break;
+ case 'code': isActive = this.editor!.isActive('code'); break;
+ case 'bulletList': isActive = this.editor!.isActive('bulletList'); break;
+ case 'orderedList': isActive = this.editor!.isActive('orderedList'); break;
+ case 'taskList': isActive = this.editor!.isActive('taskList'); break;
+ case 'blockquote': isActive = this.editor!.isActive('blockquote'); break;
+ case 'codeBlock': isActive = this.editor!.isActive('codeBlock'); break;
+ }
+ btn.classList.toggle('active', isActive);
+ });
+
+ // Update heading select
+ const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement;
+ if (headingSelect) {
+ if (this.editor.isActive('heading', { level: 1 })) headingSelect.value = '1';
+ else if (this.editor.isActive('heading', { level: 2 })) headingSelect.value = '2';
+ else if (this.editor.isActive('heading', { level: 3 })) headingSelect.value = '3';
+ else if (this.editor.isActive('heading', { level: 4 })) headingSelect.value = '4';
+ else headingSelect.value = 'paragraph';
+ }
+
+ // Update comment button state (active when text is selected)
+ const commentBtn = toolbar.querySelector('[data-cmd="comment"]') as HTMLElement;
+ if (commentBtn) {
+ commentBtn.classList.toggle('active', this.editor.isActive('comment'));
+ const { empty } = this.editor.state.selection;
+ commentBtn.style.opacity = empty ? '0.4' : '1';
+ }
+
+ // Update suggesting mode toggle
+ const suggestBtn = toolbar.querySelector('[data-cmd="toggleSuggesting"]') as HTMLElement;
+ if (suggestBtn) {
+ suggestBtn.classList.toggle('active', this.suggestingMode);
+ }
+
+ // Update peers count
+ this.updatePeersIndicator();
+ }
+
+ private updatePeersIndicator() {
+ const peersEl = this.shadow.getElementById('collab-peers');
+ const statusBar = this.shadow.getElementById('collab-status-bar');
+
+ if (!peersEl || !this.yjsProvider) {
+ if (peersEl) peersEl.style.display = 'none';
+ if (statusBar) statusBar.style.display = 'none';
+ return;
+ }
+
+ const connected = this.yjsProvider.isConnected;
+ const states = this.yjsProvider.awareness.getStates();
+ const peerCount = states.size - 1; // Exclude self
+
+ // Toolbar peer dots
+ if (peerCount > 0) {
+ peersEl.style.display = 'inline-flex';
+ peersEl.innerHTML = '';
+ let shown = 0;
+ for (const [clientId, state] of states) {
+ if (clientId === this.ydoc?.clientID) continue;
+ if (shown >= 3) break;
+ const user = state.user || { name: '?', color: '#888' };
+ const dot = document.createElement('span');
+ dot.className = 'peer-dot';
+ dot.style.backgroundColor = user.color;
+ dot.title = user.name;
+ peersEl.appendChild(dot);
+ shown++;
+ }
+ if (peerCount > 3) {
+ const more = document.createElement('span');
+ more.className = 'peer-more';
+ more.textContent = `+${peerCount - 3}`;
+ peersEl.appendChild(more);
+ }
+ } else {
+ peersEl.style.display = 'none';
+ }
+
+ // Collab status bar
+ if (statusBar) {
+ statusBar.style.display = 'flex';
+ const dot = statusBar.querySelector('.collab-status-dot') as HTMLElement;
+ const text = statusBar.querySelector('.collab-status-text') as HTMLElement;
+ if (!dot || !text) return;
+
+ if (!connected) {
+ dot.className = 'collab-status-dot offline';
+ text.textContent = 'Offline \u2014 changes will sync when reconnected';
+ } else if (peerCount > 0) {
+ dot.className = 'collab-status-dot live';
+ const names: string[] = [];
+ for (const [clientId, state] of states) {
+ if (clientId === this.ydoc?.clientID) continue;
+ names.push(state.user?.name || 'Anonymous');
+ }
+ text.textContent = `Live editing \u00B7 ${peerCount} collaborator${peerCount > 1 ? 's' : ''}`;
+ text.title = names.join(', ');
+ } else {
+ dot.className = 'collab-status-dot synced';
+ text.textContent = 'Live sync enabled';
+ text.title = '';
+ }
+ }
+
+ // Sidebar collab dot
+ const sidebarDot = this.navZone.querySelector('.sidebar-collab-dot') as HTMLElement;
+ if (sidebarDot) {
+ sidebarDot.className = `sidebar-collab-dot ${connected ? 'connected' : 'disconnected'}`;
+ }
+ }
+
+ // ── Helpers ──
+
+ private getNoteIcon(type: string): string {
+ switch (type) {
+ case "NOTE": return "\u{1F4DD}";
+ case "CODE": return "\u{1F4BB}";
+ case "BOOKMARK": return "\u{1F517}";
+ case "IMAGE": return "\u{1F5BC}";
+ case "AUDIO": return "\u{1F3A4}";
+ case "FILE": return "\u{1F4CE}";
+ case "CLIP": return "\u2702\uFE0F";
+ default: return "\u{1F4C4}";
+ }
+ }
+
+ private formatDate(dateStr: string): string {
+ const d = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - d.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+ if (diffDays === 0) return "Today";
+ if (diffDays === 1) return "Yesterday";
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return d.toLocaleDateString();
+ }
+
+ // ── Rendering ──
+
+ private render() {
+ this.renderNav();
+ if (this.selectedNote && this.editor && this.editorNoteId === this.selectedNote.id) {
+ // Editor already mounted — don't touch contentZone
+ } else {
+ this.renderContent();
+ }
+ this.renderMeta();
+ this.renderPresenceIndicators();
+ this._tour.renderOverlay();
+ }
+
+ startTour() { this._tour.start(); }
+
+ private renderNav() {
+ const isSearching = this.searchQuery.trim().length > 0;
+
+ let treeHtml = '';
+ if (isSearching && this.searchResults.length > 0) {
+ treeHtml = ``;
+ } else if (isSearching) {
+ treeHtml = 'No results
';
+ } else {
+ treeHtml = this.notebooks.map(nb => {
+ const isExpanded = this.expandedNotebooks.has(nb.id);
+ const notes = this.notebookNotes.get(nb.id) || [];
+ return `
+
+
+ ${isExpanded ? `
+ ${notes.length > 0 ? notes.map(n => `
+
+ ${this.getNoteIcon(n.type)}
+ ${this.esc(n.title)}
+ ${n.is_pinned ? '\u{1F4CC}' : ''}
+
+ `).join('') : '
No notes
'}
+
` : ''}
+
+ `;
+ }).join('');
+ }
+
+ // Preserve search input focus/cursor position
+ const prevInput = this.navZone.querySelector('#search-input') as HTMLInputElement;
+ const hadFocus = prevInput && (prevInput === this.shadow.activeElement);
+ const selStart = prevInput?.selectionStart;
+ const selEnd = prevInput?.selectionEnd;
+
+ this.navZone.innerHTML = `
+
+ `;
+
+ // Apply collapsed state
+ const layout = this.shadow.getElementById('notes-layout');
+ if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen);
+
+ // Restore search focus
+ if (hadFocus) {
+ const newInput = this.navZone.querySelector('#search-input') as HTMLInputElement;
+ if (newInput) {
+ newInput.focus();
+ if (selStart !== null && selEnd !== null) {
+ newInput.setSelectionRange(selStart, selEnd);
+ }
+ }
+ }
+
+ this.attachSidebarListeners();
+ }
+
+ private renderContent() {
+ if (this.error) {
+ this.contentZone.innerHTML = `${this.esc(this.error)}
`;
+ return;
+ }
+ if (this.loading) {
+ this.contentZone.innerHTML = 'Loading...
';
+ return;
+ }
+
+ // Empty state — no note selected
+ this.contentZone.innerHTML = `
+
+
+
Select a note from the sidebar
+ ${this.notebooks.length > 0
+ ? '
'
+ : '
'}
+
+ `;
+
+ // Wire CTA button
+ this.contentZone.querySelector('#btn-empty-new-note')?.addEventListener('click', () => {
+ const nbId = this.selectedNotebook?.id || (this.notebooks.length > 0 ? this.notebooks[0].id : null);
+ if (nbId) this.addNoteToNotebook(nbId);
+ });
+ this.contentZone.querySelector('#btn-empty-new-nb')?.addEventListener('click', () => {
+ this.space === 'demo' ? this.demoCreateNotebook() : this.createNotebook();
+ });
+ }
+
+ private renderMeta() {
+ if (this.selectedNote) {
+ const n = this.selectedNote;
+ const isAutomerge = !!(this.doc?.items?.[n.id]);
+ const isDemo = this.space === "demo";
+
+ // Get summary from Automerge doc or local note object
+ const item = this.doc?.items?.[n.id];
+ const summary = item?.summary || (n as any).summary || '';
+ const summaryModel = item?.summaryModel || (n as any).summaryModel || '';
+ const openNotebookSourceId = item?.openNotebookSourceId || (n as any).openNotebookSourceId || '';
+
+ this.metaZone.innerHTML = `
+
+ Type: ${n.type}
+ Created: ${this.formatDate(n.created_at)}
+ Updated: ${this.formatDate(n.updated_at)}
+ ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""}
+ ${isAutomerge ? 'Live' : ""}
+ ${isDemo ? 'Demo' : ""}
+
+ ${summary ? `
+
+
+
${this.esc(summary)}
+
` : ''}
+ ${!isDemo ? `
+
+
+ ${openNotebookSourceId ? '
Indexed' : ''}
+
` : ''}`;
+
+ // Attach summary panel event listeners
+ if (summary) {
+ const header = this.metaZone.querySelector('[data-action="toggle-summary"]');
+ header?.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('[data-action="regenerate-summary"]')) return;
+ const panel = this.metaZone.querySelector('.note-summary-panel');
+ panel?.classList.toggle('collapsed');
+ });
+ const regen = this.metaZone.querySelector('[data-action="regenerate-summary"]');
+ regen?.addEventListener('click', () => {
+ const btn = this.shadow.querySelector('[data-cmd="summarize"]') as HTMLElement;
+ if (btn) this.summarizeNote(btn);
+ });
+ }
+
+ // Send to Notebook button
+ const sendBtn = this.metaZone.querySelector('[data-action="send-to-notebook"]') as HTMLElement;
+ if (sendBtn && !openNotebookSourceId) {
+ sendBtn.addEventListener('click', () => this.sendToOpenNotebook());
+ }
+ } else {
+ this.metaZone.innerHTML = '';
+ }
+ }
+
+ private toggleSidebar(open?: boolean) {
+ this.sidebarOpen = open !== undefined ? open : !this.sidebarOpen;
+ const layout = this.shadow.getElementById('notes-layout');
+ if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen);
+ }
+
+ private attachSidebarListeners() {
+ const isDemo = this.space === "demo";
+
+ // Sidebar collapse (reopen button is wired once in connectedCallback)
+ this.shadow.getElementById('sidebar-collapse')?.addEventListener('click', () => this.toggleSidebar(false));
+
+ // Search
+ const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement;
+ let searchTimeout: any;
+ searchInput?.addEventListener("input", () => {
+ clearTimeout(searchTimeout);
+ this.searchQuery = searchInput.value;
+ searchTimeout = setTimeout(() => {
+ isDemo ? this.demoSearchNotes(this.searchQuery) : this.searchNotes(this.searchQuery);
+ }, 300);
+ });
+
+ // Create notebook
+ this.shadow.getElementById("create-notebook")?.addEventListener("click", () => {
+ isDemo ? this.demoCreateNotebook() : this.createNotebook();
+ });
+
+ // Import / Export
+ this.shadow.getElementById("btn-import-export")?.addEventListener("click", () => {
+ this.openImportExportDialog();
+ });
+
+ // Web Clipper download
+ this.shadow.getElementById("btn-web-clipper")?.addEventListener("click", () => {
+ window.open(`${this.getApiBase()}/extension/download`, '_blank');
+ });
+
+ // Tour
+ this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
+
+ // Toggle notebooks
+ this.shadow.querySelectorAll("[data-toggle-notebook]").forEach(el => {
+ el.addEventListener("click", (e) => {
+ if ((e.target as HTMLElement).closest('[data-add-note]')) return;
+ const id = (el as HTMLElement).dataset.toggleNotebook!;
+ this.expandNotebook(id);
+ });
+ });
+
+ // Add note to notebook (context menu on +)
+ this.shadow.querySelectorAll("[data-add-note]").forEach(el => {
+ el.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const nbId = (el as HTMLElement).dataset.addNote!;
+ this.showAddNoteMenu(nbId, el as HTMLElement);
+ });
+ });
+
+ // Click note in tree
+ this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => {
+ el.addEventListener("click", () => {
+ const noteId = (el as HTMLElement).dataset.note!;
+ const nbId = (el as HTMLElement).dataset.notebook!;
+ this.openNote(noteId, nbId);
+ });
+ });
+
+ // Click search result
+ this.shadow.querySelectorAll(".sidebar-search-result[data-note]").forEach(el => {
+ el.addEventListener("click", () => {
+ const noteId = (el as HTMLElement).dataset.note!;
+ const nbId = (el as HTMLElement).dataset.notebook!;
+ this.openNote(noteId, nbId);
+ });
+ });
+
+ // Make sidebar notes draggable (cross-rApp + intra-sidebar)
+ makeDraggableAll(this.shadow, ".sbt-note[data-note]", (el) => {
+ const title = el.querySelector(".sbt-note-title")?.textContent || "";
+ const id = el.dataset.note || "";
+ return title ? { title, module: "rdocs", entityId: id, label: "Note", color: "#f59e0b" } : null;
+ });
+
+ // Also set native drag data for intra-sidebar notebook moves + cleanup on dragend
+ this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => {
+ (el as HTMLElement).addEventListener("dragstart", (e) => {
+ const noteId = (el as HTMLElement).dataset.note!;
+ const nbId = (el as HTMLElement).dataset.notebook!;
+ e.dataTransfer?.setData("application/x-rdocs-move", JSON.stringify({ noteId, sourceNotebookId: nbId }));
+ (el as HTMLElement).style.opacity = "0.4";
+ });
+ (el as HTMLElement).addEventListener("dragend", () => {
+ (el as HTMLElement).style.opacity = "";
+ this.shadow.querySelectorAll('.sbt-note').forEach(n =>
+ (n as HTMLElement).classList.remove('drag-above', 'drag-below'));
+ });
+ });
+
+ // Notebook headers accept dropped notes (cross-notebook move)
+ this.shadow.querySelectorAll(".sbt-notebook-header[data-toggle-notebook]").forEach(el => {
+ (el as HTMLElement).addEventListener("dragover", (e) => {
+ if (e.dataTransfer?.types.includes("application/x-rdocs-move")) {
+ e.preventDefault();
+ (el as HTMLElement).classList.add("drop-target");
+ }
+ });
+ (el as HTMLElement).addEventListener("dragleave", () => {
+ (el as HTMLElement).classList.remove("drop-target");
+ });
+ (el as HTMLElement).addEventListener("drop", (e) => {
+ e.preventDefault();
+ (el as HTMLElement).classList.remove("drop-target");
+ const raw = e.dataTransfer?.getData("application/x-rdocs-move");
+ if (!raw) return;
+ try {
+ const { noteId, sourceNotebookId } = JSON.parse(raw);
+ const targetNotebookId = (el as HTMLElement).dataset.toggleNotebook!;
+ if (noteId && sourceNotebookId && targetNotebookId) {
+ this.moveNoteToNotebook(noteId, sourceNotebookId, targetNotebookId);
+ }
+ } catch {}
+ });
+ });
+
+ // Note items accept drops for intra-notebook reordering
+ this.shadow.querySelectorAll(".sbt-note[data-note]").forEach(el => {
+ const noteEl = el as HTMLElement;
+ noteEl.addEventListener("dragover", (e) => {
+ if (!e.dataTransfer?.types.includes("application/x-rdocs-move")) return;
+ e.preventDefault();
+ e.stopPropagation();
+ // Determine above/below based on cursor position
+ const rect = noteEl.getBoundingClientRect();
+ const midY = rect.top + rect.height / 2;
+ // Clear all indicators in this container
+ noteEl.closest('.sbt-notes')?.querySelectorAll('.sbt-note').forEach(n =>
+ (n as HTMLElement).classList.remove('drag-above', 'drag-below'));
+ noteEl.classList.add(e.clientY < midY ? 'drag-above' : 'drag-below');
+ });
+ noteEl.addEventListener("dragleave", (e) => {
+ // Only clear if leaving the element entirely (not entering a child)
+ if (noteEl.contains(e.relatedTarget as Node)) return;
+ noteEl.classList.remove('drag-above', 'drag-below');
+ });
+ noteEl.addEventListener("drop", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ // Clear all indicators
+ this.shadow.querySelectorAll('.sbt-note').forEach(n =>
+ (n as HTMLElement).classList.remove('drag-above', 'drag-below'));
+ const raw = e.dataTransfer?.getData("application/x-rdocs-move");
+ if (!raw) return;
+ try {
+ const { noteId, sourceNotebookId } = JSON.parse(raw);
+ const targetNbId = noteEl.dataset.notebook!;
+ // Cross-notebook move — delegate to moveNoteToNotebook
+ if (sourceNotebookId !== targetNbId) {
+ this.moveNoteToNotebook(noteId, sourceNotebookId, targetNbId);
+ return;
+ }
+ // Same notebook — reorder
+ const notes = this.notebookNotes.get(targetNbId);
+ if (!notes) return;
+ const targetNoteId = noteEl.dataset.note!;
+ const targetIdx = notes.findIndex(n => n.id === targetNoteId);
+ if (targetIdx < 0) return;
+ // Determine final index based on above/below
+ const rect = noteEl.getBoundingClientRect();
+ const insertAfter = e.clientY >= rect.top + rect.height / 2;
+ const srcIdx = notes.findIndex(n => n.id === noteId);
+ let finalIdx = insertAfter ? targetIdx + 1 : targetIdx;
+ // Adjust if dragging from before the target
+ if (srcIdx < finalIdx) finalIdx--;
+ this.reorderNote(noteId, targetNbId, finalIdx);
+ } catch {}
+ });
+ });
+ }
+
+ private demoUpdateNoteField(noteId: string, field: string, value: string) {
+ if (this.selectedNote && this.selectedNote.id === noteId) {
+ (this.selectedNote as any)[field] = value;
+ this.selectedNote.updated_at = new Date().toISOString();
+ }
+ for (const nb of this.demoNotebooks) {
+ const note = nb.notes.find(n => n.id === noteId);
+ if (note) {
+ (note as any)[field] = value;
+ note.updated_at = new Date().toISOString();
+ break;
+ }
+ }
+ if (this.selectedNotebook?.notes) {
+ const note = this.selectedNotebook.notes.find(n => n.id === noteId);
+ if (note) {
+ (note as any)[field] = value;
+ note.updated_at = new Date().toISOString();
+ }
+ }
+ }
+
+ // ── Import/Export Dialog ──
+
+ private importExportDialog: ImportExportDialog | null = null;
+
+ private openImportExportDialog(tab: 'import' | 'export' | 'sync' = 'import') {
+ if (!this.importExportDialog) {
+ // Dynamically import the dialog component
+ import('./import-export-dialog').then(() => {
+ this.importExportDialog = document.createElement('import-export-dialog') as unknown as ImportExportDialog;
+ this.importExportDialog.setAttribute('space', this.space);
+ this.shadow.appendChild(this.importExportDialog);
+
+ const refreshAfterChange = () => {
+ if (this.space === 'demo') {
+ this.loadDemoData();
+ } else {
+ this.loadNotebooks();
+ // Also refresh current notebook if one is open
+ if (this.selectedNotebook) {
+ this.loadNotebookREST(this.selectedNotebook.id);
+ }
+ }
+ };
+
+ this.importExportDialog.addEventListener('import-complete', refreshAfterChange);
+ this.importExportDialog.addEventListener('sync-complete', refreshAfterChange);
+
+ this.showDialog(tab);
+ });
+ } else {
+ this.showDialog(tab);
+ }
+ }
+
+ private showDialog(tab: 'import' | 'export' | 'sync') {
+ if (!this.importExportDialog) return;
+
+ // Gather notebook list for the dialog
+ const notebooks = this.notebooks.map(nb => ({
+ id: nb.id,
+ title: nb.title,
+ }));
+
+ this.importExportDialog.open(notebooks, tab);
+ }
+
+ private esc(s: string): string {
+ const d = document.createElement("div");
+ d.textContent = s || "";
+ return d.innerHTML;
+ }
+
+ private getStyles(): string {
+ return `
+ :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); -webkit-tap-highlight-color: transparent; }
+ * { box-sizing: border-box; }
+ button, a, input, select, textarea, [role="button"] { touch-action: manipulation; }
+
+ /* ── Sidebar Layout ── */
+ #notes-layout {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ min-height: 400px;
+ height: 100%;
+ position: relative;
+ transition: grid-template-columns 0.2s ease;
+ }
+ #notes-layout.sidebar-collapsed {
+ grid-template-columns: 0px 1fr;
+ }
+ #notes-layout.sidebar-collapsed .notes-sidebar {
+ opacity: 0;
+ pointer-events: none;
+ }
+ #notes-layout.sidebar-collapsed .sidebar-reopen {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ #nav-zone { overflow: visible; }
+ #notes-layout.sidebar-collapsed #nav-zone { overflow: hidden; }
+ .notes-sidebar {
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--rs-border-subtle);
+ background: var(--rs-bg-surface);
+ overflow: visible;
+ height: 100%;
+ position: relative;
+ transition: opacity 0.15s ease;
+ }
+ /* Collapse button — right edge, vertically centered on sidebar */
+ .sidebar-collapse {
+ position: absolute; right: -12px; top: 50%; transform: translateY(-50%);
+ width: 20px; height: 48px; z-index: 10;
+ border: 1px solid var(--rs-border-subtle, #333);
+ border-radius: 0 6px 6px 0;
+ background: var(--rs-bg-surface, #1e1e2e);
+ color: var(--rs-text-muted, #888);
+ font-size: 16px; line-height: 1;
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
+ }
+ .sidebar-collapse:hover {
+ color: var(--rs-text-primary);
+ border-color: var(--rs-primary, #6366f1);
+ background: var(--rs-bg-hover, #252538);
+ }
+ /* Reopen tab on left edge */
+ .sidebar-reopen {
+ position: absolute; left: 0; top: 50%; transform: translateY(-50%);
+ width: 20px; height: 48px; z-index: 10;
+ border: 1px solid var(--rs-border-subtle, #333);
+ border-left: none;
+ border-radius: 0 6px 6px 0;
+ background: var(--rs-bg-surface, #1e1e2e);
+ color: var(--rs-text-muted, #888);
+ font-size: 18px; line-height: 1;
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
+ opacity: 0; pointer-events: none;
+ transition: opacity 0.15s ease, color 0.15s, background 0.15s;
+ }
+ .sidebar-reopen:hover {
+ color: var(--rs-text-primary);
+ background: var(--rs-bg-hover, #252538);
+ }
+ .sidebar-header { padding: 12px 12px 8px; }
+ .sidebar-search {
+ width: 100%; padding: 8px 12px; border-radius: 6px;
+ border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
+ color: var(--rs-input-text); font-size: 13px; font-family: inherit;
+ transition: border-color 0.15s;
+ }
+ .sidebar-search:focus { border-color: var(--rs-primary); outline: none; }
+ .sidebar-tree { flex: 1; overflow-y: auto; padding: 4px 0; }
+ .sidebar-btn-new-nb {
+ display: flex; align-items: center; justify-content: center; gap: 6px;
+ width: calc(100% - 24px); margin: 0 12px 8px; padding: 7px;
+ border-radius: 6px; border: 1px dashed var(--rs-border);
+ background: transparent; color: var(--rs-text-secondary);
+ font-size: 13px; font-family: inherit; cursor: pointer; transition: all 0.15s;
+ }
+ .sidebar-btn-new-nb:hover { border-color: var(--rs-primary); color: var(--rs-primary); background: rgba(99, 102, 241, 0.05); }
+
+ /* Notebook tree */
+ .sbt-notebook-header {
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px 12px; cursor: pointer; user-select: none;
+ transition: background 0.1s; font-size: 13px;
+ }
+ .sbt-notebook-header:hover { background: var(--rs-bg-hover); }
+ .sbt-notebook-header.drop-target { background: rgba(99, 102, 241, 0.15); border: 1px dashed var(--rs-primary, #6366f1); border-radius: 4px; }
+ .sbt-toggle {
+ width: 16px; text-align: center; font-size: 10px;
+ color: var(--rs-text-muted); flex-shrink: 0;
+ transition: transform 0.15s;
+ }
+ .sbt-toggle.expanded { transform: rotate(90deg); }
+ .sbt-nb-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+ .sbt-nb-title {
+ flex: 1; font-weight: 500; color: var(--rs-text-primary);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ }
+ .sbt-nb-count { font-size: 11px; color: var(--rs-text-muted); flex-shrink: 0; }
+ .sbt-nb-add {
+ opacity: 0; border: none; background: none;
+ color: var(--rs-text-muted); cursor: pointer;
+ font-size: 16px; line-height: 1; padding: 0 4px;
+ border-radius: 3px; transition: all 0.15s; flex-shrink: 0;
+ }
+ .sbt-notebook-header:hover .sbt-nb-add { opacity: 1; }
+ .sbt-nb-add:hover { color: var(--rs-primary); background: var(--rs-bg-surface-raised); }
+ @media (pointer: coarse) {
+ .sbt-nb-add { opacity: 0.6; }
+ .sbt-nb-add:active { opacity: 1; color: var(--rs-primary); }
+ }
+ .sbt-notes { padding-left: 20px; }
+ .sbt-note {
+ display: flex; align-items: center; gap: 8px;
+ padding: 5px 12px 5px 8px; cursor: pointer;
+ font-size: 13px; color: var(--rs-text-secondary);
+ border-radius: 4px; margin: 1px 8px 1px 0;
+ transition: all 0.1s; overflow: hidden;
+ }
+ .sbt-note:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
+ .sbt-note.active { background: var(--rs-primary); color: #fff; }
+ .sbt-note-icon { font-size: 14px; flex-shrink: 0; }
+ .sbt-note-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .sbt-note-pin { font-size: 10px; flex-shrink: 0; }
+ .sbt-note.drag-above { box-shadow: 0 -2px 0 0 var(--rs-primary, #6366f1); }
+ .sbt-note.drag-below { box-shadow: 0 2px 0 0 var(--rs-primary, #6366f1); }
+
+ .sidebar-footer {
+ padding: 8px 12px; border-top: 1px solid var(--rs-border-subtle);
+ display: flex; gap: 6px; flex-wrap: wrap;
+ }
+ .sidebar-footer-btn {
+ padding: 5px 10px; border-radius: 5px;
+ border: 1px solid var(--rs-border); background: transparent;
+ color: var(--rs-text-secondary); font-size: 11px;
+ font-family: inherit; cursor: pointer; transition: all 0.15s;
+ }
+ .sidebar-footer-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
+
+ /* Add-note context menu */
+ .add-note-menu {
+ position: absolute; z-index: 100;
+ background: var(--rs-surface, #fff); border: 1px solid var(--rs-border);
+ border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ padding: 4px; min-width: 130px;
+ }
+ .add-note-menu-item {
+ display: block; width: 100%; padding: 6px 10px;
+ border: none; background: transparent; text-align: left;
+ font-size: 12px; font-family: inherit; cursor: pointer;
+ color: var(--rs-text-primary); border-radius: 4px;
+ }
+ .add-note-menu-item:hover { background: var(--rs-surface-hover, #f3f4f6); }
+
+ /* Sidebar collab info */
+ .sidebar-collab-info {
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px 12px; font-size: 11px; color: var(--rs-text-muted);
+ border-top: 1px solid var(--rs-border-subtle); cursor: default;
+ }
+ .sidebar-collab-dot {
+ width: 7px; height: 7px; border-radius: 50%;
+ flex-shrink: 0; background: #9ca3af;
+ }
+ .sidebar-collab-dot.connected { background: #22c55e; }
+ .sidebar-collab-dot.disconnected { background: #9ca3af; }
+
+ /* Sidebar search results */
+ .sidebar-search-results { padding: 4px 0; }
+ .sidebar-search-result {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 12px; cursor: pointer; font-size: 13px;
+ color: var(--rs-text-secondary); transition: background 0.1s;
+ }
+ .sidebar-search-result:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
+ .sidebar-search-result-nb { font-size: 10px; color: var(--rs-text-muted); margin-left: auto; }
+
+ /* Right column */
+ .notes-right-col {
+ display: flex; flex-direction: column; overflow: hidden; height: 100%;
+ }
+ .notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; }
+ .notes-right-col #meta-zone { padding: 0 20px 12px; }
+
+ /* ── Google Docs-like comment sidebar layout ── */
+ .editor-with-comments {
+ display: flex;
+ gap: 0;
+ min-height: 100%;
+ }
+ .editor-with-comments > .editor-wrapper {
+ flex: 1;
+ min-width: 0;
+ }
+ .comment-sidebar {
+ width: 0;
+ overflow: hidden;
+ transition: width 0.2s ease;
+ flex-shrink: 0;
+ }
+ .comment-sidebar.has-comments {
+ width: 280px;
+ overflow-y: auto;
+ border-left: 1px solid var(--rs-border, #e5e7eb);
+ }
+ @media (max-width: 768px) {
+ /* Comment sidebar → bottom sheet on all mobile */
+ .editor-with-comments { flex-direction: column; }
+ .comment-sidebar.has-comments {
+ width: 100%; border-left: none;
+ border-top: 2px solid var(--rs-border, #e5e7eb);
+ max-height: 250px; max-height: 40dvh;
+ min-height: 120px; overflow-y: auto;
+ border-radius: 12px 12px 0 0; padding-top: 4px;
+ }
+ .comment-sidebar.has-comments::before {
+ content: ''; display: block; width: 32px; height: 4px;
+ background: var(--rs-border-strong, #d1d5db); border-radius: 2px;
+ margin: 0 auto 4px;
+ }
+ }
+
+ /* Empty state */
+ .editor-empty-state {
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; height: 100%; min-height: 300px;
+ color: var(--rs-text-muted); gap: 12px;
+ }
+ .editor-empty-state svg { width: 48px; height: 48px; opacity: 0.4; }
+ .editor-empty-state p { font-size: 14px; }
+
+ /* Mobile sidebar (legacy — hidden, replaced by stack nav) */
+ .mobile-sidebar-toggle { display: none; }
+ .sidebar-overlay { display: none; }
+
+ /* ── Navigation ── */
+ .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; }
+ .rapp-nav__btn:hover { background: var(--rs-primary-hover); }
+
+ /* ── Presence Indicators ── */
+ .presence-dots { display: inline-flex; gap: 2px; align-items: center; margin-left: auto; }
+ .presence-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; border: 1px solid var(--rs-bg-surface, #fff); flex-shrink: 0; }
+ .presence-dot-more { font-size: 10px; color: var(--rs-text-muted); margin-left: 2px; }
+
+ /* ── Code Editor ── */
+ .code-editor-controls { padding: 4px 12px; display: flex; gap: 8px; align-items: center; }
+ .code-textarea {
+ width: 100%; min-height: 400px; padding: 16px 20px; border: none; outline: none;
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
+ font-size: 13px; line-height: 1.6; tab-size: 4; resize: vertical;
+ background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary);
+ }
+
+ /* ── Bookmark / Clip View ── */
+ .bookmark-card {
+ display: flex; gap: 12px; align-items: center;
+ padding: 12px 16px; margin: 0 12px 8px;
+ background: var(--rs-bg-surface-raised); border-radius: 8px;
+ }
+ .bookmark-favicon { border-radius: 4px; flex-shrink: 0; }
+ .bookmark-info { flex: 1; min-width: 0; }
+ .bookmark-url { color: var(--rs-primary); font-size: 13px; text-decoration: none; }
+ .bookmark-url:hover { text-decoration: underline; }
+ .bookmark-url-input-row { margin-top: 4px; }
+ .bookmark-url-input {
+ width: 100%; padding: 6px 10px; border-radius: 6px;
+ border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
+ color: var(--rs-input-text); font-size: 12px; font-family: inherit;
+ }
+
+ /* ── Image View ── */
+ .image-display { padding: 12px 16px; text-align: center; }
+ .image-preview { max-width: 100%; max-height: 500px; border-radius: 8px; border: 1px solid var(--rs-border-subtle); }
+ .image-upload-placeholder { padding: 16px; }
+
+ /* ── Audio View ── */
+ .audio-player-container {
+ display: flex; gap: 12px; align-items: center;
+ padding: 12px 16px; margin: 0 12px;
+ }
+ .audio-player { flex: 1; max-width: 100%; height: 40px; }
+ .audio-duration { font-size: 13px; color: var(--rs-text-muted); font-weight: 500; }
+ .audio-record-placeholder { padding: 24px; text-align: center; }
+ .audio-recording-ui { text-align: left; }
+ .audio-recording-header {
+ display: flex; align-items: center; gap: 12px; margin-bottom: 8px;
+ }
+ .recording-pulse-sm {
+ width: 12px; height: 12px; border-radius: 50%;
+ background: var(--rs-error, #ef4444); animation: pulse-recording 1.5s infinite;
+ }
+ .audio-recording-timer {
+ font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums;
+ color: var(--rs-text-primary);
+ }
+ .audio-live-segments {
+ max-height: 200px; overflow-y: auto; padding: 4px 0;
+ }
+ .transcript-segment {
+ display: flex; gap: 8px; padding: 3px 0; font-size: 14px; line-height: 1.6;
+ }
+ .transcript-segment.interim { font-style: italic; color: var(--rs-text-muted); }
+ .segment-time {
+ flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 12px; color: var(--rs-text-muted); padding-top: 2px;
+ }
+ .segment-text { flex: 1; }
+ .audio-transcript-section { padding: 0 4px; }
+ .audio-transcript-label {
+ font-size: 12px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--rs-text-muted);
+ padding: 8px 16px 0;
+ }
+
+ /* ── Editor Title ── */
+ .editable-title {
+ background: transparent; border: none; border-bottom: 2px solid transparent;
+ color: var(--rs-text-primary); font-family: inherit;
+ font-size: 22px; font-weight: 700; width: 100%; outline: none;
+ padding: 8px 0; margin-bottom: 4px; transition: border-color 0.15s;
+ }
+ .editable-title:focus { border-bottom-color: var(--rs-primary); }
+ .editable-title::placeholder { color: var(--rs-text-muted); }
+
+ /* ── Sync Badge ── */
+ .sync-badge {
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
+ margin-left: 8px; vertical-align: middle;
+ }
+ .sync-badge.connected { background: var(--rs-success); }
+ .sync-badge.disconnected { background: var(--rs-error); }
+
+ /* ── State Messages ── */
+ .empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
+ .loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
+ .error { text-align: center; color: var(--rs-error); padding: 20px; }
+
+ /* ── Meta Bar ── */
+ .note-meta-bar {
+ margin-top: 12px; font-size: 12px; color: var(--rs-text-muted);
+ display: flex; gap: 12px; padding: 8px 0; align-items: center;
+ }
+ .meta-live { color: var(--rs-success); font-weight: 500; }
+ .meta-demo { color: var(--rs-warning); font-weight: 500; }
+
+ /* ── Summary Panel ── */
+ .note-summary-panel {
+ margin-top: 8px; border: 1px solid var(--rs-border-subtle);
+ border-radius: 8px; background: var(--rs-bg-surface); overflow: hidden;
+ }
+ .note-summary-header {
+ display: flex; align-items: center; gap: 6px; padding: 8px 12px;
+ font-size: 12px; font-weight: 500; color: var(--rs-text-secondary);
+ cursor: pointer; user-select: none;
+ }
+ .note-summary-header:hover { background: var(--rs-bg-surface-raised); }
+ .note-summary-icon { display: flex; align-items: center; }
+ .note-summary-icon svg { width: 14px; height: 14px; }
+ .note-summary-model {
+ font-size: 10px; color: var(--rs-text-muted); background: var(--rs-bg-surface-raised);
+ padding: 1px 6px; border-radius: 3px; margin-left: auto;
+ }
+ .note-summary-regen {
+ background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
+ font-size: 14px; padding: 0 4px; border-radius: 3px; transition: all 0.15s;
+ }
+ .note-summary-regen:hover { color: var(--rs-primary); background: var(--rs-bg-surface-raised); }
+ .note-summary-chevron { font-size: 8px; color: var(--rs-text-muted); transition: transform 0.2s; }
+ .note-summary-body {
+ padding: 8px 12px 12px; font-size: 13px; line-height: 1.6;
+ color: var(--rs-text-primary); border-top: 1px solid var(--rs-border-subtle);
+ white-space: pre-wrap;
+ }
+ .note-summary-panel.collapsed .note-summary-body { display: none; }
+ .note-summary-panel.collapsed .note-summary-chevron { transform: rotate(-90deg); }
+
+ /* ── Note Actions Bar ── */
+ .note-actions-bar {
+ display: flex; align-items: center; gap: 8px; margin-top: 8px;
+ }
+ .note-action-btn {
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px 14px; border-radius: 6px; border: 1px solid var(--rs-border);
+ background: transparent; color: var(--rs-text-secondary); font-size: 12px;
+ font-weight: 500; cursor: pointer; transition: all 0.15s; font-family: inherit;
+ }
+ .note-action-btn:hover:not(:disabled) { border-color: var(--rs-primary); color: var(--rs-primary); }
+ .note-action-btn:disabled { opacity: 0.6; cursor: default; }
+ .note-action-btn svg { flex-shrink: 0; }
+ .note-action-badge {
+ display: inline-flex; align-items: center; gap: 4px;
+ padding: 3px 10px; border-radius: 12px;
+ background: rgba(34, 197, 94, 0.15);
+ background: color-mix(in srgb, var(--rs-success, #22c55e) 15%, transparent);
+ color: var(--rs-success, #22c55e); font-size: 11px; font-weight: 600;
+ }
+
+ /* ── Editor Toolbar ── */
+ .editor-toolbar {
+ display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
+ background: var(--rs-toolbar-bg); border: 1px solid var(--rs-toolbar-panel-border);
+ border-radius: 8px; padding: 4px 6px; margin-bottom: 2px;
+ }
+ .toolbar-group { display: flex; gap: 1px; }
+ .toolbar-sep { width: 1px; height: 20px; background: var(--rs-toolbar-sep); margin: 0 4px; }
+ .toolbar-btn {
+ display: flex; align-items: center; justify-content: center;
+ width: 30px; height: 28px; border: none; border-radius: 4px;
+ background: transparent; color: var(--rs-text-secondary); cursor: pointer;
+ font-size: 13px; font-family: inherit; transition: all 0.15s;
+ }
+ .toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
+ .toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); }
+ .toolbar-btn.active { background: var(--rs-primary); color: #fff; }
+ .toolbar-btn.recording { background: var(--rs-error, #ef4444); color: #fff; animation: pulse-recording 1.5s infinite; }
+ @keyframes pulse-recording { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
+
+ /* ── Dictation Preview ── */
+ .dictation-preview {
+ padding: 6px 12px; margin: 0 8px 2px; border-radius: 6px;
+ background: var(--rs-bg-surface-raised); color: var(--rs-text-muted);
+ font-size: 13px; font-style: italic; line-height: 1.5;
+ animation: dictation-pulse 2s ease-in-out infinite;
+ border-left: 3px solid var(--rs-error, #ef4444);
+ }
+ @keyframes dictation-pulse {
+ 0%, 100% { background: var(--rs-bg-surface-raised); }
+ 50% { background: rgba(239, 68, 68, 0.08); background: color-mix(in srgb, var(--rs-error, #ef4444) 8%, var(--rs-bg-surface-raised)); }
+ }
+ .toolbar-select {
+ padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border);
+ background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer;
+ font-family: inherit; transition: border-color 0.15s;
+ }
+ .toolbar-select:focus { outline: none; border-color: var(--rs-primary); }
+
+ /* ── Tiptap Editor ── */
+ .editor-wrapper {
+ background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
+ border-radius: 10px; overflow: hidden;
+ }
+ .editor-wrapper .editable-title { padding: 16px 20px 0; }
+ .editor-wrapper .editor-toolbar { margin: 4px 8px; border-radius: 6px; }
+
+ .tiptap-container .tiptap {
+ min-height: 300px; padding: 20px 24px; outline: none;
+ font-size: 15px; line-height: 1.75; color: var(--rs-text-primary);
+ }
+ .tiptap-container .tiptap:focus { outline: none; }
+
+ /* ── Prose Styles ── */
+ .tiptap-container .tiptap h1 {
+ font-size: 1.75em; font-weight: 700; margin: 1.2em 0 0.5em; color: var(--rs-text-primary);
+ padding-bottom: 0.3em; border-bottom: 1px solid var(--rs-border-subtle);
+ }
+ .tiptap-container .tiptap h2 { font-size: 1.35em; font-weight: 600; margin: 1em 0 0.4em; color: var(--rs-text-primary); }
+ .tiptap-container .tiptap h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.3em; color: var(--rs-text-secondary); }
+ .tiptap-container .tiptap h4 {
+ font-size: 0.95em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
+ margin: 0.7em 0 0.25em; color: var(--rs-text-muted);
+ }
+ .tiptap-container .tiptap p { margin: 0.5em 0; }
+ .tiptap-container .tiptap blockquote {
+ border-left: 3px solid var(--rs-primary); padding: 4px 0 4px 16px; margin: 0.8em 0;
+ color: var(--rs-text-secondary); font-style: italic;
+ background: var(--rs-bg-surface-raised); border-radius: 0 6px 6px 0;
+ }
+ .tiptap-container .tiptap code {
+ background: var(--rs-bg-surface-raised); padding: 2px 6px; border-radius: 4px;
+ font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.88em; color: var(--rs-accent);
+ }
+ .tiptap-container .tiptap pre {
+ background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-subtle);
+ border-radius: 8px; padding: 14px 16px; margin: 1em 0; overflow-x: auto;
+ }
+ .tiptap-container .tiptap pre code {
+ background: none; padding: 0; border-radius: 0; color: var(--rs-text-primary);
+ font-size: 13px; line-height: 1.6;
+ }
+ .tiptap-container .tiptap ul, .tiptap-container .tiptap ol { padding-left: 24px; margin: 0.5em 0; }
+ .tiptap-container .tiptap li { margin: 0.2em 0; }
+ .tiptap-container .tiptap li p { margin: 0.15em 0; }
+ .tiptap-container .tiptap li::marker { color: var(--rs-text-muted); }
+
+ /* Task list */
+ .tiptap-container .tiptap ul[data-type="taskList"] { list-style: none; padding-left: 4px; }
+ .tiptap-container .tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; }
+ .tiptap-container .tiptap ul[data-type="taskList"] li label { margin-top: 3px; }
+ .tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p {
+ text-decoration: line-through; color: var(--rs-text-muted);
+ }
+ .tiptap-container .tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
+ accent-color: var(--rs-primary); width: 15px; height: 15px;
+ }
+
+ .tiptap-container .tiptap img {
+ max-width: 100%; border-radius: 8px; margin: 0.75em 0;
+ border: 1px solid var(--rs-border-subtle);
+ }
+ .tiptap-container .tiptap a {
+ color: var(--rs-primary-hover); text-decoration: underline;
+ text-underline-offset: 2px; text-decoration-color: rgba(99, 102, 241, 0.4);
+ }
+ .tiptap-container .tiptap a:hover { text-decoration-color: var(--rs-primary-hover); }
+ .tiptap-container .tiptap hr { border: none; border-top: 1px solid var(--rs-border); margin: 1.5em 0; }
+ .tiptap-container .tiptap strong { color: var(--rs-text-primary); font-weight: 600; }
+ .tiptap-container .tiptap em { color: inherit; }
+ .tiptap-container .tiptap s { color: var(--rs-text-muted); }
+ .tiptap-container .tiptap u { text-underline-offset: 3px; }
+
+ /* ── Mobile back bar (hidden on desktop) ── */
+ .mobile-back-bar { display: none; }
+ @media (max-width: 768px) {
+ /* Two-screen horizontal stack: nav (100%) + editor (100%) side-by-side */
+ #notes-layout {
+ display: flex; overflow: hidden;
+ grid-template-columns: unset;
+ }
+ #nav-zone, .notes-right-col {
+ flex: 0 0 100%; min-width: 0;
+ transition: transform 0.3s ease;
+ }
+ /* Slide both panels left when editing */
+ #notes-layout.mobile-editing > #nav-zone,
+ #notes-layout.mobile-editing > .notes-right-col {
+ transform: translateX(-100%);
+ }
+ /* Sidebar fills screen width */
+ .notes-sidebar { width: 100%; position: static; transform: none; box-shadow: none; }
+ /* Hide old overlay FAB + desktop collapse on mobile */
+ .mobile-sidebar-toggle, .sidebar-overlay, .sidebar-collapse, .sidebar-reopen { display: none !important; }
+ /* Hide empty state on mobile — user sees doc list */
+ .editor-empty-state { display: none; }
+ /* Show back bar */
+ .mobile-back-bar {
+ display: flex; align-items: center;
+ padding: 8px 12px; border-bottom: 1px solid var(--rs-border-subtle);
+ background: var(--rs-bg-surface);
+ }
+ .mobile-back-btn {
+ background: none; border: none; color: var(--rs-primary);
+ font-size: 15px; font-weight: 600; cursor: pointer;
+ padding: 4px 8px; border-radius: 6px; font-family: inherit;
+ }
+ .mobile-back-btn:hover { background: var(--rs-bg-surface-raised); }
+ /* Tighten editor padding */
+ .editor-wrapper .editable-title { padding: 12px 14px 0; }
+ .tiptap-container .tiptap { padding: 14px 16px; }
+ .sidebar-footer-btn { min-height: 36px; padding: 7px 12px; }
+ /* Toolbar: scroll horizontally, bigger touch targets */
+ .editor-toolbar { padding: 3px 4px; gap: 1px; overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
+ .toolbar-btn { width: 36px; height: 36px; }
+ .toolbar-sep { display: none; }
+ /* Collab tools first so comment/suggest are visible without scrolling */
+ .collab-tools { order: -1; border-right: 1px solid var(--rs-toolbar-sep, #e5e7eb); padding-right: 6px; margin-right: 2px; }
+ /* Suggestion review bar: allow wrapping */
+ .suggestion-review-bar { flex-wrap: wrap; height: auto; min-height: 32px; padding: 4px 8px; gap: 6px; }
+ }
+ @media (max-width: 480px) {
+ .rapp-nav__btn { padding: 5px 10px; font-size: 12px; }
+ .editable-title { font-size: 18px; }
+ .tiptap-container .tiptap { font-size: 14px; padding: 12px 14px; min-height: 200px; }
+ .code-textarea { min-height: 200px; }
+ .image-preview { max-height: 240px; }
+ .note-actions-bar { flex-wrap: wrap; gap: 6px; }
+ .note-action-btn { padding: 5px 10px; font-size: 11px; }
+ }
+
+ /* Placeholder */
+ .tiptap-container .tiptap p.is-editor-empty:first-child::before {
+ content: attr(data-placeholder);
+ float: left; color: var(--rs-text-muted); pointer-events: none; height: 0;
+ font-style: italic;
+ }
+
+ /* ── URL Popover ── */
+ .url-popover {
+ position: absolute; z-index: 110;
+ background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
+ border-radius: 10px; box-shadow: var(--rs-shadow-md);
+ padding: 8px; min-width: 300px;
+ animation: popover-in 0.15s ease-out;
+ }
+ @keyframes popover-in {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ .url-popover__input {
+ width: 100%; padding: 8px 10px; border-radius: 6px;
+ border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
+ color: var(--rs-input-text); font-size: 13px; font-family: inherit;
+ outline: none; margin-bottom: 6px; transition: border-color 0.15s;
+ }
+ .url-popover__input:focus { border-color: var(--rs-primary); }
+ .url-popover__actions { display: flex; gap: 6px; justify-content: flex-end; }
+ .url-popover__btn {
+ padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
+ cursor: pointer; border: none; transition: all 0.15s;
+ }
+ .url-popover__btn--insert { background: var(--rs-primary); color: #fff; }
+ .url-popover__btn--insert:hover { background: var(--rs-primary-hover); }
+ .url-popover__btn--cancel {
+ background: transparent; color: var(--rs-text-secondary);
+ border: 1px solid var(--rs-border);
+ }
+ .url-popover__btn--cancel:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
+ @media (max-width: 640px) {
+ .url-popover {
+ position: fixed; left: 8px !important; right: 8px !important;
+ top: auto !important; bottom: max(env(safe-area-inset-bottom), 8px);
+ min-width: 0; width: auto; border-radius: 12px 12px 0 0;
+ }
+ }
+
+ /* ── Slash Menu ── */
+ .slash-menu {
+ position: absolute; z-index: 100;
+ background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
+ border-radius: 10px; box-shadow: var(--rs-shadow-lg);
+ max-height: 360px; overflow-y: auto; min-width: 240px;
+ display: none;
+ }
+ .slash-menu__header {
+ padding: 8px 12px 6px; font-size: 11px; font-weight: 600;
+ text-transform: uppercase; letter-spacing: 0.05em;
+ color: var(--rs-text-muted); border-bottom: 1px solid var(--rs-border-subtle);
+ }
+ .slash-menu-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 12px; cursor: pointer; transition: background 0.1s;
+ }
+ .slash-menu-item:last-child { border-radius: 0 0 10px 10px; }
+ .slash-menu-item:hover, .slash-menu-item.selected { background: var(--rs-bg-hover); }
+ .slash-menu-icon {
+ width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
+ background: var(--rs-bg-surface-raised); border-radius: 6px;
+ font-size: 13px; font-weight: 600; color: var(--rs-primary);
+ flex-shrink: 0;
+ }
+ .slash-menu-icon svg { width: 16px; height: 16px; }
+ .slash-menu-text { flex: 1; }
+ .slash-menu-title { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
+ .slash-menu-desc { font-size: 11px; color: var(--rs-text-muted); }
+ .slash-menu-hint {
+ font-size: 10px; color: var(--rs-text-muted); padding: 1px 6px;
+ background: var(--rs-bg-surface-raised); border-radius: 3px; margin-left: auto;
+ }
+ @media (max-width: 480px) {
+ .slash-menu { min-width: 200px; max-height: 260px; }
+ .slash-menu-item { padding: 10px 12px; }
+ .slash-menu-desc { display: none; }
+ .slash-menu-hint { display: none; }
+ }
+
+ /* ── Code highlighting (lowlight) ── */
+ .tiptap-container .tiptap .hljs-keyword { color: #c792ea; }
+ .tiptap-container .tiptap .hljs-string { color: #c3e88d; }
+ .tiptap-container .tiptap .hljs-number { color: #f78c6c; }
+ .tiptap-container .tiptap .hljs-comment { color: #546e7a; font-style: italic; }
+ .tiptap-container .tiptap .hljs-built_in { color: #82aaff; }
+ .tiptap-container .tiptap .hljs-function { color: #82aaff; }
+ .tiptap-container .tiptap .hljs-title { color: #82aaff; }
+ .tiptap-container .tiptap .hljs-attr { color: #ffcb6b; }
+ .tiptap-container .tiptap .hljs-tag { color: #f07178; }
+ .tiptap-container .tiptap .hljs-type { color: #ffcb6b; }
+
+ /* ── Collaboration: Remote Cursors ── */
+ .collab-cursor {
+ position: relative;
+ border-left: 2px solid;
+ margin-left: -1px;
+ margin-right: -1px;
+ pointer-events: none;
+ }
+ .collab-cursor-label {
+ position: absolute;
+ top: -1.4em;
+ left: -1px;
+ font-size: 10px;
+ font-weight: 600;
+ color: #fff;
+ padding: 1px 5px;
+ border-radius: 3px 3px 3px 0;
+ white-space: nowrap;
+ pointer-events: none;
+ user-select: none;
+ line-height: 1.4;
+ }
+ /* y-prosemirror remote selection highlight */
+ .ProseMirror .yRemoteSelection {
+ background-color: var(--user-color, rgba(99, 102, 241, 0.2));
+ }
+ .ProseMirror .yRemoteSelectionHead {
+ position: absolute;
+ border-left: 2px solid var(--user-color, #6366f1);
+ height: 1.2em;
+ }
+
+ /* ── Collaboration: Status Bar ── */
+ .collab-status-bar {
+ display: none; align-items: center; gap: 8px;
+ padding: 4px 16px; height: 28px;
+ background: var(--rs-bg-hover, rgba(0,0,0,0.03));
+ border-top: 1px solid var(--rs-border-subtle);
+ border-bottom: 1px solid var(--rs-border-subtle);
+ font-size: 12px; color: var(--rs-text-muted);
+ }
+ .collab-status-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ flex-shrink: 0;
+ background: #9ca3af;
+ }
+ .collab-status-dot.live { background: #22c55e; }
+ .collab-status-dot.synced { background: #3b82f6; }
+ .collab-status-dot.offline { background: #9ca3af; }
+ .collab-status-text { white-space: nowrap; }
+
+ /* ── Collaboration: Peers Indicator ── */
+ .collab-tools { display: flex; align-items: center; gap: 4px; }
+ .collab-peers {
+ display: none;
+ align-items: center;
+ gap: 2px;
+ margin-left: 4px;
+ }
+ .peer-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+ border: 1px solid var(--rs-bg-surface, #fff);
+ }
+ .peer-more {
+ font-size: 10px;
+ color: var(--rs-text-muted);
+ margin-left: 2px;
+ }
+
+ /* ── Collaboration: Comment Highlights (Google Docs style) ── */
+ .tiptap-container .tiptap .comment-highlight {
+ background: rgba(251, 188, 4, 0.2);
+ border-bottom: 2px solid rgba(251, 188, 4, 0.5);
+ cursor: pointer;
+ transition: background 0.15s;
+ border-radius: 1px;
+ }
+ .tiptap-container .tiptap .comment-highlight:hover {
+ background: rgba(251, 188, 4, 0.35);
+ }
+ .tiptap-container .tiptap .comment-highlight.resolved {
+ background: rgba(251, 188, 4, 0.06);
+ border-bottom-color: rgba(251, 188, 4, 0.12);
+ }
+
+ /* ── Collaboration: Suggestions ── */
+ .tiptap-container .tiptap .suggestion-insert {
+ color: #137333;
+ background: rgba(22, 163, 74, 0.1);
+ border-bottom: 2px solid rgba(22, 163, 74, 0.4);
+ text-decoration: none;
+ cursor: pointer;
+ }
+ .tiptap-container .tiptap .suggestion-insert:hover {
+ background: rgba(22, 163, 74, 0.18);
+ }
+ .tiptap-container .tiptap .suggestion-delete {
+ color: #c5221f;
+ background: rgba(220, 38, 38, 0.08);
+ text-decoration: line-through;
+ text-decoration-color: #c5221f;
+ cursor: pointer;
+ }
+ .tiptap-container .tiptap .suggestion-delete:hover {
+ background: rgba(220, 38, 38, 0.15);
+ }
+ .tiptap-container.suggesting-mode {
+ border-left: 3px solid #f59e0b;
+ }
+ .suggest-toggle.active {
+ background: #f59e0b !important;
+ color: #fff !important;
+ }
+
+ /* ── Suggestion Review Bar ── */
+ .suggestion-review-bar {
+ display: flex; align-items: center; gap: 8px;
+ padding: 4px 12px; height: 32px;
+ background: color-mix(in srgb, #f59e0b 8%, var(--rs-bg-surface, #fff));
+ border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border, #e5e7eb));
+ font-size: 12px; color: var(--rs-text-secondary, #666);
+ }
+ .srb-label { font-weight: 600; color: #b45309; }
+ .srb-count { margin-left: auto; color: var(--rs-text-muted, #999); }
+ .srb-hint { color: var(--rs-text-muted, #999); font-style: italic; }
+
+ /* ── Suggestion Popover (accept/reject on click) ── */
+ .suggestion-popover {
+ position: absolute;
+ z-index: 100;
+ background: var(--rs-bg-surface, #fff);
+ border: 1px solid var(--rs-border, #e5e7eb);
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0,0,0,0.12);
+ padding: 8px 10px;
+ display: flex; flex-direction: column; gap: 6px;
+ min-width: 140px;
+ font-size: 12px;
+ }
+ .sp-header { display: flex; align-items: center; gap: 6px; }
+ .sp-author { font-weight: 600; color: var(--rs-text-primary, #111); }
+ .sp-type { color: var(--rs-text-muted, #999); font-size: 11px; }
+ .sp-actions { display: flex; gap: 6px; }
+ .sp-btn {
+ flex: 1; padding: 4px 0; border: 1px solid var(--rs-border, #ddd);
+ border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600;
+ background: var(--rs-bg-surface, #fff); text-align: center;
+ }
+ .sp-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
+ .sp-accept { color: #137333; border-color: #137333; }
+ .sp-accept:hover { background: rgba(22, 163, 74, 0.08); }
+ .sp-reject { color: #c5221f; border-color: #c5221f; }
+ .sp-reject:hover { background: rgba(220, 38, 38, 0.08); }
+ `;
+ }
+}
+
+customElements.define("folk-docs-app", FolkDocsApp);
diff --git a/modules/rdocs/components/folk-voice-recorder.ts b/modules/rdocs/components/folk-voice-recorder.ts
new file mode 100644
index 00000000..df39d1ab
--- /dev/null
+++ b/modules/rdocs/components/folk-voice-recorder.ts
@@ -0,0 +1,578 @@
+/**
+ * — Standalone voice recorder web component.
+ *
+ * Full-page recorder with MediaRecorder, SpeechDictation (live),
+ * and three-tier transcription cascade:
+ * 1. Server (voice-command-api)
+ * 2. Live (Web Speech API captured during recording)
+ * 3. Offline (Parakeet TDT 0.6B in-browser)
+ *
+ * Saves AUDIO notes to rNotes via REST API with Tiptap-JSON formatted
+ * timestamped transcript segments.
+ */
+
+import { SpeechDictation } from '../../../lib/speech-dictation';
+import { transcribeOffline, isModelCached } from '../../../lib/parakeet-offline';
+import type { TranscriptionProgress } from '../../../lib/parakeet-offline';
+import type { TranscriptSegment } from '../../../lib/folk-transcription';
+import { getAccessToken } from '../../../shared/components/rstack-identity';
+
+type RecorderState = 'idle' | 'recording' | 'processing' | 'done';
+
+class FolkVoiceRecorder extends HTMLElement {
+ private shadow!: ShadowRoot;
+ private space = '';
+ private state: RecorderState = 'idle';
+ private mediaRecorder: MediaRecorder | null = null;
+ private audioChunks: Blob[] = [];
+ private dictation: SpeechDictation | null = null;
+ private segments: TranscriptSegment[] = [];
+ private liveTranscript = '';
+ private finalTranscript = '';
+ private recordingStartTime = 0;
+ private durationTimer: ReturnType | null = null;
+ private elapsedSeconds = 0;
+ private audioBlob: Blob | null = null;
+ private audioUrl: string | null = null;
+ private progressMessage = '';
+ private selectedNotebookId = '';
+ private notebooks: { id: string; title: string }[] = [];
+ private tags = '';
+
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.space = this.getAttribute('space') || 'demo';
+ this.loadNotebooks();
+ this.render();
+ }
+
+ disconnectedCallback() {
+ this.cleanup();
+ }
+
+ private cleanup() {
+ this.stopDurationTimer();
+ this.dictation?.destroy();
+ this.dictation = null;
+ if (this.mediaRecorder?.state === 'recording') {
+ this.mediaRecorder.stop();
+ }
+ this.mediaRecorder = null;
+ if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
+ }
+
+ private getApiBase(): string {
+ const path = window.location.pathname;
+ const match = path.match(/^(\/[^/]+)?\/rnotes/);
+ return match ? match[0] : '';
+ }
+
+ private authHeaders(extra?: Record): Record {
+ const headers: Record = { ...extra };
+ const token = getAccessToken();
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+ return headers;
+ }
+
+ private async loadNotebooks() {
+ try {
+ const base = this.getApiBase();
+ const res = await fetch(`${base}/api/notebooks`, { headers: this.authHeaders() });
+ const data = await res.json();
+ this.notebooks = (data.notebooks || []).map((nb: any) => ({ id: nb.id, title: nb.title }));
+ if (this.notebooks.length > 0 && !this.selectedNotebookId) {
+ this.selectedNotebookId = this.notebooks[0].id;
+ }
+ this.render();
+ } catch { /* fallback: empty list */ }
+ }
+
+ private async startRecording() {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+ // Determine supported mimeType
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
+ ? 'audio/webm;codecs=opus'
+ : MediaRecorder.isTypeSupported('audio/webm')
+ ? 'audio/webm'
+ : 'audio/mp4';
+
+ this.audioChunks = [];
+ this.segments = [];
+ this.mediaRecorder = new MediaRecorder(stream, { mimeType });
+
+ this.mediaRecorder.ondataavailable = (e) => {
+ if (e.data.size > 0) this.audioChunks.push(e.data);
+ };
+
+ this.mediaRecorder.onstop = () => {
+ stream.getTracks().forEach(t => t.stop());
+ this.audioBlob = new Blob(this.audioChunks, { type: mimeType });
+ if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
+ this.audioUrl = URL.createObjectURL(this.audioBlob);
+ this.processRecording();
+ };
+
+ this.mediaRecorder.start(1000); // 1s timeslice
+
+ // Start live transcription via Web Speech API with segment tracking
+ this.liveTranscript = '';
+ if (SpeechDictation.isSupported()) {
+ this.dictation = new SpeechDictation({
+ onInterim: (text) => {
+ const interimIdx = this.segments.findIndex(s => !s.isFinal);
+ if (interimIdx >= 0) {
+ this.segments[interimIdx].text = text;
+ } else {
+ this.segments.push({
+ id: crypto.randomUUID(),
+ text,
+ timestamp: this.elapsedSeconds,
+ isFinal: false,
+ });
+ }
+ this.renderTranscriptSegments();
+ },
+ onFinal: (text) => {
+ const interimIdx = this.segments.findIndex(s => !s.isFinal);
+ if (interimIdx >= 0) {
+ this.segments[interimIdx] = { ...this.segments[interimIdx], text, isFinal: true };
+ } else {
+ this.segments.push({
+ id: crypto.randomUUID(),
+ text,
+ timestamp: this.elapsedSeconds,
+ isFinal: true,
+ });
+ }
+ this.liveTranscript = this.segments.filter(s => s.isFinal).map(s => s.text).join(' ');
+ this.renderTranscriptSegments();
+ },
+ });
+ this.dictation.start();
+ }
+
+ // Start timer
+ this.recordingStartTime = Date.now();
+ this.elapsedSeconds = 0;
+ this.durationTimer = setInterval(() => {
+ this.elapsedSeconds = Math.floor((Date.now() - this.recordingStartTime) / 1000);
+ const timerEl = this.shadow.querySelector('.recording-timer');
+ if (timerEl) timerEl.textContent = this.formatTime(this.elapsedSeconds);
+ }, 1000);
+
+ this.state = 'recording';
+ this.render();
+ } catch (err) {
+ console.error('Failed to start recording:', err);
+ }
+ }
+
+ private stopRecording() {
+ this.stopDurationTimer();
+ this.dictation?.stop();
+ if (this.mediaRecorder?.state === 'recording') {
+ this.mediaRecorder.stop();
+ }
+ }
+
+ private stopDurationTimer() {
+ if (this.durationTimer) {
+ clearInterval(this.durationTimer);
+ this.durationTimer = null;
+ }
+ }
+
+ /** Targeted DOM update of transcript segments container (avoids full re-render). */
+ private renderTranscriptSegments() {
+ const container = this.shadow.querySelector('.live-transcript-segments');
+ if (!container) return;
+
+ const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
+
+ container.innerHTML = this.segments.map(seg => `
+
+ [${this.formatTime(seg.timestamp)}]
+ ${esc(seg.text)}
+
+ `).join('');
+
+ // Auto-scroll to bottom
+ container.scrollTop = container.scrollHeight;
+ }
+
+ /** Convert final segments to Tiptap JSON document with timestamped paragraphs. */
+ private segmentsToTiptapJSON(): object {
+ const finalSegments = this.segments.filter(s => s.isFinal);
+ if (finalSegments.length === 0) return { type: 'doc', content: [{ type: 'paragraph' }] };
+
+ return {
+ type: 'doc',
+ content: finalSegments.map(seg => ({
+ type: 'paragraph',
+ content: [
+ { type: 'text', marks: [{ type: 'code' }], text: `[${this.formatTime(seg.timestamp)}]` },
+ { type: 'text', text: ` ${seg.text}` },
+ ],
+ })),
+ };
+ }
+
+ private async processRecording() {
+ this.state = 'processing';
+ this.progressMessage = 'Processing recording...';
+ this.render();
+
+ // Three-tier transcription cascade
+ let transcript = '';
+
+ // Tier 1: Server transcription
+ if (this.audioBlob && this.space !== 'demo') {
+ try {
+ this.progressMessage = 'Sending to server for transcription...';
+ this.render();
+ const base = this.getApiBase();
+ const formData = new FormData();
+ formData.append('file', this.audioBlob, 'recording.webm');
+ const res = await fetch(`${base}/api/voice/transcribe`, {
+ method: 'POST',
+ headers: this.authHeaders(),
+ body: formData,
+ });
+ if (res.ok) {
+ const data = await res.json();
+ transcript = data.text || data.transcript || '';
+ }
+ } catch { /* fall through to next tier */ }
+ }
+
+ // Tier 2: Live transcript from segments
+ if (!transcript && this.liveTranscript.trim()) {
+ transcript = this.liveTranscript.trim();
+ }
+
+ // Tier 3: Offline Parakeet transcription
+ if (!transcript && this.audioBlob) {
+ try {
+ transcript = await transcribeOffline(this.audioBlob, (p: TranscriptionProgress) => {
+ this.progressMessage = p.message || 'Processing...';
+ this.render();
+ });
+ } catch {
+ this.progressMessage = 'Transcription failed. You can still save the recording.';
+ this.render();
+ }
+ }
+
+ this.finalTranscript = transcript;
+ this.state = 'done';
+ this.progressMessage = '';
+ this.render();
+ }
+
+ private async saveNote() {
+ if (!this.audioBlob || !this.selectedNotebookId) return;
+
+ const base = this.getApiBase();
+
+ // Upload audio file
+ let fileUrl = '';
+ try {
+ const formData = new FormData();
+ formData.append('file', this.audioBlob, 'recording.webm');
+ const uploadRes = await fetch(`${base}/api/uploads`, {
+ method: 'POST',
+ headers: this.authHeaders(),
+ body: formData,
+ });
+ if (uploadRes.ok) {
+ const uploadData = await uploadRes.json();
+ fileUrl = uploadData.url;
+ }
+ } catch { /* continue without file */ }
+
+ // Build content: use Tiptap JSON with segments if available, else raw text
+ const hasFinalSegments = this.segments.some(s => s.isFinal);
+ const content = hasFinalSegments
+ ? JSON.stringify(this.segmentsToTiptapJSON())
+ : (this.finalTranscript || '');
+ const contentFormat = hasFinalSegments ? 'tiptap-json' : undefined;
+
+ // Create the note
+ const tagList = this.tags.split(',').map(t => t.trim()).filter(Boolean);
+ tagList.push('voice');
+
+ try {
+ const res = await fetch(`${base}/api/notes`, {
+ method: 'POST',
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({
+ notebook_id: this.selectedNotebookId,
+ title: `Voice Note — ${new Date().toLocaleDateString()}`,
+ content,
+ content_format: contentFormat,
+ type: 'AUDIO',
+ tags: tagList,
+ file_url: fileUrl,
+ mime_type: this.audioBlob.type,
+ duration: this.elapsedSeconds,
+ }),
+ });
+ if (res.ok) {
+ this.state = 'idle';
+ this.finalTranscript = '';
+ this.liveTranscript = '';
+ this.segments = [];
+ this.audioBlob = null;
+ if (this.audioUrl) { URL.revokeObjectURL(this.audioUrl); this.audioUrl = null; }
+ this.render();
+ // Show success briefly
+ this.progressMessage = 'Note saved!';
+ this.render();
+ setTimeout(() => { this.progressMessage = ''; this.render(); }, 2000);
+ }
+ } catch (err) {
+ this.progressMessage = 'Failed to save note';
+ this.render();
+ }
+ }
+
+ private discard() {
+ this.cleanup();
+ this.state = 'idle';
+ this.finalTranscript = '';
+ this.liveTranscript = '';
+ this.segments = [];
+ this.audioBlob = null;
+ this.audioUrl = null;
+ this.elapsedSeconds = 0;
+ this.progressMessage = '';
+ this.render();
+ }
+
+ private formatTime(s: number): string {
+ const m = Math.floor(s / 60);
+ const sec = s % 60;
+ return `${m}:${String(sec).padStart(2, '0')}`;
+ }
+
+ private render() {
+ const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
+
+ let body = '';
+ switch (this.state) {
+ case 'idle':
+ body = `
+
+
+
Voice Recorder
+
Record voice notes with automatic transcription
+
+
+
+
+
+ ${isModelCached() ? '
Offline model cached
' : ''}
+
`;
+ break;
+
+ case 'recording':
+ body = `
+
+
+
${this.formatTime(this.elapsedSeconds)}
+
Recording...
+
+
+
`;
+ break;
+
+ case 'processing':
+ body = `
+
+
+
${esc(this.progressMessage)}
+
`;
+ break;
+
+ case 'done':
+ body = `
+
+
Recording Complete
+ ${this.audioUrl ? `
` : ''}
+
Duration: ${this.formatTime(this.elapsedSeconds)}
+
+
+
+
+
+
+
+
+
+
`;
+ break;
+ }
+
+ this.shadow.innerHTML = `
+
+ ${body}
+ ${this.progressMessage && this.state === 'idle' ? `${esc(this.progressMessage)}
` : ''}
+ `;
+ this.attachListeners();
+
+ // Re-render segments after DOM is in place (recording state)
+ if (this.state === 'recording' && this.segments.length > 0) {
+ this.renderTranscriptSegments();
+ }
+ }
+
+ private attachListeners() {
+ this.shadow.getElementById('btn-start')?.addEventListener('click', () => this.startRecording());
+ this.shadow.getElementById('btn-stop')?.addEventListener('click', () => this.stopRecording());
+ this.shadow.getElementById('btn-save')?.addEventListener('click', () => this.saveNote());
+ this.shadow.getElementById('btn-discard')?.addEventListener('click', () => this.discard());
+ this.shadow.getElementById('btn-copy')?.addEventListener('click', () => {
+ const textarea = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement;
+ if (textarea) navigator.clipboard.writeText(textarea.value);
+ });
+
+ const nbSelect = this.shadow.getElementById('notebook-select') as HTMLSelectElement;
+ if (nbSelect) nbSelect.addEventListener('change', () => { this.selectedNotebookId = nbSelect.value; });
+
+ const tagsInput = this.shadow.getElementById('tags-input') as HTMLInputElement;
+ if (tagsInput) tagsInput.addEventListener('input', () => { this.tags = tagsInput.value; });
+
+ const transcriptEdit = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement;
+ if (transcriptEdit) transcriptEdit.addEventListener('input', () => { this.finalTranscript = transcriptEdit.value; });
+ }
+
+ private getStyles(): string {
+ return `
+ :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
+ * { box-sizing: border-box; }
+
+ .voice-recorder {
+ max-width: 600px; margin: 0 auto; padding: 40px 20px;
+ display: flex; flex-direction: column; align-items: center; text-align: center;
+ }
+
+ h2 { font-size: 24px; font-weight: 700; margin: 16px 0 4px; }
+ h3 { font-size: 18px; font-weight: 600; margin: 0 0 16px; }
+ .recorder-subtitle { color: var(--rs-text-muted); margin: 0 0 24px; }
+
+ .recorder-icon { color: var(--rs-primary); margin-bottom: 8px; }
+
+ .recorder-config {
+ display: flex; flex-direction: column; gap: 12px; width: 100%;
+ max-width: 400px; margin-bottom: 24px; text-align: left;
+ }
+ .recorder-config label { font-size: 13px; color: var(--rs-text-secondary); display: flex; flex-direction: column; gap: 4px; }
+ .recorder-config select, .recorder-config input {
+ padding: 8px 12px; border-radius: 6px; border: 1px solid var(--rs-input-border);
+ background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 14px; font-family: inherit;
+ }
+
+ .record-btn {
+ padding: 14px 36px; border-radius: 50px; border: none;
+ background: var(--rs-error, #ef4444); color: #fff; font-size: 16px; font-weight: 600;
+ cursor: pointer; transition: all 0.2s;
+ }
+ .record-btn:hover { transform: scale(1.05); filter: brightness(1.1); }
+
+ .model-status { font-size: 11px; color: var(--rs-text-muted); margin-top: 12px; }
+
+ /* Recording state */
+ .recorder-recording { display: flex; flex-direction: column; align-items: center; gap: 16px; }
+ .recording-pulse {
+ width: 80px; height: 80px; border-radius: 50%;
+ background: var(--rs-error, #ef4444); animation: pulse 1.5s infinite;
+ }
+ @keyframes pulse {
+ 0% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
+ 70% { transform: scale(1.05); opacity: 0.8; box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); }
+ 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
+ }
+ .recording-timer { font-size: 48px; font-weight: 700; font-variant-numeric: tabular-nums; }
+ .recording-status { color: var(--rs-error, #ef4444); font-weight: 500; }
+
+ /* Live transcript segments */
+ .live-transcript-segments {
+ width: 100%; max-width: 500px; max-height: 250px; overflow-y: auto;
+ text-align: left; padding: 8px 0;
+ }
+ .transcript-segment {
+ display: flex; gap: 8px; padding: 4px 12px; border-radius: 4px;
+ font-size: 14px; line-height: 1.6;
+ }
+ .transcript-segment.interim {
+ font-style: italic; color: var(--rs-text-muted);
+ background: var(--rs-bg-surface-raised);
+ }
+ .segment-time {
+ flex-shrink: 0; font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 12px; color: var(--rs-text-muted); padding-top: 2px;
+ }
+ .segment-text { flex: 1; }
+
+ .stop-btn {
+ padding: 12px 32px; border-radius: 50px; border: none;
+ background: var(--rs-text-primary); color: var(--rs-bg-surface); font-size: 15px; font-weight: 600;
+ cursor: pointer;
+ }
+
+ /* Processing */
+ .recorder-processing { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 40px; }
+ .processing-spinner {
+ width: 48px; height: 48px; border: 3px solid var(--rs-border);
+ border-top-color: var(--rs-primary); border-radius: 50%; animation: spin 0.8s linear infinite;
+ }
+ @keyframes spin { to { transform: rotate(360deg); } }
+
+ /* Done */
+ .recorder-done { display: flex; flex-direction: column; align-items: center; gap: 12px; width: 100%; }
+ .result-audio { width: 100%; max-width: 500px; height: 40px; margin-bottom: 8px; }
+ .result-duration { font-size: 13px; color: var(--rs-text-muted); }
+ .transcript-section { width: 100%; max-width: 500px; text-align: left; }
+ .transcript-section label { font-size: 12px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
+ .transcript-textarea {
+ width: 100%; min-height: 120px; padding: 12px; margin-top: 4px;
+ border-radius: 8px; border: 1px solid var(--rs-input-border);
+ background: var(--rs-input-bg); color: var(--rs-input-text);
+ font-size: 14px; font-family: inherit; line-height: 1.6; resize: vertical;
+ }
+ .result-actions { display: flex; gap: 8px; margin-top: 8px; }
+ .save-btn {
+ padding: 10px 24px; border-radius: 8px; border: none;
+ background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer;
+ }
+ .copy-btn, .discard-btn {
+ padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer;
+ border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary);
+ }
+ .discard-btn { color: var(--rs-error, #ef4444); border-color: var(--rs-error, #ef4444); }
+
+ .toast {
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
+ padding: 10px 20px; border-radius: 8px; background: var(--rs-primary); color: #fff;
+ font-size: 13px; font-weight: 500; z-index: 100;
+ }
+ `;
+ }
+}
+
+customElements.define('folk-voice-recorder', FolkVoiceRecorder);
diff --git a/modules/rdocs/components/import-export-dialog.ts b/modules/rdocs/components/import-export-dialog.ts
new file mode 100644
index 00000000..a9eda96c
--- /dev/null
+++ b/modules/rdocs/components/import-export-dialog.ts
@@ -0,0 +1,1003 @@
+/**
+ * — Modal dialog for importing, exporting, and syncing notes.
+ *
+ * Import sources: Files (direct), Obsidian, Logseq, Notion, Google Docs, Evernote, Roam Research.
+ * Export targets: Obsidian, Logseq, Notion, Google Docs.
+ * Sync: Re-fetch from API sources (Notion/Google) or re-upload vault ZIPs for file-based.
+ *
+ * File-based sources (Obsidian/Logseq/Evernote/Roam) use file upload.
+ * API-based sources (Notion/Google) use OAuth connections.
+ */
+
+import { getModuleApiBase } from "../../../shared/url-helpers";
+
+interface NotebookOption {
+ id: string;
+ title: string;
+}
+
+interface ConnectionStatus {
+ notion: { connected: boolean; workspaceName?: string };
+ google: { connected: boolean; email?: string };
+ logseq: { connected: boolean };
+ obsidian: { connected: boolean };
+}
+
+interface RemotePage {
+ id: string;
+ title: string;
+ lastEdited?: string;
+ lastModified?: string;
+ icon?: string;
+}
+
+class ImportExportDialog extends HTMLElement {
+ private shadow!: ShadowRoot;
+ private space = '';
+ private activeTab: 'import' | 'export' | 'sync' = 'import';
+ private activeSource: 'files' | 'obsidian' | 'logseq' | 'notion' | 'google-docs' | 'evernote' | 'roam' = 'files';
+ private notebooks: NotebookOption[] = [];
+ private connections: ConnectionStatus & { evernote?: { connected: boolean }; roam?: { connected: boolean } } = {
+ notion: { connected: false },
+ google: { connected: false },
+ logseq: { connected: true },
+ obsidian: { connected: true },
+ };
+ private selectedFiles: File[] = [];
+ private remotePages: RemotePage[] = [];
+ private selectedPages = new Set();
+ private importing = false;
+ private exporting = false;
+ private syncing = false;
+ private syncStatuses: Record = {};
+ private statusMessage = '';
+ private statusType: 'info' | 'success' | 'error' = 'info';
+ private selectedFile: File | null = null;
+ private targetNotebookId = '';
+
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.space = this.getAttribute('space') || 'demo';
+ this.render();
+ this.loadConnections();
+ }
+
+ /** Open the dialog. */
+ open(notebooks: NotebookOption[], tab: 'import' | 'export' | 'sync' = 'import') {
+ this.notebooks = notebooks;
+ this.activeTab = tab;
+ this.statusMessage = '';
+ this.selectedPages.clear();
+ this.selectedFile = null;
+ this.selectedFiles = [];
+ this.syncStatuses = {};
+ this.render();
+ (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open');
+ }
+
+ /** Close the dialog. */
+ close() {
+ (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.remove('open');
+ }
+
+ private async loadConnections() {
+ try {
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/connections`);
+ if (res.ok) {
+ this.connections = await res.json();
+ }
+ } catch { /* ignore */ }
+ }
+
+ private async loadRemotePages() {
+ this.remotePages = [];
+ this.selectedPages.clear();
+
+ if (this.activeSource === 'notion') {
+ try {
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion/pages`);
+ if (res.ok) {
+ const data = await res.json();
+ this.remotePages = data.pages || [];
+ }
+ } catch { /* ignore */ }
+ } else if (this.activeSource === 'google-docs') {
+ try {
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs/list`);
+ if (res.ok) {
+ const data = await res.json();
+ this.remotePages = data.docs || [];
+ }
+ } catch { /* ignore */ }
+ }
+
+ this.render();
+ (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open');
+ }
+
+ private setStatus(msg: string, type: 'info' | 'success' | 'error' = 'info') {
+ this.statusMessage = msg;
+ this.statusType = type;
+ const statusEl = this.shadow.querySelector('.status-message') as HTMLElement;
+ if (statusEl) {
+ statusEl.textContent = msg;
+ statusEl.className = `status-message status-${type}`;
+ statusEl.style.display = msg ? 'block' : 'none';
+ }
+ }
+
+ private async handleImport() {
+ this.importing = true;
+ this.setStatus('Importing...', 'info');
+
+ try {
+ if (this.activeSource === 'files') {
+ if (this.selectedFiles.length === 0) {
+ this.setStatus('Please select at least one file', 'error');
+ this.importing = false;
+ return;
+ }
+
+ const formData = new FormData();
+ for (const f of this.selectedFiles) formData.append('files', f);
+ if (this.targetNotebookId) formData.append('notebookId', this.targetNotebookId);
+
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/files`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ body: formData,
+ });
+
+ const data = await res.json();
+ if (data.ok) {
+ this.setStatus(`Imported ${data.imported} note${data.imported !== 1 ? 's' : ''}${data.warnings?.length ? ` (${data.warnings.length} warnings)` : ''}`, 'success');
+ this.dispatchEvent(new CustomEvent('import-complete', { detail: data }));
+ } else {
+ this.setStatus(data.error || 'Import failed', 'error');
+ }
+ } else if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'evernote' || this.activeSource === 'roam') {
+ if (!this.selectedFile) {
+ const fileTypeHint: Record = {
+ obsidian: 'ZIP file', logseq: 'ZIP file',
+ evernote: '.enex file', roam: 'JSON file',
+ };
+ this.setStatus(`Please select a ${fileTypeHint[this.activeSource] || 'file'}`, 'error');
+ this.importing = false;
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('file', this.selectedFile);
+ formData.append('source', this.activeSource);
+ if (this.targetNotebookId) {
+ formData.append('notebookId', this.targetNotebookId);
+ }
+
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/upload`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ body: formData,
+ });
+
+ const data = await res.json();
+ if (data.ok) {
+ this.setStatus(
+ `Imported ${data.imported} notes${data.updated ? `, updated ${data.updated}` : ''}${data.warnings?.length ? ` (${data.warnings.length} warnings)` : ''}`,
+ 'success'
+ );
+ this.dispatchEvent(new CustomEvent('import-complete', { detail: data }));
+ } else {
+ this.setStatus(data.error || 'Import failed', 'error');
+ }
+ } else if (this.activeSource === 'notion') {
+ if (this.selectedPages.size === 0) {
+ this.setStatus('Please select at least one page', 'error');
+ this.importing = false;
+ return;
+ }
+
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ pageIds: Array.from(this.selectedPages),
+ notebookId: this.targetNotebookId || undefined,
+ }),
+ });
+
+ const data = await res.json();
+ if (data.ok) {
+ this.setStatus(`Imported ${data.imported} notes from Notion`, 'success');
+ this.dispatchEvent(new CustomEvent('import-complete', { detail: data }));
+ } else {
+ this.setStatus(data.error || 'Notion import failed', 'error');
+ }
+ } else if (this.activeSource === 'google-docs') {
+ if (this.selectedPages.size === 0) {
+ this.setStatus('Please select at least one document', 'error');
+ this.importing = false;
+ return;
+ }
+
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ docIds: Array.from(this.selectedPages),
+ notebookId: this.targetNotebookId || undefined,
+ }),
+ });
+
+ const data = await res.json();
+ if (data.ok) {
+ this.setStatus(`Imported ${data.imported} notes from Google Docs`, 'success');
+ this.dispatchEvent(new CustomEvent('import-complete', { detail: data }));
+ } else {
+ this.setStatus(data.error || 'Google Docs import failed', 'error');
+ }
+ }
+ } catch (err) {
+ this.setStatus(`Import error: ${(err as Error).message}`, 'error');
+ }
+
+ this.importing = false;
+ }
+
+ private async handleExport() {
+ if (!this.targetNotebookId) {
+ this.setStatus('Please select a notebook to export', 'error');
+ return;
+ }
+
+ this.exporting = true;
+ this.setStatus('Exporting...', 'info');
+
+ try {
+ if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'markdown' as any) {
+ const format = this.activeSource === 'markdown' as any ? 'markdown' : this.activeSource;
+ const url = `${getModuleApiBase("rnotes")}/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`;
+ const res = await fetch(url);
+
+ if (res.ok) {
+ const blob = await res.blob();
+ const disposition = res.headers.get('Content-Disposition') || '';
+ const filename = disposition.match(/filename="(.+)"/)?.[1] || `export-${format}.zip`;
+
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(a.href);
+
+ this.setStatus('Download started!', 'success');
+ } else {
+ const data = await res.json();
+ this.setStatus(data.error || 'Export failed', 'error');
+ }
+ } else if (this.activeSource === 'notion') {
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/notion`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ notebookId: this.targetNotebookId }),
+ });
+
+ const data = await res.json();
+ if (data.exported) {
+ this.setStatus(`Exported ${data.exported.length} notes to Notion`, 'success');
+ } else {
+ this.setStatus(data.error || 'Notion export failed', 'error');
+ }
+ } else if (this.activeSource === 'google-docs') {
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/google-docs`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ notebookId: this.targetNotebookId }),
+ });
+
+ const data = await res.json();
+ if (data.exported) {
+ this.setStatus(`Exported ${data.exported.length} notes to Google Docs`, 'success');
+ } else {
+ this.setStatus(data.error || 'Google Docs export failed', 'error');
+ }
+ }
+ } catch (err) {
+ this.setStatus(`Export error: ${(err as Error).message}`, 'error');
+ }
+
+ this.exporting = false;
+ }
+
+ private esc(s: string): string {
+ const d = document.createElement('div');
+ d.textContent = s || '';
+ return d.innerHTML;
+ }
+
+ private render() {
+ const isApiSource = this.activeSource === 'notion' || this.activeSource === 'google-docs';
+ const isFileSource = this.activeSource === 'files';
+ // File-based sources (files, obsidian, logseq, evernote, roam) are always "connected" — no auth needed
+ const fileBased = ['files', 'obsidian', 'logseq', 'evernote', 'roam'];
+ const sourceConnKey = this.activeSource === 'google-docs' ? 'google' : this.activeSource;
+ const isConnected = fileBased.includes(this.activeSource) || (this.connections as any)[sourceConnKey]?.connected || false;
+
+ this.shadow.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ ${this.activeTab !== 'sync' ? (() => {
+ const sources = this.activeTab === 'export'
+ ? (['obsidian', 'logseq', 'notion', 'google-docs'] as const)
+ : (['files', 'obsidian', 'logseq', 'notion', 'google-docs', 'evernote', 'roam'] as const);
+ return `
+ ${sources.map(s => `
+
+ `).join('')}
+
`;
+ })() : ''}
+
+
+ ${this.activeTab === 'sync' ? this.renderSyncTab() : this.activeTab === 'import' ? this.renderImportTab(isApiSource, isConnected) : this.renderExportTab(isApiSource, isConnected)}
+
+
+ ${this.esc(this.statusMessage)}
+
+
+
+
`;
+
+ this.attachListeners();
+ }
+
+ private renderImportTab(isApiSource: boolean, isConnected: boolean): string {
+ if (isApiSource && !isConnected) {
+ return `
+
+
Connect your ${this.sourceName(this.activeSource)} account to import notes.
+
+
`;
+ }
+
+ if (isApiSource) {
+ // Show page list for selection
+ return `
+
+
+ ${this.remotePages.length === 0
+ ? '
No pages found. Click Refresh to load.
'
+ : this.remotePages.map(p => `
+
+ `).join('')}
+
+
+
+
+
+ `;
+ }
+
+ // Generic file import
+ if (this.activeSource === 'files') {
+ return `
+
+
Drop files to import as notes
+
+
+
.md .txt .html .jpg .png .webp — drag & drop supported
+
+
+
+
+
+ `;
+ }
+
+ // File-based source import (Obsidian/Logseq/Evernote/Roam)
+ const acceptMap: Record = {
+ obsidian: '.zip', logseq: '.zip', evernote: '.enex,.zip', roam: '.json,.zip',
+ };
+ const hintMap: Record = {
+ obsidian: 'Upload a ZIP of your Obsidian vault',
+ logseq: 'Upload a ZIP of your Logseq graph',
+ evernote: 'Upload an .enex export file',
+ roam: 'Upload a Roam Research JSON export',
+ };
+ return `
+
+
${hintMap[this.activeSource] || 'Upload a file'}
+
+
+
or drag & drop here
+
+
+
+
+
+ `;
+ }
+
+ private renderExportTab(isApiSource: boolean, isConnected: boolean): string {
+ if (isApiSource && !isConnected) {
+ return `
+
+
Connect your ${this.sourceName(this.activeSource)} account to export notes.
+
+
`;
+ }
+
+ return `
+
+
+
+
+ `;
+ }
+
+ private renderSyncTab(): string {
+ const statusEntries = Object.entries(this.syncStatuses);
+ const hasApiNotes = statusEntries.some(([_, s]) => s.source === 'notion' || s.source === 'google-docs');
+ const hasFileNotes = statusEntries.some(([_, s]) => s.source === 'obsidian' || s.source === 'logseq');
+
+ return `
+
+
+
+
+
+ ${this.targetNotebookId ? `
+
+ ${statusEntries.length === 0
+ ? '
No imported notes found in this notebook. Import notes first to enable sync.
'
+ : `
${statusEntries.length} synced note${statusEntries.length !== 1 ? 's' : ''} found
+
+ ${statusEntries.map(([id, s]) => `
+
+ ${s.source}
+ ${s.hasConflict ? 'conflict' : s.syncStatus || 'synced'}
+ ${s.lastSyncedAt ? `${this.relativeTime(s.lastSyncedAt)}` : ''}
+
+ `).join('')}
+
`
+ }
+
+
+ ${hasApiNotes ? `
+
+ ` : ''}
+
+ ${hasFileNotes ? `
+
+
+ File-based sources require re-uploading your vault ZIP:
+
+
+
+
+
+
+ ` : ''}
+ ` : ''}
+ `;
+ }
+
+ private relativeTime(ts: number): string {
+ const diff = Date.now() - ts;
+ if (diff < 60000) return 'just now';
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
+ return `${Math.floor(diff / 86400000)}d ago`;
+ }
+
+ private async loadSyncStatus(notebookId: string) {
+ try {
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/status/${notebookId}`);
+ if (res.ok) {
+ const data = await res.json();
+ this.syncStatuses = data.statuses || {};
+ }
+ } catch { /* ignore */ }
+ this.render();
+ (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open');
+ }
+
+ private async handleSyncApi() {
+ if (!this.targetNotebookId) return;
+ this.syncing = true;
+ this.setStatus('Syncing...', 'info');
+
+ try {
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/notebook/${this.targetNotebookId}`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ });
+
+ const data = await res.json();
+ if (data.ok) {
+ const parts: string[] = [];
+ if (data.synced > 0) parts.push(`${data.synced} updated`);
+ if (data.conflicts > 0) parts.push(`${data.conflicts} conflicts`);
+ if (data.errors > 0) parts.push(`${data.errors} errors`);
+ this.setStatus(parts.length > 0 ? `Sync complete: ${parts.join(', ')}` : 'All notes up to date', 'success');
+ this.dispatchEvent(new CustomEvent('sync-complete', { detail: data }));
+ this.loadSyncStatus(this.targetNotebookId);
+ } else {
+ this.setStatus(data.error || 'Sync failed', 'error');
+ }
+ } catch (err) {
+ this.setStatus(`Sync error: ${(err as Error).message}`, 'error');
+ }
+
+ this.syncing = false;
+ }
+
+ private async handleSyncUpload(file: File) {
+ if (!this.targetNotebookId) return;
+ this.syncing = true;
+ this.setStatus('Syncing from ZIP...', 'info');
+
+ // Detect source from sync statuses
+ const sources = new Set(Object.values(this.syncStatuses).map(s => s.source));
+ const source = sources.has('obsidian') ? 'obsidian' : sources.has('logseq') ? 'logseq' : '';
+ if (!source) {
+ this.setStatus('Could not determine source type', 'error');
+ this.syncing = false;
+ return;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('notebookId', this.targetNotebookId);
+ formData.append('source', source);
+
+ const token = localStorage.getItem('encryptid_token') || '';
+ const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/upload`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` },
+ body: formData,
+ });
+
+ const data = await res.json();
+ if (data.ok) {
+ this.setStatus(`Sync: ${data.synced} updated, ${data.conflicts} conflicts`, 'success');
+ this.dispatchEvent(new CustomEvent('sync-complete', { detail: data }));
+ this.loadSyncStatus(this.targetNotebookId);
+ } else {
+ this.setStatus(data.error || 'Sync failed', 'error');
+ }
+ } catch (err) {
+ this.setStatus(`Sync error: ${(err as Error).message}`, 'error');
+ }
+
+ this.syncing = false;
+ }
+
+ private sourceName(s: string): string {
+ const names: Record = {
+ files: 'Files',
+ obsidian: 'Obsidian',
+ logseq: 'Logseq',
+ notion: 'Notion',
+ 'google-docs': 'Google Docs',
+ evernote: 'Evernote',
+ roam: 'Roam',
+ };
+ return names[s] || s;
+ }
+
+ private sourceIcon(s: string): string {
+ const icons: Record = {
+ files: '',
+ obsidian: '',
+ logseq: '',
+ notion: '',
+ 'google-docs': '',
+ evernote: '',
+ roam: '',
+ };
+ return icons[s] || '';
+ }
+
+ private attachListeners() {
+ // Close button
+ this.shadow.getElementById('btn-close')?.addEventListener('click', () => this.close());
+
+ // Overlay click to close
+ this.shadow.querySelector('.dialog-overlay')?.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).classList.contains('dialog-overlay')) this.close();
+ });
+
+ // Tab switching
+ this.shadow.querySelectorAll('.tab').forEach(btn => {
+ btn.addEventListener('click', () => {
+ this.activeTab = (btn as HTMLElement).dataset.tab as any;
+ // Auto-select valid source when switching to export (non-exportable: files, evernote, roam)
+ if (this.activeTab === 'export' && ['files', 'evernote', 'roam'].includes(this.activeSource)) {
+ this.activeSource = 'obsidian';
+ }
+ this.statusMessage = '';
+ this.render();
+ (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open');
+ });
+ });
+
+ // Source switching
+ this.shadow.querySelectorAll('.source-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ this.activeSource = (btn as HTMLElement).dataset.source as any;
+ this.remotePages = [];
+ this.selectedPages.clear();
+ this.selectedFile = null;
+ this.selectedFiles = [];
+ this.statusMessage = '';
+ this.render();
+ (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open');
+ });
+ });
+
+ // File input
+ const fileInput = this.shadow.getElementById('file-input') as HTMLInputElement;
+ const chooseBtn = this.shadow.getElementById('btn-choose-file');
+ chooseBtn?.addEventListener('click', () => fileInput?.click());
+ fileInput?.addEventListener('change', () => {
+ if (this.activeSource === 'files') {
+ this.selectedFiles = Array.from(fileInput.files || []);
+ if (chooseBtn) chooseBtn.textContent = this.selectedFiles.length > 0 ? `${this.selectedFiles.length} file${this.selectedFiles.length > 1 ? 's' : ''} selected` : 'Choose Files';
+ const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement;
+ if (importBtn) importBtn.disabled = this.selectedFiles.length === 0;
+ } else {
+ this.selectedFile = fileInput.files?.[0] || null;
+ if (chooseBtn) chooseBtn.textContent = this.selectedFile?.name || 'Choose File';
+ const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement;
+ if (importBtn) importBtn.disabled = !this.selectedFile;
+ }
+ });
+
+ // Drag & drop
+ const uploadArea = this.shadow.getElementById('upload-area');
+ if (uploadArea) {
+ uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); });
+ uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
+ uploadArea.addEventListener('drop', (e) => {
+ e.preventDefault();
+ uploadArea.classList.remove('dragover');
+ const files = (e as DragEvent).dataTransfer?.files;
+ if (!files || files.length === 0) return;
+ if (this.activeSource === 'files') {
+ this.selectedFiles = Array.from(files);
+ if (chooseBtn) chooseBtn.textContent = `${this.selectedFiles.length} file${this.selectedFiles.length > 1 ? 's' : ''} selected`;
+ const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement;
+ if (importBtn) importBtn.disabled = false;
+ } else {
+ const file = files[0];
+ this.selectedFile = file;
+ if (chooseBtn) chooseBtn.textContent = file.name;
+ const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement;
+ if (importBtn) importBtn.disabled = false;
+ }
+ });
+ }
+
+ // Target notebook select
+ const notebookSelect = this.shadow.getElementById('target-notebook') as HTMLSelectElement;
+ notebookSelect?.addEventListener('change', () => {
+ this.targetNotebookId = notebookSelect.value;
+ });
+
+ // Page checkboxes
+ this.shadow.querySelectorAll('.page-item input[type="checkbox"]').forEach(cb => {
+ cb.addEventListener('change', () => {
+ const input = cb as HTMLInputElement;
+ if (input.checked) {
+ this.selectedPages.add(input.value);
+ } else {
+ this.selectedPages.delete(input.value);
+ }
+ const importBtn = this.shadow.getElementById('btn-import');
+ if (importBtn) importBtn.textContent = `Import Selected (${this.selectedPages.size})`;
+ });
+ });
+
+ // Refresh pages
+ this.shadow.getElementById('btn-refresh-pages')?.addEventListener('click', () => {
+ this.loadRemotePages();
+ });
+
+ // Connect button
+ this.shadow.getElementById('btn-connect')?.addEventListener('click', () => {
+ const provider = this.activeSource === 'google-docs' ? 'google' : this.activeSource;
+ window.location.href = `/api/oauth/${provider}/authorize?space=${this.space}`;
+ });
+
+ // Import button
+ this.shadow.getElementById('btn-import')?.addEventListener('click', () => this.handleImport());
+
+ // Export button
+ this.shadow.getElementById('btn-export')?.addEventListener('click', () => this.handleExport());
+
+ // Sync tab listeners
+ const syncNotebookSelect = this.shadow.getElementById('sync-notebook') as HTMLSelectElement;
+ syncNotebookSelect?.addEventListener('change', () => {
+ this.targetNotebookId = syncNotebookSelect.value;
+ if (this.targetNotebookId) {
+ this.loadSyncStatus(this.targetNotebookId);
+ } else {
+ this.syncStatuses = {};
+ this.render();
+ (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open');
+ }
+ });
+
+ this.shadow.getElementById('btn-sync-api')?.addEventListener('click', () => this.handleSyncApi());
+
+ const syncFileInput = this.shadow.getElementById('sync-file-input') as HTMLInputElement;
+ this.shadow.getElementById('btn-sync-choose-file')?.addEventListener('click', () => syncFileInput?.click());
+ syncFileInput?.addEventListener('change', () => {
+ const file = syncFileInput.files?.[0];
+ if (file) this.handleSyncUpload(file);
+ });
+ }
+
+ private getStyles(): string {
+ return `
+ :host { display: block; }
+
+ .dialog-overlay {
+ display: none;
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.5); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
+ z-index: 10000; justify-content: center; align-items: center;
+ }
+ .dialog-overlay.open { display: flex; }
+
+ .dialog {
+ background: var(--rs-bg-surface, #1a1a2e);
+ border: 1px solid var(--rs-border, #2a2a4a);
+ border-radius: 16px; width: 560px; max-width: 95vw;
+ max-height: 80vh; display: flex; flex-direction: column;
+ box-shadow: 0 24px 80px rgba(0,0,0,0.5);
+ }
+
+ .dialog-header {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 16px 20px; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a);
+ }
+ .dialog-header h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--rs-text-primary, #e0e0e0); }
+ .dialog-close {
+ background: none; border: none; color: var(--rs-text-muted, #888);
+ font-size: 22px; cursor: pointer; padding: 0 4px; line-height: 1;
+ }
+ .dialog-close:hover { color: var(--rs-text-primary, #e0e0e0); }
+
+ .tab-bar {
+ display: flex; gap: 0; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a);
+ }
+ .tab {
+ flex: 1; padding: 10px; border: none; background: none;
+ color: var(--rs-text-secondary, #aaa); font-size: 13px; font-weight: 500;
+ cursor: pointer; transition: all 0.15s;
+ border-bottom: 2px solid transparent;
+ }
+ .tab.active {
+ color: var(--rs-primary, #6366f1);
+ border-bottom-color: var(--rs-primary, #6366f1);
+ }
+ .tab:hover { color: var(--rs-text-primary, #e0e0e0); }
+
+ .source-bar {
+ display: flex; gap: 4px; padding: 12px 16px; flex-wrap: wrap;
+ border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a);
+ }
+ .source-btn {
+ padding: 6px 12px; border-radius: 6px; border: 1px solid var(--rs-border, #2a2a4a);
+ background: transparent; color: var(--rs-text-secondary, #aaa);
+ font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px;
+ transition: all 0.15s;
+ }
+ .source-btn:hover { border-color: var(--rs-border-strong, #444); color: var(--rs-text-primary, #e0e0e0); }
+ .source-btn.active {
+ background: var(--rs-primary, #6366f1); color: #fff;
+ border-color: var(--rs-primary, #6366f1);
+ }
+
+ .dialog-body {
+ padding: 16px 20px; overflow-y: auto; flex: 1;
+ }
+
+ .upload-area {
+ border: 2px dashed var(--rs-border, #2a2a4a);
+ border-radius: 10px; padding: 24px; text-align: center;
+ margin-bottom: 16px; transition: border-color 0.15s, background 0.15s;
+ }
+ .upload-area.dragover {
+ border-color: var(--rs-primary, #6366f1);
+ background: rgba(99,102,241,0.05);
+ }
+ .upload-area p { margin: 0 0 8px; color: var(--rs-text-secondary, #aaa); font-size: 13px; }
+ .upload-hint { font-size: 11px !important; color: var(--rs-text-muted, #666) !important; margin-top: 8px !important; }
+
+ .form-row {
+ display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
+ }
+ .form-row label { font-size: 13px; color: var(--rs-text-secondary, #aaa); white-space: nowrap; }
+ .form-row select {
+ flex: 1; padding: 7px 10px; border-radius: 6px;
+ border: 1px solid var(--rs-border, #2a2a4a);
+ background: var(--rs-input-bg, #111); color: var(--rs-text-primary, #e0e0e0);
+ font-size: 13px;
+ }
+
+ .btn-primary {
+ width: 100%; padding: 10px; border-radius: 8px; border: none;
+ background: var(--rs-primary, #6366f1); color: #fff; font-weight: 600;
+ font-size: 13px; cursor: pointer; transition: background 0.15s;
+ }
+ .btn-primary:hover { background: var(--rs-primary-hover, #5558e6); }
+ .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
+
+ .btn-secondary {
+ padding: 8px 16px; border-radius: 6px;
+ border: 1px solid var(--rs-border, #2a2a4a);
+ background: transparent; color: var(--rs-text-primary, #e0e0e0);
+ font-size: 13px; cursor: pointer;
+ }
+ .btn-secondary:hover { border-color: var(--rs-border-strong, #444); }
+ .btn-sm { padding: 4px 10px; font-size: 11px; }
+
+ .connect-prompt {
+ text-align: center; padding: 24px;
+ }
+ .connect-prompt p { color: var(--rs-text-secondary, #aaa); margin-bottom: 16px; font-size: 13px; }
+
+ .page-list-header {
+ display: flex; justify-content: space-between; align-items: center;
+ margin-bottom: 8px;
+ }
+ .page-list-header span { font-size: 13px; color: var(--rs-text-secondary, #aaa); }
+
+ .page-list {
+ max-height: 200px; overflow-y: auto;
+ border: 1px solid var(--rs-border-subtle, #2a2a4a);
+ border-radius: 8px; margin-bottom: 14px;
+ }
+ .page-item {
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px;
+ cursor: pointer; transition: background 0.1s;
+ border-bottom: 1px solid var(--rs-border-subtle, #1a1a2e);
+ }
+ .page-item:last-child { border-bottom: none; }
+ .page-item:hover { background: rgba(255,255,255,0.03); }
+ .page-item input[type="checkbox"] { accent-color: var(--rs-primary, #6366f1); }
+ .page-icon {
+ width: 20px; height: 20px; font-size: 12px;
+ display: flex; align-items: center; justify-content: center;
+ background: var(--rs-bg-surface-raised, #222); border-radius: 4px;
+ color: var(--rs-text-muted, #888);
+ }
+ .page-title { font-size: 13px; color: var(--rs-text-primary, #e0e0e0); flex: 1; }
+
+ .empty-list {
+ padding: 20px; text-align: center; color: var(--rs-text-muted, #666); font-size: 12px;
+ }
+
+ .status-message {
+ margin-top: 12px; padding: 8px 12px; border-radius: 6px;
+ font-size: 12px; text-align: center;
+ }
+ .status-info { background: rgba(99,102,241,0.1); color: var(--rs-primary, #6366f1); }
+ .status-success { background: rgba(34,197,94,0.1); color: var(--rs-success, #22c55e); }
+ .status-error { background: rgba(239,68,68,0.1); color: var(--rs-error, #ef4444); }
+
+ .sync-summary { margin: 12px 0; }
+ .sync-empty { font-size: 12px; color: var(--rs-text-muted, #666); text-align: center; padding: 20px; }
+ .sync-count { font-size: 12px; color: var(--rs-text-secondary, #aaa); margin: 0 0 8px; }
+ .sync-list {
+ max-height: 200px; overflow-y: auto;
+ border: 1px solid var(--rs-border-subtle, #2a2a4a); border-radius: 8px;
+ margin-bottom: 14px;
+ }
+ .sync-item {
+ display: flex; align-items: center; gap: 8px; padding: 6px 12px;
+ border-bottom: 1px solid var(--rs-border-subtle, #1a1a2e); font-size: 12px;
+ }
+ .sync-item:last-child { border-bottom: none; }
+ .sync-source-badge {
+ padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600;
+ text-transform: uppercase; letter-spacing: 0.5px;
+ background: rgba(99,102,241,0.15); color: var(--rs-primary, #6366f1);
+ }
+ .sync-source-badge.notion { background: rgba(255,255,255,0.08); }
+ .sync-source-badge.google-docs { background: rgba(66,133,244,0.15); color: #4285f4; }
+ .sync-source-badge.obsidian { background: rgba(126,100,255,0.15); color: #7e64ff; }
+ .sync-source-badge.logseq { background: rgba(133,211,127,0.15); color: #85d37f; }
+ .sync-status {
+ font-size: 10px; padding: 2px 6px; border-radius: 4px;
+ }
+ .sync-status.synced { background: rgba(34,197,94,0.1); color: var(--rs-success, #22c55e); }
+ .sync-status.conflict { background: rgba(239,68,68,0.1); color: var(--rs-error, #ef4444); }
+ .sync-status.local-modified { background: rgba(250,204,21,0.1); color: #facc15; }
+ .sync-status.remote-modified { background: rgba(99,102,241,0.1); color: var(--rs-primary, #6366f1); }
+ .sync-time { color: var(--rs-text-muted, #666); margin-left: auto; font-size: 10px; }
+ .sync-upload { margin-top: 12px; }
+ `;
+ }
+}
+
+customElements.define('import-export-dialog', ImportExportDialog);
+
+export { ImportExportDialog };
diff --git a/modules/rdocs/components/notes.css b/modules/rdocs/components/notes.css
new file mode 100644
index 00000000..3b415915
--- /dev/null
+++ b/modules/rdocs/components/notes.css
@@ -0,0 +1,7 @@
+/* Notes module — dark theme (host-level styles) */
+folk-notes-app {
+ display: block;
+ min-height: 400px;
+ padding: 0;
+ position: relative;
+}
diff --git a/modules/rdocs/components/slash-command.ts b/modules/rdocs/components/slash-command.ts
new file mode 100644
index 00000000..d751e571
--- /dev/null
+++ b/modules/rdocs/components/slash-command.ts
@@ -0,0 +1,308 @@
+/**
+ * Slash command ProseMirror plugin for Tiptap.
+ *
+ * Detects '/' typed at the start of an empty block and shows a floating menu
+ * with block type options. Keyboard navigation: arrow keys + Enter + Escape.
+ */
+
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import type { Editor } from '@tiptap/core';
+
+/** Inline SVG icons for slash menu items (16×16, stroke-based, currentColor) */
+const SLASH_ICONS: Record = {
+ text: '',
+ heading1: '',
+ heading2: '',
+ heading3: '',
+ bulletList: '',
+ orderedList: '',
+ taskList: '',
+ codeBlock: '',
+ blockquote: '',
+ horizontalRule: '',
+ image: '',
+};
+
+export interface SlashMenuItem {
+ title: string;
+ icon: string;
+ description: string;
+ command: (editor: Editor) => void;
+}
+
+export const SLASH_ITEMS: SlashMenuItem[] = [
+ {
+ title: 'Text',
+ icon: 'text',
+ description: 'Plain paragraph text',
+ command: (e) => e.chain().focus().setParagraph().run(),
+ },
+ {
+ title: 'Heading 1',
+ icon: 'heading1',
+ description: 'Large section heading',
+ command: (e) => e.chain().focus().setHeading({ level: 1 }).run(),
+ },
+ {
+ title: 'Heading 2',
+ icon: 'heading2',
+ description: 'Medium section heading',
+ command: (e) => e.chain().focus().setHeading({ level: 2 }).run(),
+ },
+ {
+ title: 'Heading 3',
+ icon: 'heading3',
+ description: 'Small section heading',
+ command: (e) => e.chain().focus().setHeading({ level: 3 }).run(),
+ },
+ {
+ title: 'Bullet List',
+ icon: 'bulletList',
+ description: 'Unordered bullet list',
+ command: (e) => e.chain().focus().toggleBulletList().run(),
+ },
+ {
+ title: 'Numbered List',
+ icon: 'orderedList',
+ description: 'Ordered numbered list',
+ command: (e) => e.chain().focus().toggleOrderedList().run(),
+ },
+ {
+ title: 'Task List',
+ icon: 'taskList',
+ description: 'Checklist with checkboxes',
+ command: (e) => e.chain().focus().toggleTaskList().run(),
+ },
+ {
+ title: 'Code Block',
+ icon: 'codeBlock',
+ description: 'Syntax-highlighted code block',
+ command: (e) => e.chain().focus().toggleCodeBlock().run(),
+ },
+ {
+ title: 'Blockquote',
+ icon: 'blockquote',
+ description: 'Indented quote block',
+ command: (e) => e.chain().focus().toggleBlockquote().run(),
+ },
+ {
+ title: 'Horizontal Rule',
+ icon: 'horizontalRule',
+ description: 'Visual divider line',
+ command: (e) => e.chain().focus().setHorizontalRule().run(),
+ },
+ {
+ title: 'Image',
+ icon: 'image',
+ description: 'Insert an image from URL',
+ command: (e) => {
+ const event = new CustomEvent('slash-insert-image', { bubbles: true, composed: true });
+ (e.view.dom as HTMLElement).dispatchEvent(event);
+ },
+ },
+ {
+ title: 'Code Snippet',
+ icon: 'codeBlock',
+ description: 'Create a new code snippet note',
+ command: (e) => {
+ const event = new CustomEvent('slash-create-typed-note', { bubbles: true, composed: true, detail: { type: 'CODE' } });
+ (e.view.dom as HTMLElement).dispatchEvent(event);
+ },
+ },
+ {
+ title: 'Voice Note',
+ icon: 'text',
+ description: 'Create a new voice recording note',
+ command: (e) => {
+ const event = new CustomEvent('slash-create-typed-note', { bubbles: true, composed: true, detail: { type: 'AUDIO' } });
+ (e.view.dom as HTMLElement).dispatchEvent(event);
+ },
+ },
+];
+
+const pluginKey = new PluginKey('slashCommand');
+
+export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot): Plugin {
+ let menuEl: HTMLDivElement | null = null;
+ let selectedIndex = 0;
+ let filteredItems: SlashMenuItem[] = [];
+ let query = '';
+ let active = false;
+ let triggerPos = -1;
+
+ function show(view: EditorView) {
+ if (!menuEl) {
+ menuEl = document.createElement('div');
+ menuEl.className = 'slash-menu';
+ shadowRoot.appendChild(menuEl);
+ }
+ active = true;
+ selectedIndex = 0;
+ query = '';
+ filteredItems = SLASH_ITEMS;
+ updateMenuContent();
+ positionMenu(view);
+ menuEl.style.display = 'block';
+ }
+
+ function hide() {
+ active = false;
+ query = '';
+ triggerPos = -1;
+ if (menuEl) menuEl.style.display = 'none';
+ }
+
+ function updateMenuContent() {
+ if (!menuEl) return;
+ menuEl.innerHTML = `` +
+ filteredItems
+ .map(
+ (item, i) =>
+ ``,
+ )
+ .join('');
+
+ // Click handlers
+ menuEl.querySelectorAll('.slash-menu-item').forEach((el) => {
+ el.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ const idx = parseInt((el as HTMLElement).dataset.index || '0');
+ executeItem(idx);
+ });
+ el.addEventListener('pointerenter', () => {
+ selectedIndex = parseInt((el as HTMLElement).dataset.index || '0');
+ updateMenuContent();
+ });
+ });
+ }
+
+ function positionMenu(view: EditorView) {
+ if (!menuEl) return;
+ const { from } = view.state.selection;
+ const coords = view.coordsAtPos(from);
+ const shadowHost = shadowRoot.host as HTMLElement;
+ const hostRect = shadowHost.getBoundingClientRect();
+
+ let left = coords.left - hostRect.left;
+ const menuWidth = 240;
+ const maxLeft = window.innerWidth - menuWidth - 8 - hostRect.left;
+ left = Math.max(4, Math.min(left, maxLeft));
+ menuEl.style.left = `${left}px`;
+ menuEl.style.top = `${coords.bottom - hostRect.top + 4}px`;
+ }
+
+ function filterItems() {
+ const q = query.toLowerCase();
+ filteredItems = q
+ ? SLASH_ITEMS.filter(
+ (item) =>
+ item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q),
+ )
+ : SLASH_ITEMS;
+ selectedIndex = Math.min(selectedIndex, Math.max(0, filteredItems.length - 1));
+ updateMenuContent();
+ }
+
+ function executeItem(index: number) {
+ const item = filteredItems[index];
+ if (!item) return;
+
+ // Delete the slash + query text
+ const { state } = editor.view;
+ const tr = state.tr.delete(triggerPos, state.selection.from);
+ editor.view.dispatch(tr);
+
+ item.command(editor);
+ hide();
+ }
+
+ return new Plugin({
+ key: pluginKey,
+ props: {
+ handleKeyDown(view, event) {
+ if (active) {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ selectedIndex = (selectedIndex + 1) % filteredItems.length;
+ updateMenuContent();
+ return true;
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length;
+ updateMenuContent();
+ return true;
+ }
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ executeItem(selectedIndex);
+ return true;
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ hide();
+ return true;
+ }
+ if (event.key === 'Backspace') {
+ if (query.length === 0) {
+ // Backspace deletes the '/', close menu
+ hide();
+ return false; // let ProseMirror handle the deletion
+ }
+ query = query.slice(0, -1);
+ filterItems();
+ return false; // let ProseMirror handle the deletion
+ }
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
+ query += event.key;
+ filterItems();
+ if (filteredItems.length === 0) {
+ hide();
+ }
+ return false; // let ProseMirror insert the character
+ }
+ }
+ return false;
+ },
+
+ handleTextInput(view, from, to, text) {
+ if (text === '/' && !active) {
+ // Check if cursor is at start of an empty block
+ const { $from } = view.state.selection;
+ const isAtStart = $from.parentOffset === 0;
+ const isEmpty = $from.parent.textContent === '';
+ if (isAtStart && isEmpty) {
+ triggerPos = from;
+ // Defer show to after the '/' is inserted
+ setTimeout(() => show(view), 0);
+ }
+ }
+ return false;
+ },
+ },
+
+ view() {
+ return {
+ update(view) {
+ if (active && menuEl) {
+ positionMenu(view);
+ }
+ },
+ destroy() {
+ if (menuEl) {
+ menuEl.remove();
+ menuEl = null;
+ }
+ },
+ };
+ },
+ });
+}
diff --git a/modules/rdocs/components/suggestion-marks.ts b/modules/rdocs/components/suggestion-marks.ts
new file mode 100644
index 00000000..b4a3678e
--- /dev/null
+++ b/modules/rdocs/components/suggestion-marks.ts
@@ -0,0 +1,101 @@
+/**
+ * TipTap mark extensions for track-changes suggestions.
+ *
+ * SuggestionInsert: wraps text that was inserted in suggesting mode (green underline).
+ * SuggestionDelete: wraps text that was marked for deletion in suggesting mode (red strikethrough).
+ *
+ * Both marks are stored in the Yjs document and sync in real-time.
+ * Accept/reject logic is handled by the suggestion-plugin.
+ */
+
+import { Mark, mergeAttributes } from '@tiptap/core';
+
+export const SuggestionInsertMark = Mark.create({
+ name: 'suggestionInsert',
+
+ addAttributes() {
+ return {
+ suggestionId: { default: null },
+ authorId: { default: null },
+ authorName: { default: null },
+ createdAt: { default: null },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-suggestion-insert]',
+ getAttrs: (el) => {
+ const element = el as HTMLElement;
+ return {
+ suggestionId: element.getAttribute('data-suggestion-id'),
+ authorId: element.getAttribute('data-author-id'),
+ authorName: element.getAttribute('data-author-name'),
+ createdAt: Number(element.getAttribute('data-created-at')) || null,
+ };
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'span',
+ mergeAttributes({
+ class: 'suggestion-insert',
+ 'data-suggestion-insert': '',
+ 'data-suggestion-id': HTMLAttributes.suggestionId,
+ 'data-author-id': HTMLAttributes.authorId,
+ 'data-author-name': HTMLAttributes.authorName,
+ 'data-created-at': HTMLAttributes.createdAt,
+ }),
+ 0,
+ ];
+ },
+});
+
+export const SuggestionDeleteMark = Mark.create({
+ name: 'suggestionDelete',
+
+ addAttributes() {
+ return {
+ suggestionId: { default: null },
+ authorId: { default: null },
+ authorName: { default: null },
+ createdAt: { default: null },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-suggestion-delete]',
+ getAttrs: (el) => {
+ const element = el as HTMLElement;
+ return {
+ suggestionId: element.getAttribute('data-suggestion-id'),
+ authorId: element.getAttribute('data-author-id'),
+ authorName: element.getAttribute('data-author-name'),
+ createdAt: Number(element.getAttribute('data-created-at')) || null,
+ };
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'span',
+ mergeAttributes({
+ class: 'suggestion-delete',
+ 'data-suggestion-delete': '',
+ 'data-suggestion-id': HTMLAttributes.suggestionId,
+ 'data-author-id': HTMLAttributes.authorId,
+ 'data-author-name': HTMLAttributes.authorName,
+ 'data-created-at': HTMLAttributes.createdAt,
+ }),
+ 0,
+ ];
+ },
+});
diff --git a/modules/rdocs/components/suggestion-plugin.ts b/modules/rdocs/components/suggestion-plugin.ts
new file mode 100644
index 00000000..8fa42ae7
--- /dev/null
+++ b/modules/rdocs/components/suggestion-plugin.ts
@@ -0,0 +1,366 @@
+/**
+ * ProseMirror plugin that intercepts user input in "suggesting" mode
+ * and converts edits into track-changes marks instead of direct mutations.
+ *
+ * In suggesting mode:
+ * - Typed text → inserted with `suggestionInsert` mark (green underline)
+ * - Backspace/Delete → text NOT deleted, marked with `suggestionDelete` (red strikethrough)
+ * - Select + type → old text gets `suggestionDelete`, new text gets `suggestionInsert`
+ * - Paste → same as select + type
+ *
+ * Uses ProseMirror props (handleTextInput, handleKeyDown, handlePaste) rather
+ * than filterTransaction for reliability.
+ */
+
+import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
+import type { Slice } from '@tiptap/pm/model';
+import type { Editor } from '@tiptap/core';
+
+const pluginKey = new PluginKey('suggestion-plugin');
+
+function makeSuggestionId(): string {
+ return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+// ── Typing session tracker ──
+// Reuses the same suggestionId while the user types consecutively,
+// so an entire typed word/phrase becomes ONE suggestion in the sidebar.
+let _sessionSuggestionId: string | null = null;
+let _sessionNextPos: number = -1; // the position where the next char is expected
+
+function getOrCreateSessionId(insertPos: number): string {
+ if (_sessionSuggestionId && insertPos === _sessionNextPos) {
+ return _sessionSuggestionId;
+ }
+ _sessionSuggestionId = makeSuggestionId();
+ return _sessionSuggestionId;
+}
+
+function advanceSession(id: string, nextPos: number): void {
+ _sessionSuggestionId = id;
+ _sessionNextPos = nextPos;
+}
+
+function resetSession(): void {
+ _sessionSuggestionId = null;
+ _sessionNextPos = -1;
+}
+
+/**
+ * Create the suggestion mode ProseMirror plugin.
+ * @param getSuggesting - callback that returns current suggesting mode state
+ * @param getAuthor - callback that returns { authorId, authorName }
+ */
+export function createSuggestionPlugin(
+ getSuggesting: () => boolean,
+ getAuthor: () => { authorId: string; authorName: string },
+): Plugin {
+ return new Plugin({
+ key: pluginKey,
+
+ props: {
+ /** Intercept typed text — insert with suggestionInsert mark. */
+ handleTextInput(view: EditorView, from: number, to: number, text: string): boolean {
+ if (!getSuggesting()) return false;
+
+ const { state } = view;
+ const { authorId, authorName } = getAuthor();
+ // Reuse session ID for consecutive typing at the same position
+ const suggestionId = (from !== to)
+ ? makeSuggestionId() // replacement → new suggestion
+ : getOrCreateSessionId(from); // plain insert → batch with session
+ const tr = state.tr;
+
+ // If there's a selection (replacement), mark the selected text as deleted
+ if (from !== to) {
+ // Check if selected text is all suggestionInsert from the same author
+ // → if so, just replace it (editing your own suggestion)
+ const ownInsert = isOwnSuggestionInsert(state, from, to, authorId);
+ if (ownInsert) {
+ tr.replaceWith(from, to, state.schema.text(text, [
+ state.schema.marks.suggestionInsert.create({
+ suggestionId: ownInsert, authorId, authorName, createdAt: Date.now(),
+ }),
+ ]));
+ tr.setMeta('suggestion-applied', true);
+ view.dispatch(tr);
+ return true;
+ }
+
+ const deleteMark = state.schema.marks.suggestionDelete.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.addMark(from, to, deleteMark);
+ }
+
+ // Insert the new text with insert mark after the (marked-for-deletion) text
+ const insertPos = to;
+ const insertMark = state.schema.marks.suggestionInsert.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.insert(insertPos, state.schema.text(text, [insertMark]));
+ tr.setMeta('suggestion-applied', true);
+
+ // Place cursor after the inserted text
+ const newCursorPos = insertPos + text.length;
+ tr.setSelection(TextSelection.create(tr.doc, newCursorPos));
+
+ view.dispatch(tr);
+ advanceSession(suggestionId, newCursorPos);
+ return true;
+ },
+
+ /** Intercept Backspace/Delete — mark text as deleted instead of removing. */
+ handleKeyDown(view: EditorView, event: KeyboardEvent): boolean {
+ if (!getSuggesting()) return false;
+ if (event.key !== 'Backspace' && event.key !== 'Delete') return false;
+ resetSession(); // break typing session on delete actions
+
+ const { state } = view;
+ const { from, to, empty } = state.selection;
+ const { authorId, authorName } = getAuthor();
+
+ let deleteFrom = from;
+ let deleteTo = to;
+
+ if (empty) {
+ if (event.key === 'Backspace') {
+ if (from === 0) return true;
+ deleteFrom = from - 1;
+ deleteTo = from;
+ } else {
+ if (from >= state.doc.content.size) return true;
+ deleteFrom = from;
+ deleteTo = from + 1;
+ }
+
+ // Don't cross block boundaries
+ const $from = state.doc.resolve(deleteFrom);
+ const $to = state.doc.resolve(deleteTo);
+ if ($from.parent !== $to.parent) return true;
+ }
+
+ // Backspace/delete on own suggestionInsert → actually remove it
+ const ownInsert = isOwnSuggestionInsert(state, deleteFrom, deleteTo, authorId);
+ if (ownInsert) {
+ const tr = state.tr;
+ tr.delete(deleteFrom, deleteTo);
+ tr.setMeta('suggestion-applied', true);
+ view.dispatch(tr);
+ return true;
+ }
+
+ // Already marked as suggestionDelete → skip past it
+ if (isAlreadySuggestionDelete(state, deleteFrom, deleteTo)) {
+ const tr = state.tr;
+ const newPos = event.key === 'Backspace' ? deleteFrom : deleteTo;
+ tr.setSelection(TextSelection.create(state.doc, newPos));
+ view.dispatch(tr);
+ return true;
+ }
+
+ // Mark the text as deleted
+ const suggestionId = makeSuggestionId();
+ const tr = state.tr;
+ const deleteMark = state.schema.marks.suggestionDelete.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.addMark(deleteFrom, deleteTo, deleteMark);
+ tr.setMeta('suggestion-applied', true);
+
+ if (event.key === 'Backspace') {
+ tr.setSelection(TextSelection.create(tr.doc, deleteFrom));
+ } else {
+ tr.setSelection(TextSelection.create(tr.doc, deleteTo));
+ }
+
+ view.dispatch(tr);
+ return true;
+ },
+
+ /** Intercept paste — insert pasted text as a suggestion. */
+ handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean {
+ if (!getSuggesting()) return false;
+ resetSession(); // paste is a discrete action, break typing session
+
+ const { state } = view;
+ const { from, to } = state.selection;
+ const { authorId, authorName } = getAuthor();
+ const suggestionId = makeSuggestionId();
+ const tr = state.tr;
+
+ // Mark selected text as deleted
+ if (from !== to) {
+ const deleteMark = state.schema.marks.suggestionDelete.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.addMark(from, to, deleteMark);
+ }
+
+ // Extract text from slice and insert with mark
+ let pastedText = '';
+ slice.content.forEach((node: any) => {
+ if (pastedText) pastedText += '\n';
+ pastedText += node.textContent;
+ });
+
+ if (pastedText) {
+ const insertPos = to;
+ const insertMark = state.schema.marks.suggestionInsert.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.insert(insertPos, state.schema.text(pastedText, [insertMark]));
+ tr.setMeta('suggestion-applied', true);
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + pastedText.length));
+ }
+
+ view.dispatch(tr);
+ return true;
+ },
+ },
+ });
+}
+
+/** Check if the range is entirely covered by suggestionInsert marks from the same author. */
+function isOwnSuggestionInsert(
+ state: { doc: any; schema: any },
+ from: number,
+ to: number,
+ authorId: string,
+): string | null {
+ let allOwn = true;
+ let foundId: string | null = null;
+ state.doc.nodesBetween(from, to, (node: any) => {
+ if (!node.isText) return;
+ const mark = node.marks.find(
+ (m: any) => m.type.name === 'suggestionInsert' && m.attrs.authorId === authorId
+ );
+ if (!mark) {
+ allOwn = false;
+ } else if (!foundId) {
+ foundId = mark.attrs.suggestionId;
+ }
+ });
+ return allOwn && foundId ? foundId : null;
+}
+
+/** Check if the range is already entirely covered by suggestionDelete marks. */
+function isAlreadySuggestionDelete(state: { doc: any }, from: number, to: number): boolean {
+ let allDeleted = true;
+ state.doc.nodesBetween(from, to, (node: any) => {
+ if (!node.isText) return;
+ if (!node.marks.find((m: any) => m.type.name === 'suggestionDelete')) allDeleted = false;
+ });
+ return allDeleted;
+}
+
+
+/**
+ * Accept a suggestion: insertions stay, deletions are removed.
+ */
+export function acceptSuggestion(editor: Editor, suggestionId: string) {
+ const { state } = editor;
+ const { tr } = state;
+
+ // Collect ranges first, apply from end→start to preserve positions
+ const deleteRanges: [number, number][] = [];
+ const insertRanges: [number, number, any][] = [];
+
+ state.doc.descendants((node: any, pos: number) => {
+ if (!node.isText) return;
+ const deleteMark = node.marks.find(
+ (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
+ );
+ if (deleteMark) {
+ deleteRanges.push([pos, pos + node.nodeSize]);
+ return;
+ }
+ const insertMark = node.marks.find(
+ (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
+ );
+ if (insertMark) {
+ insertRanges.push([pos, pos + node.nodeSize, insertMark]);
+ }
+ });
+
+ for (const [from, to] of deleteRanges.sort((a, b) => b[0] - a[0])) {
+ tr.delete(from, to);
+ }
+ for (const [from, to, mark] of insertRanges.sort((a, b) => b[0] - a[0])) {
+ tr.removeMark(from, to, mark);
+ }
+
+ if (tr.docChanged) {
+ tr.setMeta('suggestion-accept', true);
+ editor.view.dispatch(tr);
+ }
+}
+
+/**
+ * Reject a suggestion: insertions are removed, deletions stay.
+ */
+export function rejectSuggestion(editor: Editor, suggestionId: string) {
+ const { state } = editor;
+ const { tr } = state;
+
+ const insertRanges: [number, number][] = [];
+ const deleteRanges: [number, number, any][] = [];
+
+ state.doc.descendants((node: any, pos: number) => {
+ if (!node.isText) return;
+ const insertMark = node.marks.find(
+ (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
+ );
+ if (insertMark) {
+ insertRanges.push([pos, pos + node.nodeSize]);
+ return;
+ }
+ const deleteMark = node.marks.find(
+ (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
+ );
+ if (deleteMark) {
+ deleteRanges.push([pos, pos + node.nodeSize, deleteMark]);
+ }
+ });
+
+ for (const [from, to] of insertRanges.sort((a, b) => b[0] - a[0])) {
+ tr.delete(from, to);
+ }
+ for (const [from, to, mark] of deleteRanges.sort((a, b) => b[0] - a[0])) {
+ tr.removeMark(from, to, mark);
+ }
+
+ if (tr.docChanged) {
+ tr.setMeta('suggestion-reject', true);
+ editor.view.dispatch(tr);
+ }
+}
+
+/** Accept all suggestions in the document. */
+export function acceptAllSuggestions(editor: Editor) {
+ const ids = new Set();
+ editor.state.doc.descendants((node: any) => {
+ if (!node.isText) return;
+ for (const mark of node.marks) {
+ if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
+ ids.add(mark.attrs.suggestionId);
+ }
+ }
+ });
+ for (const id of ids) acceptSuggestion(editor, id);
+}
+
+/** Reject all suggestions in the document. */
+export function rejectAllSuggestions(editor: Editor) {
+ const ids = new Set();
+ editor.state.doc.descendants((node: any) => {
+ if (!node.isText) return;
+ for (const mark of node.marks) {
+ if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
+ ids.add(mark.attrs.suggestionId);
+ }
+ }
+ });
+ for (const id of ids) rejectSuggestion(editor, id);
+}
diff --git a/modules/rdocs/converters/evernote.ts b/modules/rdocs/converters/evernote.ts
new file mode 100644
index 00000000..0aefb68e
--- /dev/null
+++ b/modules/rdocs/converters/evernote.ts
@@ -0,0 +1,236 @@
+/**
+ * Evernote ENEX → rNotes converter.
+ *
+ * Import: Parse .enex XML (ENML — strict HTML subset inside )
+ * Convert ENML → markdown via Turndown.
+ * Extract base64 attachments, save to /data/files/uploads/.
+ * File-based import (.enex), no auth needed.
+ */
+
+import TurndownService from 'turndown';
+import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
+import { registerConverter, hashContent } from './index';
+import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
+import type { NoteItem } from '../schemas';
+
+const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
+
+// Custom Turndown rules for ENML-specific elements
+turndown.addRule('enMedia', {
+ filter: (node) => node.nodeName === 'EN-MEDIA',
+ replacement: (_content, node) => {
+ const el = node as Element;
+ const hash = el.getAttribute('hash') || '';
+ const type = el.getAttribute('type') || '';
+ if (type.startsWith('image/')) {
+ return ``;
+ }
+ return `[attachment](resource:${hash})`;
+ },
+});
+
+turndown.addRule('enTodo', {
+ filter: (node) => node.nodeName === 'EN-TODO',
+ replacement: (_content, node) => {
+ const el = node as Element;
+ const checked = el.getAttribute('checked') === 'true';
+ return checked ? '[x] ' : '[ ] ';
+ },
+});
+
+/** Simple XML tag content extractor (avoids needing a full DOM parser on server). */
+function extractTagContent(xml: string, tagName: string): string[] {
+ const results: string[] = [];
+ const openTag = `<${tagName}`;
+ const closeTag = `${tagName}>`;
+ let pos = 0;
+
+ while (true) {
+ const start = xml.indexOf(openTag, pos);
+ if (start === -1) break;
+
+ // Find end of opening tag (handles attributes)
+ const tagEnd = xml.indexOf('>', start);
+ if (tagEnd === -1) break;
+
+ const end = xml.indexOf(closeTag, tagEnd);
+ if (end === -1) break;
+
+ results.push(xml.substring(tagEnd + 1, end));
+ pos = end + closeTag.length;
+ }
+
+ return results;
+}
+
+/** Extract a single tag's text content. */
+function extractSingleTag(xml: string, tagName: string): string {
+ const results = extractTagContent(xml, tagName);
+ return results[0]?.trim() || '';
+}
+
+/** Extract attribute value from a tag. */
+function extractAttribute(xml: string, attrName: string): string {
+ const match = xml.match(new RegExp(`${attrName}="([^"]*)"`, 'i'));
+ return match?.[1] || '';
+}
+
+/** Parse a single element from ENEX. */
+function parseNote(noteXml: string): {
+ title: string;
+ content: string;
+ tags: string[];
+ created?: string;
+ updated?: string;
+ resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[];
+} {
+ const title = extractSingleTag(noteXml, 'title') || 'Untitled';
+
+ // Extract ENML content (inside CDATA)
+ let enml = extractSingleTag(noteXml, 'content');
+ // Strip CDATA wrapper if present
+ enml = enml.replace(/^\s*\s*$/, '');
+
+ const tags: string[] = [];
+ const tagMatches = extractTagContent(noteXml, 'tag');
+ for (const t of tagMatches) {
+ tags.push(t.trim().toLowerCase().replace(/\s+/g, '-'));
+ }
+
+ const created = extractSingleTag(noteXml, 'created');
+ const updated = extractSingleTag(noteXml, 'updated');
+
+ // Extract resources (attachments)
+ const resources: { hash: string; mime: string; data: Uint8Array; filename?: string }[] = [];
+ const resourceBlocks = extractTagContent(noteXml, 'resource');
+ for (const resXml of resourceBlocks) {
+ const mime = extractSingleTag(resXml, 'mime');
+ const b64Data = extractSingleTag(resXml, 'data');
+ const encoding = extractAttribute(resXml, 'encoding') || 'base64';
+
+ // Extract recognition hash or compute from data
+ let hash = '';
+ const recognition = extractSingleTag(resXml, 'recognition');
+ if (recognition) {
+ // Try to get hash from recognition XML
+ const hashMatch = recognition.match(/objID="([^"]+)"/);
+ if (hashMatch) hash = hashMatch[1];
+ }
+
+ // Extract resource attributes
+ const resAttrs = extractSingleTag(resXml, 'resource-attributes');
+ const filename = resAttrs ? extractSingleTag(resAttrs, 'file-name') : undefined;
+
+ if (b64Data && encoding === 'base64') {
+ try {
+ // Decode base64
+ const cleaned = b64Data.replace(/\s/g, '');
+ const binary = atob(cleaned);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+
+ // Compute MD5-like hash for matching en-media tags
+ if (!hash) {
+ hash = simpleHash(bytes);
+ }
+
+ resources.push({ hash, mime, data: bytes, filename });
+ } catch { /* skip malformed base64 */ }
+ }
+ }
+
+ return { title, content: enml, tags, created, updated, resources };
+}
+
+/** Simple hash for resource matching when recognition hash is missing. */
+function simpleHash(data: Uint8Array): string {
+ let h = 0;
+ for (let i = 0; i < Math.min(data.length, 1024); i++) {
+ h = ((h << 5) - h) + data[i];
+ h |= 0;
+ }
+ return Math.abs(h).toString(16);
+}
+
+const evernoteConverter: NoteConverter = {
+ id: 'evernote',
+ name: 'Evernote',
+ requiresAuth: false,
+
+ async import(input: ImportInput): Promise {
+ if (!input.fileData) {
+ throw new Error('Evernote import requires an .enex file');
+ }
+
+ const enexXml = new TextDecoder().decode(input.fileData);
+ const noteBlocks = extractTagContent(enexXml, 'note');
+
+ if (noteBlocks.length === 0) {
+ return { notes: [], notebookTitle: 'Evernote Import', warnings: ['No notes found in ENEX file'] };
+ }
+
+ const notes: ConvertedNote[] = [];
+ const warnings: string[] = [];
+
+ for (const noteXml of noteBlocks) {
+ try {
+ const parsed = parseNote(noteXml);
+
+ // Build resource hash→filename map for en-media replacement
+ const resourceMap = new Map();
+ for (const res of parsed.resources) {
+ const ext = res.mime.includes('jpeg') || res.mime.includes('jpg') ? 'jpg'
+ : res.mime.includes('png') ? 'png'
+ : res.mime.includes('gif') ? 'gif'
+ : res.mime.includes('webp') ? 'webp'
+ : res.mime.includes('pdf') ? 'pdf'
+ : 'bin';
+ const fname = res.filename || `evernote-${res.hash}.${ext}`;
+ resourceMap.set(res.hash, { filename: fname, data: res.data, mimeType: res.mime });
+ }
+
+ // Convert ENML to markdown
+ let markdown = turndown.turndown(parsed.content);
+
+ // Resolve resource: references to actual file paths
+ const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
+ markdown = markdown.replace(/resource:([a-f0-9]+)/g, (_match, hash) => {
+ const res = resourceMap.get(hash);
+ if (res) {
+ attachments.push(res);
+ return `/data/files/uploads/${res.filename}`;
+ }
+ return `resource:${hash}`;
+ });
+
+ const tiptapJson = markdownToTiptap(markdown);
+ const contentPlain = extractPlainTextFromTiptap(tiptapJson);
+
+ notes.push({
+ title: parsed.title,
+ content: tiptapJson,
+ contentPlain,
+ markdown,
+ tags: parsed.tags,
+ attachments: attachments.length > 0 ? attachments : undefined,
+ sourceRef: {
+ source: 'evernote',
+ externalId: `enex:${parsed.title}`,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(markdown),
+ },
+ });
+ } catch (err) {
+ warnings.push(`Failed to parse note: ${(err as Error).message}`);
+ }
+ }
+
+ return { notes, notebookTitle: 'Evernote Import', warnings };
+ },
+
+ async export(): Promise {
+ throw new Error('Evernote export is not supported — use Evernote\'s native import');
+ },
+};
+
+registerConverter(evernoteConverter);
diff --git a/modules/rdocs/converters/file-import.ts b/modules/rdocs/converters/file-import.ts
new file mode 100644
index 00000000..0b9baf52
--- /dev/null
+++ b/modules/rdocs/converters/file-import.ts
@@ -0,0 +1,171 @@
+/**
+ * Generic file import for rNotes.
+ *
+ * Handles direct import of individual files:
+ * - .md / .txt → parse as markdown/text
+ * - .html → convert via Turndown
+ * - .jpg / .png / .webp / .gif → create IMAGE note with stored file
+ *
+ * All produce ConvertedNote with sourceRef.source = 'manual'.
+ */
+
+import TurndownService from 'turndown';
+import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
+import { hashContent } from './index';
+import type { ConvertedNote } from './index';
+
+const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
+
+/** Dispatch file import by extension / MIME type. */
+export function importFile(
+ filename: string,
+ data: Uint8Array,
+ mimeType?: string,
+): ConvertedNote {
+ const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
+ const textContent = () => new TextDecoder().decode(data);
+
+ if (ext === '.md' || ext === '.markdown') {
+ return importMarkdownFile(filename, textContent());
+ }
+ if (ext === '.txt') {
+ return importTextFile(filename, textContent());
+ }
+ if (ext === '.html' || ext === '.htm') {
+ return importHtmlFile(filename, textContent());
+ }
+ if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'].includes(ext)) {
+ return importImageFile(filename, data, mimeType || guessMime(ext));
+ }
+
+ // Default: treat as text
+ try {
+ return importTextFile(filename, textContent());
+ } catch {
+ // Binary file — store as FILE note
+ return importBinaryFile(filename, data, mimeType || 'application/octet-stream');
+ }
+}
+
+/** Import a markdown file. */
+export function importMarkdownFile(filename: string, content: string): ConvertedNote {
+ const title = titleFromFilename(filename);
+ const tiptapJson = markdownToTiptap(content);
+ const contentPlain = extractPlainTextFromTiptap(tiptapJson);
+
+ return {
+ title,
+ content: tiptapJson,
+ contentPlain,
+ markdown: content,
+ tags: [],
+ sourceRef: {
+ source: 'manual',
+ externalId: `file:${filename}`,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(content),
+ },
+ };
+}
+
+/** Import a plain text file — wrap as simple note. */
+export function importTextFile(filename: string, content: string): ConvertedNote {
+ const title = titleFromFilename(filename);
+ const tiptapJson = markdownToTiptap(content);
+ const contentPlain = content;
+
+ return {
+ title,
+ content: tiptapJson,
+ contentPlain,
+ markdown: content,
+ tags: [],
+ sourceRef: {
+ source: 'manual',
+ externalId: `file:${filename}`,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(content),
+ },
+ };
+}
+
+/** Import an HTML file — convert via Turndown. */
+export function importHtmlFile(filename: string, html: string): ConvertedNote {
+ const title = titleFromFilename(filename);
+ const markdown = turndown.turndown(html);
+ const tiptapJson = markdownToTiptap(markdown);
+ const contentPlain = extractPlainTextFromTiptap(tiptapJson);
+
+ return {
+ title,
+ content: tiptapJson,
+ contentPlain,
+ markdown,
+ tags: [],
+ sourceRef: {
+ source: 'manual',
+ externalId: `file:${filename}`,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(markdown),
+ },
+ };
+}
+
+/** Import an image file — create IMAGE note with stored file reference. */
+export function importImageFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote {
+ const title = titleFromFilename(filename);
+ const md = ``;
+ const tiptapJson = markdownToTiptap(md);
+
+ return {
+ title,
+ content: tiptapJson,
+ contentPlain: title,
+ markdown: md,
+ tags: [],
+ type: 'IMAGE',
+ attachments: [{ filename, data, mimeType }],
+ sourceRef: {
+ source: 'manual',
+ externalId: `file:${filename}`,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(String(data.length)),
+ },
+ };
+}
+
+/** Import a binary/unknown file as a FILE note. */
+function importBinaryFile(filename: string, data: Uint8Array, mimeType: string): ConvertedNote {
+ const title = titleFromFilename(filename);
+ const md = `[${filename}](/data/files/uploads/${filename})`;
+ const tiptapJson = markdownToTiptap(md);
+
+ return {
+ title,
+ content: tiptapJson,
+ contentPlain: title,
+ markdown: md,
+ tags: [],
+ type: 'FILE',
+ attachments: [{ filename, data, mimeType }],
+ sourceRef: {
+ source: 'manual',
+ externalId: `file:${filename}`,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(String(data.length)),
+ },
+ };
+}
+
+function titleFromFilename(filename: string): string {
+ return filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
+}
+
+function guessMime(ext: string): string {
+ const mimes: Record = {
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
+ '.bmp': 'image/bmp',
+ };
+ return mimes[ext] || 'application/octet-stream';
+}
diff --git a/modules/rdocs/converters/google-docs.ts b/modules/rdocs/converters/google-docs.ts
new file mode 100644
index 00000000..231991c8
--- /dev/null
+++ b/modules/rdocs/converters/google-docs.ts
@@ -0,0 +1,329 @@
+/**
+ * Google Docs ↔ rNotes converter.
+ *
+ * Import: Google Docs API structural JSON → markdown → TipTap JSON
+ * Export: TipTap JSON → Google Docs batch update requests
+ */
+
+import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
+import { registerConverter, hashContent } from './index';
+import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
+import type { NoteItem } from '../schemas';
+
+const DOCS_API_BASE = 'https://docs.googleapis.com/v1';
+const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3';
+
+/** Fetch from Google APIs with auth. */
+async function googleFetch(url: string, token: string, opts: RequestInit = {}): Promise {
+ const res = await fetch(url, {
+ ...opts,
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ ...opts.headers,
+ },
+ });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Google API error ${res.status}: ${body}`);
+ }
+ return res.json();
+}
+
+/** Convert Google Docs structural elements to markdown. */
+function structuralElementToMarkdown(element: any, inlineObjects?: Record): string {
+ if (element.paragraph) {
+ return paragraphToMarkdown(element.paragraph, inlineObjects);
+ }
+ if (element.table) {
+ return tableToMarkdown(element.table);
+ }
+ if (element.sectionBreak) {
+ return '\n---\n';
+ }
+ return '';
+}
+
+/** Convert a Google Docs paragraph to markdown (with inline image resolution context). */
+function paragraphToMarkdown(paragraph: any, inlineObjects?: Record): string {
+ const style = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
+ const elements = paragraph.elements || [];
+ let text = '';
+
+ for (const el of elements) {
+ if (el.textRun) {
+ text += textRunToMarkdown(el.textRun);
+ } else if (el.inlineObjectElement) {
+ const objectId = el.inlineObjectElement.inlineObjectId;
+ const obj = inlineObjects?.[objectId];
+ if (obj) {
+ const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties;
+ const contentUri = imageProps?.contentUri;
+ if (contentUri) {
+ text += ``;
+ } else {
+ text += ``;
+ }
+ } else {
+ text += ``;
+ }
+ }
+ }
+
+ // Remove trailing newline that Google Docs adds to every paragraph
+ text = text.replace(/\n$/, '');
+
+ // Apply heading styles
+ switch (style) {
+ case 'HEADING_1': return `# ${text}`;
+ case 'HEADING_2': return `## ${text}`;
+ case 'HEADING_3': return `### ${text}`;
+ case 'HEADING_4': return `#### ${text}`;
+ case 'HEADING_5': return `##### ${text}`;
+ case 'HEADING_6': return `###### ${text}`;
+ default: return text;
+ }
+}
+
+/** Convert a Google Docs TextRun to markdown with formatting. */
+function textRunToMarkdown(textRun: any): string {
+ let text = textRun.content || '';
+ const style = textRun.textStyle || {};
+
+ // Don't apply formatting to whitespace-only text
+ if (!text.trim()) return text;
+
+ if (style.bold) text = `**${text.trim()}** `;
+ if (style.italic) text = `*${text.trim()}* `;
+ if (style.strikethrough) text = `~~${text.trim()}~~ `;
+ if (style.link?.url) text = `[${text.trim()}](${style.link.url})`;
+
+ return text;
+}
+
+/** Convert a Google Docs table to markdown. */
+function tableToMarkdown(table: any): string {
+ const rows = table.tableRows || [];
+ if (rows.length === 0) return '';
+
+ const mdRows: string[] = [];
+ for (let r = 0; r < rows.length; r++) {
+ const cells = rows[r].tableCells || [];
+ const cellTexts = cells.map((cell: any) => {
+ const content = (cell.content || [])
+ .map((el: any) => structuralElementToMarkdown(el))
+ .join('')
+ .trim();
+ return content || ' ';
+ });
+ mdRows.push(`| ${cellTexts.join(' | ')} |`);
+
+ // Separator after header
+ if (r === 0) {
+ mdRows.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
+ }
+ }
+
+ return mdRows.join('\n');
+}
+
+/** Convert TipTap markdown to Google Docs batchUpdate requests. */
+function markdownToGoogleDocsRequests(md: string): any[] {
+ const requests: any[] = [];
+ const lines = md.split('\n');
+ let index = 1; // Google Docs indexes start at 1
+
+ for (const line of lines) {
+ if (!line && lines.indexOf(line) < lines.length - 1) {
+ // Empty line → insert newline
+ requests.push({
+ insertText: { location: { index }, text: '\n' },
+ });
+ index += 1;
+ continue;
+ }
+
+ // Headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
+ if (headingMatch) {
+ const level = headingMatch[1].length;
+ const text = headingMatch[2] + '\n';
+ requests.push({
+ insertText: { location: { index }, text },
+ });
+ requests.push({
+ updateParagraphStyle: {
+ range: { startIndex: index, endIndex: index + text.length },
+ paragraphStyle: { namedStyleType: `HEADING_${level}` },
+ fields: 'namedStyleType',
+ },
+ });
+ index += text.length;
+ continue;
+ }
+
+ // Regular text
+ const text = line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '') + '\n';
+ requests.push({
+ insertText: { location: { index }, text },
+ });
+
+ // Apply bullet/list styles
+ if (line.match(/^[-*]\s+/)) {
+ requests.push({
+ createParagraphBullets: {
+ range: { startIndex: index, endIndex: index + text.length },
+ bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE',
+ },
+ });
+ } else if (line.match(/^\d+\.\s+/)) {
+ requests.push({
+ createParagraphBullets: {
+ range: { startIndex: index, endIndex: index + text.length },
+ bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN',
+ },
+ });
+ }
+
+ index += text.length;
+ }
+
+ return requests;
+}
+
+const googleDocsConverter: NoteConverter = {
+ id: 'google-docs',
+ name: 'Google Docs',
+ requiresAuth: true,
+
+ async import(input: ImportInput): Promise {
+ const token = input.accessToken;
+ if (!token) throw new Error('Google Docs import requires an access token. Connect your Google account first.');
+ if (!input.pageIds || input.pageIds.length === 0) {
+ throw new Error('No Google Docs selected for import');
+ }
+
+ const notes: ConvertedNote[] = [];
+ const warnings: string[] = [];
+
+ for (const docId of input.pageIds) {
+ try {
+ // Fetch document
+ const doc = await googleFetch(`${DOCS_API_BASE}/documents/${docId}`, token);
+ const title = doc.title || 'Untitled';
+
+ // Convert structural elements to markdown, passing inlineObjects for image resolution
+ const body = doc.body?.content || [];
+ const inlineObjects = doc.inlineObjects || {};
+ const mdParts: string[] = [];
+
+ for (const element of body) {
+ const md = structuralElementToMarkdown(element, inlineObjects);
+ if (md) mdParts.push(md);
+ }
+
+ const markdown = mdParts.join('\n\n');
+ const tiptapJson = markdownToTiptap(markdown);
+ const contentPlain = extractPlainTextFromTiptap(tiptapJson);
+
+ // Download inline images as attachments
+ const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
+ for (const [objectId, obj] of Object.entries(inlineObjects) as [string, any][]) {
+ const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties;
+ const contentUri = imageProps?.contentUri;
+ if (contentUri) {
+ try {
+ const res = await fetch(contentUri, {
+ headers: { 'Authorization': `Bearer ${token}` },
+ });
+ if (res.ok) {
+ const data = new Uint8Array(await res.arrayBuffer());
+ const ct = res.headers.get('content-type') || 'image/png';
+ const ext = ct.includes('jpeg') || ct.includes('jpg') ? 'jpg' : ct.includes('gif') ? 'gif' : ct.includes('webp') ? 'webp' : 'png';
+ attachments.push({ filename: `gdocs-${objectId}.${ext}`, data, mimeType: ct });
+ }
+ } catch { /* skip failed image downloads */ }
+ }
+ }
+
+ notes.push({
+ title,
+ content: tiptapJson,
+ contentPlain,
+ markdown,
+ tags: [],
+ attachments: attachments.length > 0 ? attachments : undefined,
+ sourceRef: {
+ source: 'google-docs',
+ externalId: docId,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(markdown),
+ },
+ });
+ } catch (err) {
+ warnings.push(`Failed to import doc ${docId}: ${(err as Error).message}`);
+ }
+ }
+
+ return { notes, notebookTitle: 'Google Docs Import', warnings };
+ },
+
+ async export(notes: NoteItem[], opts: ExportOptions): Promise {
+ const token = opts.accessToken;
+ if (!token) throw new Error('Google Docs export requires an access token. Connect your Google account first.');
+
+ const warnings: string[] = [];
+ const results: any[] = [];
+
+ for (const note of notes) {
+ try {
+ // Create a new Google Doc
+ const doc = await googleFetch(`${DOCS_API_BASE}/documents`, token, {
+ method: 'POST',
+ body: JSON.stringify({ title: note.title }),
+ });
+
+ // Convert to markdown
+ let md: string;
+ if (note.contentFormat === 'tiptap-json' && note.content) {
+ md = tiptapToMarkdown(note.content);
+ } else {
+ md = note.content?.replace(/<[^>]*>/g, '').trim() || '';
+ }
+
+ // Build batch update requests
+ const requests = markdownToGoogleDocsRequests(md);
+
+ if (requests.length > 0) {
+ await googleFetch(`${DOCS_API_BASE}/documents/${doc.documentId}:batchUpdate`, token, {
+ method: 'POST',
+ body: JSON.stringify({ requests }),
+ });
+ }
+
+ // Move to folder if parentId specified
+ if (opts.parentId) {
+ await googleFetch(
+ `${DRIVE_API_BASE}/files/${doc.documentId}?addParents=${opts.parentId}`,
+ token,
+ { method: 'PATCH', body: JSON.stringify({}) }
+ );
+ }
+
+ results.push({ noteId: note.id, googleDocId: doc.documentId });
+ } catch (err) {
+ warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`);
+ }
+ }
+
+ const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings }));
+ return {
+ data,
+ filename: 'google-docs-export-results.json',
+ mimeType: 'application/json',
+ };
+ },
+};
+
+registerConverter(googleDocsConverter);
diff --git a/modules/rdocs/converters/index.ts b/modules/rdocs/converters/index.ts
new file mode 100644
index 00000000..abdf2c99
--- /dev/null
+++ b/modules/rdocs/converters/index.ts
@@ -0,0 +1,57 @@
+/**
+ * Converter registry and shared types for rDocs import/export.
+ *
+ * All source-specific converters implement NoteConverter.
+ * ConvertedNote is the intermediate format between external sources and NoteItem.
+ */
+
+import type { NoteItem, SourceRef } from '../schemas';
+
+// Re-export types from shared converters
+export type { ConvertedNote, ImportResult, ExportResult, NoteConverter, ImportInput, ExportOptions } from '../../../shared/converters/types';
+
+// ── Shared utilities ──
+
+/** Hash content for conflict detection (shared across all converters). */
+export function hashContent(content: string): string {
+ let hash = 0;
+ for (let i = 0; i < content.length; i++) {
+ const char = content.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash |= 0;
+ }
+ return Math.abs(hash).toString(36);
+}
+
+// ── Converter registry ──
+
+import type { NoteConverter } from '../../../shared/converters/types';
+
+const converters = new Map();
+
+export function registerConverter(converter: NoteConverter): void {
+ converters.set(converter.id, converter);
+}
+
+export function getConverter(id: string): NoteConverter | undefined {
+ ensureConvertersLoaded();
+ return converters.get(id);
+}
+
+export function getAllConverters(): NoteConverter[] {
+ ensureConvertersLoaded();
+ return Array.from(converters.values());
+}
+
+// ── Lazy-load converters ──
+let _loaded = false;
+export function ensureConvertersLoaded(): void {
+ if (_loaded) return;
+ _loaded = true;
+ require('./obsidian');
+ require('./logseq');
+ require('./notion');
+ require('./google-docs');
+ require('./evernote');
+ require('./roam');
+}
diff --git a/modules/rdocs/converters/logseq.ts b/modules/rdocs/converters/logseq.ts
new file mode 100644
index 00000000..02b7af48
--- /dev/null
+++ b/modules/rdocs/converters/logseq.ts
@@ -0,0 +1,9 @@
+/**
+ * Logseq converter — re-exports from shared and registers with rDocs converter system.
+ */
+import { logseqConverter } from '../../../shared/converters/logseq';
+import { registerConverter } from './index';
+
+export { logseqConverter };
+
+registerConverter(logseqConverter);
diff --git a/modules/rdocs/converters/markdown-tiptap.ts b/modules/rdocs/converters/markdown-tiptap.ts
new file mode 100644
index 00000000..4cf11150
--- /dev/null
+++ b/modules/rdocs/converters/markdown-tiptap.ts
@@ -0,0 +1,9 @@
+/**
+ * Re-export from shared location.
+ * Markdown ↔ TipTap conversion is now shared across modules.
+ */
+export {
+ markdownToTiptap,
+ tiptapToMarkdown,
+ extractPlainTextFromTiptap,
+} from '../../../shared/markdown-tiptap';
diff --git a/modules/rdocs/converters/notion.ts b/modules/rdocs/converters/notion.ts
new file mode 100644
index 00000000..567485d6
--- /dev/null
+++ b/modules/rdocs/converters/notion.ts
@@ -0,0 +1,462 @@
+/**
+ * Notion ↔ rNotes converter.
+ *
+ * Import: Notion API block types → markdown → TipTap JSON
+ * Export: TipTap JSON → Notion block format, creates pages via API
+ */
+
+import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
+import { registerConverter, hashContent } from './index';
+import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
+import type { NoteItem } from '../schemas';
+// Note: imports from './index' and './markdown-tiptap' resolve to rdocs-local copies
+
+const NOTION_API_VERSION = '2022-06-28';
+const NOTION_API_BASE = 'https://api.notion.com/v1';
+
+/** Rate-limited fetch for Notion API (3 req/s). */
+let lastRequestTime = 0;
+async function notionFetch(url: string, opts: RequestInit & { token: string }): Promise {
+ const now = Date.now();
+ const elapsed = now - lastRequestTime;
+ if (elapsed < 334) { // ~3 req/s
+ await new Promise(r => setTimeout(r, 334 - elapsed));
+ }
+ lastRequestTime = Date.now();
+
+ const res = await fetch(url, {
+ ...opts,
+ headers: {
+ 'Authorization': `Bearer ${opts.token}`,
+ 'Notion-Version': NOTION_API_VERSION,
+ 'Content-Type': 'application/json',
+ ...opts.headers,
+ },
+ });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Notion API error ${res.status}: ${body}`);
+ }
+ return res.json();
+}
+
+/** Convert a Notion rich text array to markdown. */
+function richTextToMarkdown(richText: any[]): string {
+ if (!richText) return '';
+ return richText.map((rt: any) => {
+ let text = rt.plain_text || '';
+ const ann = rt.annotations || {};
+ if (ann.code) text = `\`${text}\``;
+ if (ann.bold) text = `**${text}**`;
+ if (ann.italic) text = `*${text}*`;
+ if (ann.strikethrough) text = `~~${text}~~`;
+ if (rt.href) text = `[${text}](${rt.href})`;
+ return text;
+ }).join('');
+}
+
+/** Convert a Notion block to markdown. */
+function blockToMarkdown(block: any, indent = ''): string {
+ const type = block.type;
+ const data = block[type];
+ if (!data) return '';
+
+ switch (type) {
+ case 'paragraph':
+ return `${indent}${richTextToMarkdown(data.rich_text)}`;
+
+ case 'heading_1':
+ return `# ${richTextToMarkdown(data.rich_text)}`;
+
+ case 'heading_2':
+ return `## ${richTextToMarkdown(data.rich_text)}`;
+
+ case 'heading_3':
+ return `### ${richTextToMarkdown(data.rich_text)}`;
+
+ case 'bulleted_list_item':
+ return `${indent}- ${richTextToMarkdown(data.rich_text)}`;
+
+ case 'numbered_list_item':
+ return `${indent}1. ${richTextToMarkdown(data.rich_text)}`;
+
+ case 'to_do': {
+ const checked = data.checked ? 'x' : ' ';
+ return `${indent}- [${checked}] ${richTextToMarkdown(data.rich_text)}`;
+ }
+
+ case 'toggle':
+ return `${indent}- ${richTextToMarkdown(data.rich_text)}`;
+
+ case 'code': {
+ const lang = data.language || '';
+ const code = richTextToMarkdown(data.rich_text);
+ return `\`\`\`${lang}\n${code}\n\`\`\``;
+ }
+
+ case 'quote':
+ return `> ${richTextToMarkdown(data.rich_text)}`;
+
+ case 'callout': {
+ const icon = data.icon?.emoji || '';
+ return `> ${icon} ${richTextToMarkdown(data.rich_text)}`;
+ }
+
+ case 'divider':
+ return '---';
+
+ case 'image': {
+ const url = data.file?.url || data.external?.url || '';
+ const caption = data.caption ? richTextToMarkdown(data.caption) : '';
+ return ``;
+ }
+
+ case 'bookmark':
+ return `[${data.url}](${data.url})`;
+
+ case 'table': {
+ // Tables are handled via children blocks
+ return '';
+ }
+
+ case 'table_row': {
+ const cells = (data.cells || []).map((cell: any[]) => richTextToMarkdown(cell));
+ return `| ${cells.join(' | ')} |`;
+ }
+
+ case 'child_page':
+ return `**${data.title}** (sub-page)`;
+
+ case 'child_database':
+ return `**${data.title}** (database)`;
+
+ default:
+ // Try to extract rich_text if available
+ if (data.rich_text) {
+ return richTextToMarkdown(data.rich_text);
+ }
+ return '';
+ }
+}
+
+/** Convert TipTap markdown content to Notion blocks. */
+function markdownToNotionBlocks(md: string): any[] {
+ const lines = md.split('\n');
+ const blocks: any[] = [];
+
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // Empty line
+ if (!line.trim()) {
+ i++;
+ continue;
+ }
+
+ // Headings
+ const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
+ if (headingMatch) {
+ const level = headingMatch[1].length;
+ const text = headingMatch[2];
+ const type = `heading_${level}` as string;
+ blocks.push({
+ type,
+ [type]: {
+ rich_text: [{ type: 'text', text: { content: text } }],
+ },
+ });
+ i++;
+ continue;
+ }
+
+ // Code blocks
+ if (line.startsWith('```')) {
+ const lang = line.slice(3).trim();
+ const codeLines: string[] = [];
+ i++;
+ while (i < lines.length && !lines[i].startsWith('```')) {
+ codeLines.push(lines[i]);
+ i++;
+ }
+ blocks.push({
+ type: 'code',
+ code: {
+ rich_text: [{ type: 'text', text: { content: codeLines.join('\n') } }],
+ language: lang || 'plain text',
+ },
+ });
+ i++; // skip closing ```
+ continue;
+ }
+
+ // Blockquotes
+ if (line.startsWith('> ')) {
+ blocks.push({
+ type: 'quote',
+ quote: {
+ rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
+ },
+ });
+ i++;
+ continue;
+ }
+
+ // Task list items
+ const taskMatch = line.match(/^- \[([ x])\]\s+(.+)/);
+ if (taskMatch) {
+ blocks.push({
+ type: 'to_do',
+ to_do: {
+ rich_text: [{ type: 'text', text: { content: taskMatch[2] } }],
+ checked: taskMatch[1] === 'x',
+ },
+ });
+ i++;
+ continue;
+ }
+
+ // Bullet list items
+ if (line.match(/^[-*]\s+/)) {
+ blocks.push({
+ type: 'bulleted_list_item',
+ bulleted_list_item: {
+ rich_text: [{ type: 'text', text: { content: line.replace(/^[-*]\s+/, '') } }],
+ },
+ });
+ i++;
+ continue;
+ }
+
+ // Numbered list items
+ if (line.match(/^\d+\.\s+/)) {
+ blocks.push({
+ type: 'numbered_list_item',
+ numbered_list_item: {
+ rich_text: [{ type: 'text', text: { content: line.replace(/^\d+\.\s+/, '') } }],
+ },
+ });
+ i++;
+ continue;
+ }
+
+ // Horizontal rule
+ if (line.match(/^---+$/)) {
+ blocks.push({ type: 'divider', divider: {} });
+ i++;
+ continue;
+ }
+
+ // Default: paragraph
+ blocks.push({
+ type: 'paragraph',
+ paragraph: {
+ rich_text: [{ type: 'text', text: { content: line } }],
+ },
+ });
+ i++;
+ }
+
+ return blocks;
+}
+
+const notionConverter: NoteConverter = {
+ id: 'notion',
+ name: 'Notion',
+ requiresAuth: true,
+
+ async import(input: ImportInput): Promise {
+ const token = input.accessToken;
+ if (!token) throw new Error('Notion import requires an access token. Connect your Notion account first.');
+ if (!input.pageIds || input.pageIds.length === 0) {
+ throw new Error('No Notion pages selected for import');
+ }
+
+ const notes: ConvertedNote[] = [];
+ const warnings: string[] = [];
+
+ for (const pageId of input.pageIds) {
+ try {
+ // Fetch page metadata
+ const page = await notionFetch(`${NOTION_API_BASE}/pages/${pageId}`, {
+ method: 'GET',
+ token,
+ });
+
+ // Extract title
+ const titleProp = page.properties?.title || page.properties?.Name;
+ const title = titleProp?.title?.[0]?.plain_text || 'Untitled';
+
+ // Fetch all blocks (paginated)
+ const allBlocks: any[] = [];
+ let cursor: string | undefined;
+ do {
+ const url = `${NOTION_API_BASE}/blocks/${pageId}/children?page_size=100${cursor ? `&start_cursor=${cursor}` : ''}`;
+ const result = await notionFetch(url, { method: 'GET', token });
+ allBlocks.push(...(result.results || []));
+ cursor = result.has_more ? result.next_cursor : undefined;
+ } while (cursor);
+
+ // Handle table rows specially
+ const mdParts: string[] = [];
+ let inTable = false;
+ let tableRowIndex = 0;
+
+ for (const block of allBlocks) {
+ if (block.type === 'table') {
+ inTable = true;
+ tableRowIndex = 0;
+ // Fetch table children
+ const tableChildren = await notionFetch(
+ `${NOTION_API_BASE}/blocks/${block.id}/children?page_size=100`,
+ { method: 'GET', token }
+ );
+ for (const child of tableChildren.results || []) {
+ const rowMd = blockToMarkdown(child);
+ mdParts.push(rowMd);
+ if (tableRowIndex === 0) {
+ // Add separator after header
+ const cellCount = (child.table_row?.cells || []).length;
+ mdParts.push(`| ${Array(cellCount).fill('---').join(' | ')} |`);
+ }
+ tableRowIndex++;
+ }
+ inTable = false;
+ } else {
+ const md = blockToMarkdown(block);
+ if (md) mdParts.push(md);
+ }
+ }
+
+ const markdown = mdParts.join('\n\n');
+ const tiptapJson = markdownToTiptap(markdown);
+ const contentPlain = extractPlainTextFromTiptap(tiptapJson);
+
+ // Extract tags from Notion properties
+ const tags: string[] = [];
+ if (page.properties) {
+ for (const [key, value] of Object.entries(page.properties) as [string, any][]) {
+ if (value.type === 'multi_select') {
+ tags.push(...(value.multi_select || []).map((s: any) => s.name.toLowerCase()));
+ } else if (value.type === 'select' && value.select) {
+ tags.push(value.select.name.toLowerCase());
+ }
+ }
+ }
+
+ notes.push({
+ title,
+ content: tiptapJson,
+ contentPlain,
+ markdown,
+ tags: [...new Set(tags)],
+ sourceRef: {
+ source: 'notion',
+ externalId: pageId,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(markdown),
+ },
+ });
+
+ // Recursively import child pages if requested
+ if (input.recursive) {
+ for (const block of allBlocks) {
+ if (block.type === 'child_page') {
+ try {
+ const childResult = await this.import({
+ ...input,
+ pageIds: [block.id],
+ recursive: true,
+ });
+ notes.push(...childResult.notes);
+ warnings.push(...childResult.warnings);
+ } catch (err) {
+ warnings.push(`Failed to import child page "${block.child_page?.title}": ${(err as Error).message}`);
+ }
+ }
+ }
+ }
+ } catch (err) {
+ warnings.push(`Failed to import page ${pageId}: ${(err as Error).message}`);
+ }
+ }
+
+ return { notes, notebookTitle: 'Notion Import', warnings };
+ },
+
+ async export(notes: NoteItem[], opts: ExportOptions): Promise {
+ const token = opts.accessToken;
+ if (!token) throw new Error('Notion export requires an access token. Connect your Notion account first.');
+
+ const warnings: string[] = [];
+ const results: any[] = [];
+
+ for (const note of notes) {
+ try {
+ // Convert to markdown first
+ let md: string;
+ if (note.contentFormat === 'tiptap-json' && note.content) {
+ md = tiptapToMarkdown(note.content);
+ } else {
+ md = note.content?.replace(/<[^>]*>/g, '').trim() || '';
+ }
+
+ // Convert markdown to Notion blocks
+ const blocks = markdownToNotionBlocks(md);
+
+ // Create page in Notion
+ // If parentId is provided, create as child page; otherwise create in workspace root
+ const parent = opts.parentId
+ ? { page_id: opts.parentId }
+ : { type: 'page_id' as const, page_id: opts.parentId || '' };
+
+ // For workspace-level pages, we need a database or page parent
+ // Default to creating standalone pages
+ const createBody: any = {
+ parent: opts.parentId
+ ? { page_id: opts.parentId }
+ : { type: 'workspace', workspace: true },
+ properties: {
+ title: {
+ title: [{ type: 'text', text: { content: note.title } }],
+ },
+ },
+ children: blocks.slice(0, 100), // Notion limit: 100 blocks per request
+ };
+
+ const page = await notionFetch(`${NOTION_API_BASE}/pages`, {
+ method: 'POST',
+ token,
+ body: JSON.stringify(createBody),
+ });
+
+ results.push({ noteId: note.id, notionPageId: page.id });
+
+ // If more than 100 blocks, append in batches
+ if (blocks.length > 100) {
+ for (let i = 100; i < blocks.length; i += 100) {
+ const batch = blocks.slice(i, i + 100);
+ await notionFetch(`${NOTION_API_BASE}/blocks/${page.id}/children`, {
+ method: 'PATCH',
+ token,
+ body: JSON.stringify({ children: batch }),
+ });
+ }
+ }
+ } catch (err) {
+ warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`);
+ }
+ }
+
+ // Return results as JSON since we don't produce a file
+ const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings }));
+ return {
+ data,
+ filename: 'notion-export-results.json',
+ mimeType: 'application/json',
+ };
+ },
+};
+
+registerConverter(notionConverter);
diff --git a/modules/rdocs/converters/obsidian.ts b/modules/rdocs/converters/obsidian.ts
new file mode 100644
index 00000000..eb8bb618
--- /dev/null
+++ b/modules/rdocs/converters/obsidian.ts
@@ -0,0 +1,9 @@
+/**
+ * Obsidian converter — re-exports from shared and registers with rDocs converter system.
+ */
+import { obsidianConverter } from '../../../shared/converters/obsidian';
+import { registerConverter } from './index';
+
+export { obsidianConverter };
+
+registerConverter(obsidianConverter);
diff --git a/modules/rdocs/converters/roam.ts b/modules/rdocs/converters/roam.ts
new file mode 100644
index 00000000..5a828b8a
--- /dev/null
+++ b/modules/rdocs/converters/roam.ts
@@ -0,0 +1,171 @@
+/**
+ * Roam Research JSON → rNotes converter.
+ *
+ * Import: Roam JSON export ([{ title, children: [{ string, children }] }])
+ * Converts recursive tree → indented markdown bullets.
+ * Handles Roam syntax: ((block-refs)), {{embed}}, ^^highlight^^, [[page refs]]
+ */
+
+import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
+import { registerConverter, hashContent } from './index';
+import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
+import type { NoteItem } from '../schemas';
+
+interface RoamBlock {
+ string?: string;
+ uid?: string;
+ children?: RoamBlock[];
+ 'create-time'?: number;
+ 'edit-time'?: number;
+}
+
+interface RoamPage {
+ title: string;
+ uid?: string;
+ children?: RoamBlock[];
+ 'create-time'?: number;
+ 'edit-time'?: number;
+}
+
+/** Convert Roam block tree to indented markdown. */
+function blocksToMarkdown(blocks: RoamBlock[], depth = 0): string {
+ const lines: string[] = [];
+
+ for (const block of blocks) {
+ if (!block.string && (!block.children || block.children.length === 0)) continue;
+
+ if (block.string) {
+ const indent = ' '.repeat(depth);
+ const text = convertRoamSyntax(block.string);
+ lines.push(`${indent}- ${text}`);
+ }
+
+ if (block.children && block.children.length > 0) {
+ lines.push(blocksToMarkdown(block.children, depth + 1));
+ }
+ }
+
+ return lines.join('\n');
+}
+
+/** Convert Roam-specific syntax to standard markdown. */
+function convertRoamSyntax(text: string): string {
+ // [[page references]] → [page references](page references)
+ text = text.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
+
+ // ((block refs)) → (ref)
+ text = text.replace(/\(\(([a-zA-Z0-9_-]+)\)\)/g, '(ref:$1)');
+
+ // {{embed: ((ref))}} → (embedded ref)
+ text = text.replace(/\{\{embed:\s*\(\(([^)]+)\)\)\}\}/g, '> (embedded: $1)');
+
+ // {{[[TODO]]}} and {{[[DONE]]}}
+ text = text.replace(/\{\{\[\[TODO\]\]\}\}/g, '- [ ]');
+ text = text.replace(/\{\{\[\[DONE\]\]\}\}/g, '- [x]');
+
+ // ^^highlight^^ → ==highlight== (or just **highlight**)
+ text = text.replace(/\^\^([^^]+)\^\^/g, '**$1**');
+
+ // **bold** already valid markdown
+ // __italic__ → *italic*
+ text = text.replace(/__([^_]+)__/g, '*$1*');
+
+ return text;
+}
+
+/** Extract tags from Roam page content (inline [[refs]] and #tags). */
+function extractRoamTags(blocks: RoamBlock[]): string[] {
+ const tags = new Set();
+
+ function walk(items: RoamBlock[]) {
+ for (const block of items) {
+ if (block.string) {
+ // [[page refs]]
+ const pageRefs = block.string.match(/\[\[([^\]]+)\]\]/g);
+ if (pageRefs) {
+ for (const ref of pageRefs) {
+ const tag = ref.slice(2, -2).toLowerCase().replace(/\s+/g, '-');
+ if (tag.length <= 30) tags.add(tag); // Skip very long refs
+ }
+ }
+ // #tags
+ const hashTags = block.string.match(/#([a-zA-Z0-9_-]+)/g);
+ if (hashTags) {
+ for (const t of hashTags) tags.add(t.slice(1).toLowerCase());
+ }
+ }
+ if (block.children) walk(block.children);
+ }
+ }
+
+ walk(blocks);
+ return Array.from(tags).slice(0, 20); // Cap tags
+}
+
+const roamConverter: NoteConverter = {
+ id: 'roam',
+ name: 'Roam Research',
+ requiresAuth: false,
+
+ async import(input: ImportInput): Promise {
+ if (!input.fileData) {
+ throw new Error('Roam import requires a JSON file');
+ }
+
+ const jsonStr = new TextDecoder().decode(input.fileData);
+ let pages: RoamPage[];
+ try {
+ pages = JSON.parse(jsonStr);
+ } catch {
+ throw new Error('Invalid Roam Research JSON format');
+ }
+
+ if (!Array.isArray(pages)) {
+ throw new Error('Expected a JSON array of Roam pages');
+ }
+
+ const notes: ConvertedNote[] = [];
+ const warnings: string[] = [];
+
+ for (const page of pages) {
+ try {
+ if (!page.title) continue;
+
+ const children = page.children || [];
+ const markdown = children.length > 0
+ ? blocksToMarkdown(children)
+ : '';
+
+ if (!markdown.trim() && children.length === 0) continue; // Skip empty pages
+
+ const tiptapJson = markdownToTiptap(markdown);
+ const contentPlain = extractPlainTextFromTiptap(tiptapJson);
+ const tags = extractRoamTags(children);
+
+ notes.push({
+ title: page.title,
+ content: tiptapJson,
+ contentPlain,
+ markdown,
+ tags,
+ sourceRef: {
+ source: 'roam',
+ externalId: page.uid || page.title,
+ lastSyncedAt: Date.now(),
+ contentHash: hashContent(markdown),
+ },
+ });
+ } catch (err) {
+ warnings.push(`Failed to parse page "${page.title}": ${(err as Error).message}`);
+ }
+ }
+
+ return { notes, notebookTitle: 'Roam Research Import', warnings };
+ },
+
+ async export(): Promise {
+ throw new Error('Roam Research export is not supported — use Roam\'s native import');
+ },
+};
+
+registerConverter(roamConverter);
diff --git a/modules/rdocs/converters/sync.ts b/modules/rdocs/converters/sync.ts
new file mode 100644
index 00000000..c5eaf52a
--- /dev/null
+++ b/modules/rdocs/converters/sync.ts
@@ -0,0 +1,207 @@
+/**
+ * Sync service for rNotes — handles re-fetching, conflict detection,
+ * and merging for imported notes.
+ *
+ * Conflict policy:
+ * - Remote-only-changed → auto-update
+ * - Local-only-changed → keep local
+ * - Both changed → mark conflict (stores remote version in conflictContent)
+ */
+
+import type { NoteItem, SourceRef } from '../schemas';
+import { getConverter, hashContent } from './index';
+import { tiptapToMarkdown } from './markdown-tiptap';
+
+export interface SyncResult {
+ action: 'unchanged' | 'updated' | 'conflict' | 'error';
+ remoteHash?: string;
+ error?: string;
+ updatedContent?: string; // TipTap JSON of remote content
+ updatedPlain?: string;
+ updatedMarkdown?: string;
+}
+
+/** Sync a single Notion note by re-fetching from API. */
+export async function syncNotionNote(note: NoteItem, token: string): Promise {
+ if (!note.sourceRef || note.sourceRef.source !== 'notion') {
+ return { action: 'error', error: 'Note is not from Notion' };
+ }
+
+ try {
+ const converter = getConverter('notion');
+ if (!converter) return { action: 'error', error: 'Notion converter not available' };
+
+ const result = await converter.import({
+ pageIds: [note.sourceRef.externalId],
+ accessToken: token,
+ });
+
+ if (result.notes.length === 0) {
+ return { action: 'error', error: 'Could not fetch page from Notion' };
+ }
+
+ const remote = result.notes[0];
+ const remoteHash = remote.sourceRef.contentHash || '';
+ const localHash = note.sourceRef.contentHash || '';
+
+ // Compare hashes
+ if (remoteHash === localHash) {
+ return { action: 'unchanged' };
+ }
+
+ // Check if local was modified since last sync
+ const currentLocalHash = hashContent(
+ note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content
+ );
+ const localModified = currentLocalHash !== localHash;
+
+ if (!localModified) {
+ // Only remote changed — auto-update
+ return {
+ action: 'updated',
+ remoteHash,
+ updatedContent: remote.content,
+ updatedPlain: remote.contentPlain,
+ updatedMarkdown: remote.markdown,
+ };
+ }
+
+ // Both changed — conflict
+ return {
+ action: 'conflict',
+ remoteHash,
+ updatedContent: remote.content,
+ updatedPlain: remote.contentPlain,
+ updatedMarkdown: remote.markdown,
+ };
+ } catch (err) {
+ return { action: 'error', error: (err as Error).message };
+ }
+}
+
+/** Sync a single Google Docs note by re-fetching from API. */
+export async function syncGoogleDocsNote(note: NoteItem, token: string): Promise {
+ if (!note.sourceRef || note.sourceRef.source !== 'google-docs') {
+ return { action: 'error', error: 'Note is not from Google Docs' };
+ }
+
+ try {
+ const converter = getConverter('google-docs');
+ if (!converter) return { action: 'error', error: 'Google Docs converter not available' };
+
+ const result = await converter.import({
+ pageIds: [note.sourceRef.externalId],
+ accessToken: token,
+ });
+
+ if (result.notes.length === 0) {
+ return { action: 'error', error: 'Could not fetch doc from Google Docs' };
+ }
+
+ const remote = result.notes[0];
+ const remoteHash = remote.sourceRef.contentHash || '';
+ const localHash = note.sourceRef.contentHash || '';
+
+ if (remoteHash === localHash) {
+ return { action: 'unchanged' };
+ }
+
+ const currentLocalHash = hashContent(
+ note.contentFormat === 'tiptap-json' ? tiptapToMarkdown(note.content) : note.content
+ );
+ const localModified = currentLocalHash !== localHash;
+
+ if (!localModified) {
+ return {
+ action: 'updated',
+ remoteHash,
+ updatedContent: remote.content,
+ updatedPlain: remote.contentPlain,
+ updatedMarkdown: remote.markdown,
+ };
+ }
+
+ return {
+ action: 'conflict',
+ remoteHash,
+ updatedContent: remote.content,
+ updatedPlain: remote.contentPlain,
+ updatedMarkdown: remote.markdown,
+ };
+ } catch (err) {
+ return { action: 'error', error: (err as Error).message };
+ }
+}
+
+/** Sync file-based notes by re-parsing a ZIP and matching by externalId. */
+export async function syncFileBasedNotes(
+ notes: NoteItem[],
+ zipData: Uint8Array,
+ source: 'obsidian' | 'logseq',
+): Promise