/** * Notes — Surface Entry Point (v0.8.0) * * Markdown notes surface using the SDK: * sw.api.ext('notes') — scoped API client * sw.ui.* — primitive components * sw.shell.Topbar — navigation bar * window.CM — CodeMirror 6 bundle (optional) */ (async function () { 'use strict'; var mount = document.getElementById('extension-mount'); if (!mount) return; var base = window.__BASE__ || ''; var ver = window.__VERSION__ || '0'; // ── Dynamic Script Loader ──────────────────── function _loadScript(src) { return new Promise(function(resolve, reject) { if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) { resolve(); return; } var s = document.createElement('script'); s.src = base + src; s.onload = resolve; s.onerror = function() { resolve(); }; // Non-fatal document.head.appendChild(s); }); } // ── Boot SDK ─────────────────────────────── try { if (!window.preact) { var { h, render } = await import(base + '/js/sw/vendor/preact.module.js'); var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js'); var htmModule = await import(base + '/js/sw/vendor/htm.module.js'); window.preact = { h, render }; window.hooks = hooksModule; window.html = htmModule.default.bind(h); } var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver); await sdk.boot(); } catch (e) { mount.innerHTML = '

SDK boot failed: ' + e.message + '

'; return; } // ── Load CM6 bundle (optional — falls back to textarea) ── await _loadScript('/vendor/codemirror/codemirror.bundle.js?v=' + ver); var hasCM = !!(window.CM && window.CM.noteEditor); var { html } = window; var { useState, useEffect, useCallback, useRef, useMemo } = hooks; var { render } = preact; // ── SDK modules ──────────────────────────── var api = sw.api.ext('notes'); var { Button, Spinner } = sw.ui; var Topbar = sw.shell.Topbar; // ── Debounce helper ──────────────────────── var _timers = {}; function debounce(key, fn, ms) { clearTimeout(_timers[key]); _timers[key] = setTimeout(fn, ms); } // ═══════════════════════════════════════════ // Markdown rendering via sw.markdown // ═══════════════════════════════════════════ // Preload marked + DOMPurify so renderSync works in useMemo sw.markdown.preload(); function _escAttr(s) { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ═══════════════════════════════════════════ // TagInput — editor tag management // ═══════════════════════════════════════════ function TagInput({ tags, allTags, onChange }) { var [input, setInput] = useState(''); var [showAC, setShowAC] = useState(false); var inputRef = useRef(null); var suggestions = useMemo(function() { if (!input.trim()) return []; var q = input.trim().toLowerCase(); return (allTags || []).filter(function(t) { return t.toLowerCase().indexOf(q) !== -1 && (tags || []).indexOf(t) === -1; }).slice(0, 8); }, [input, allTags, tags]); function addTag(tag) { var t = tag.trim().toLowerCase(); if (!t) return; var current = tags || []; if (current.indexOf(t) === -1) { onChange(current.concat([t])); } setInput(''); setShowAC(false); if (inputRef.current) inputRef.current.focus(); } function removeTag(tag) { onChange((tags || []).filter(function(t) { return t !== tag; })); } function handleKeyDown(e) { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (input.trim()) addTag(input); } if (e.key === 'Backspace' && !input && tags && tags.length > 0) { removeTag(tags[tags.length - 1]); } if (e.key === 'Escape') { setShowAC(false); } } function handleInput(e) { var val = e.target.value.replace(',', ''); setInput(val); setShowAC(val.trim().length > 0); } return html`
${(tags || []).map(function(tag) { return html` ${tag} × `; })} ${showAC && suggestions.length > 0 && html`
${suggestions.map(function(s) { return html`
${s}
`; })}
`}
`; } // ═══════════════════════════════════════════ // TagFilter — sidebar tag filter // ═══════════════════════════════════════════ function TagFilter({ tags, activeTag, onSelect }) { if (!tags || tags.length === 0) return null; return html`
Tags ${activeTag && html` `}
${tags.map(function(tag) { return html` ${tag} `; })}
`; } // ═══════════════════════════════════════════ // NoteCard — sidebar list item (draggable) // ═══════════════════════════════════════════ function NoteCard({ note, active, onClick }) { var [dragging, setDragging] = useState(false); var dateStr = note.updated_at || note.created_at || ''; if (dateStr) { try { dateStr = new Date(dateStr).toLocaleDateString(); } catch(e) { /* keep raw */ } } var tags = note.tags || []; var showTags = tags.slice(0, 3); var overflow = tags.length - 3; function handleDragStart(e) { e.dataTransfer.setData('text/plain', note.id); e.dataTransfer.effectAllowed = 'move'; setDragging(true); } function handleDragEnd() { setDragging(false); } return html`
${note.pinned ? html`📌` : null} ${note.title || 'Untitled'}
${note.snippet && html`
${note.snippet}
`} ${showTags.length > 0 && html`
${showTags.map(function(t) { return html`${t}`; })} ${overflow > 0 && html`+${overflow}`}
`} ${dateStr && html`
${dateStr}
`}
`; } // ═══════════════════════════════════════════ // FolderContextMenu — right-click menu // ═══════════════════════════════════════════ function FolderContextMenu({ x, y, onRename, onDelete, onAddSub, onClose }) { var menuRef = useRef(null); useEffect(function() { function handleClick(e) { if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); } document.addEventListener('mousedown', handleClick); return function() { document.removeEventListener('mousedown', handleClick); }; }, [onClose]); return html`
Add subfolder
Rename
Delete
`; } // ═══════════════════════════════════════════ // FolderNode — recursive tree node (drop target) // ═══════════════════════════════════════════ function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub, onDropNote }) { var [expanded, setExpanded] = useState(true); var [editing, setEditing] = useState(false); var [editName, setEditName] = useState(node.folder.name); var [dropOver, setDropOver] = useState(false); var [menu, setMenu] = useState(null); var hasChildren = node.children.length > 0; var isActive = activeFolderId === node.folder.id; var indent = 20 + depth * 16; function handleToggle(e) { e.stopPropagation(); setExpanded(!expanded); } function handleRename() { if (editName.trim() && editName !== node.folder.name) { onRename(node.folder.id, editName.trim()); } setEditing(false); } function handleContext(e) { e.preventDefault(); e.stopPropagation(); setMenu({ x: e.clientX, y: e.clientY }); } function handleDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDropOver(true); } function handleDragLeave() { setDropOver(false); } function handleDrop(e) { e.preventDefault(); setDropOver(false); var noteId = e.dataTransfer.getData('text/plain'); if (noteId && onDropNote) onDropNote(noteId, node.folder.id); } return html`
${hasChildren ? (expanded ? '▼' : '▶') : '·'} ${isActive ? '📂' : '📁'} ${editing ? html`` : html`${node.folder.name}` }
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y} onRename=${function() { setMenu(null); setEditing(true); setEditName(node.folder.name); }} onDelete=${function() { setMenu(null); onDelete(node.folder.id); }} onAddSub=${function() { setMenu(null); onCreateSub(node.folder.id); }} onClose=${function() { setMenu(null); }} />`} ${expanded && node.children.map(function(child) { return html`<${FolderNode} key=${child.folder.id} node=${child} depth=${depth + 1} activeFolderId=${activeFolderId} onSelect=${onSelect} onRename=${onRename} onDelete=${onDelete} onCreateSub=${onCreateSub} onDropNote=${onDropNote} />`; })}
`; } // ═══════════════════════════════════════════ // FolderTree — navigation panel (drop targets) // ═══════════════════════════════════════════ function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder, onDropNote }) { var [dropAll, setDropAll] = useState(false); var [dropUnfiled, setDropUnfiled] = useState(false); // build tree from flat list var tree = useMemo(function() { var byId = {}; var roots = []; for (var i = 0; i < folders.length; i++) { byId[folders[i].id] = { folder: folders[i], children: [] }; } for (var i = 0; i < folders.length; i++) { var f = folders[i]; var node = byId[f.id]; if (f.parent_id && byId[f.parent_id]) { byId[f.parent_id].children.push(node); } else { roots.push(node); } } function sortNodes(arr) { arr.sort(function(a, b) { if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order; return (a.folder.name || '').localeCompare(b.folder.name || ''); }); for (var j = 0; j < arr.length; j++) sortNodes(arr[j].children); } sortNodes(roots); return roots; }, [folders]); // drop handlers for "All Notes" / "Unfiled" (move to unfiled = folder_id "") function makeDropHandlers(setOver) { return { onDragOver: function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOver(true); }, onDragLeave: function() { setOver(false); }, onDrop: function(e) { e.preventDefault(); setOver(false); var noteId = e.dataTransfer.getData('text/plain'); if (noteId && onDropNote) onDropNote(noteId, ''); } }; } var allDrop = makeDropHandlers(setDropAll); var unfiledDrop = makeDropHandlers(setDropUnfiled); return html`
📋 All Notes
📄 Unfiled
${tree.map(function(node) { return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0} activeFolderId=${activeFolderId} onSelect=${onSelectFolder} onRename=${onRenameFolder} onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder} onDropNote=${onDropNote} />`; })}
+ New Folder
`; } // ═══════════════════════════════════════════ // BacklinksPanel — incoming wikilinks // ═══════════════════════════════════════════ function BacklinksPanel({ noteId, onNavigate }) { var [backlinks, setBacklinks] = useState([]); var [collapsed, setCollapsed] = useState(false); useEffect(function() { if (!noteId) { setBacklinks([]); return; } api.get('/backlinks/' + noteId).then(function(res) { var items = (res && res.data) || res || []; setBacklinks(Array.isArray(items) ? items : []); }).catch(function() { setBacklinks([]); }); }, [noteId]); if (!backlinks.length) return null; return html` `; } // ═══════════════════════════════════════════ // EditorPane — note editor + preview + tags // ═══════════════════════════════════════════ // ═══════════════════════════════════════════ // Export / Import helpers // ═══════════════════════════════════════════ function exportNoteAsMarkdown(note, tags) { var parts = ['---']; parts.push('title: ' + (note.title || 'Untitled')); if (tags && tags.length) parts.push('tags: [' + tags.join(', ') + ']'); if (note.created_at) parts.push('created: ' + note.created_at); parts.push('---'); parts.push(''); parts.push(note.body || ''); var text = parts.join('\n'); var blob = new Blob([text], { type: 'text/markdown;charset=utf-8' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = (note.title || 'Untitled').replace(/[^a-zA-Z0-9_\- ]/g, '').trim() + '.md'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function parseFrontmatter(text) { var result = { title: '', tags: [], body: text }; if (!text.startsWith('---')) return result; var end = text.indexOf('\n---', 3); if (end === -1) return result; var fm = text.substring(3, end).trim(); var bodyStart = end + 4; // skip leading newline after frontmatter if (text[bodyStart] === '\n') bodyStart++; result.body = text.substring(bodyStart); var lines = fm.split('\n'); for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (line.startsWith('title:')) { result.title = line.substring(6).trim().replace(/^["']|["']$/g, ''); } else if (line.startsWith('tags:')) { var tagStr = line.substring(5).trim(); // parse [tag1, tag2] or tag1, tag2 tagStr = tagStr.replace(/^\[|\]$/g, ''); result.tags = tagStr.split(',').map(function(t) { return t.trim().toLowerCase(); }).filter(Boolean); } } return result; } // ═══════════════════════════════════════════ // Heading parser + Document Outline // ═══════════════════════════════════════════ function parseHeadings(body) { if (!body) return []; var lines = body.split('\n'); var result = []; var inCode = false; for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (line.trim().indexOf('```') === 0) { inCode = !inCode; continue; } if (inCode) continue; var m = line.match(/^(#{1,6})\s+(.+)/); if (m) { result.push({ level: m[1].length, text: m[2].replace(/[*_`\[\]]/g, '').trim(), line: i }); } } return result; } // ═══════════════════════════════════════════ // SidebarTabs — Notes / Outline tab header // ═══════════════════════════════════════════ function SidebarTabs({ activeTab, onTabChange, noteSelected }) { return html` `; } // ═══════════════════════════════════════════ // SidebarOutline — heading tree in sidebar // ═══════════════════════════════════════════ function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) { if (!headings || headings.length === 0) { return html``; } return html` `; } var _modeStore = sw.storage.local('notes'); function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle, onHeadingsChange, scrollToHeadingRef, onActiveHeadingChange }) { var [title, setTitle] = useState(note ? note.title : ''); var [body, setBody] = useState(note ? note.body : ''); var [noteTags, setNoteTags] = useState([]); var [viewMode, setViewMode] = useState(_modeStore.get('editor_mode') || 'rendered'); var [dirty, setDirty] = useState(false); var [saving, setSaving] = useState(false); var [saveCount, setSaveCount] = useState(0); var textareaRef = useRef(null); var cmContainerRef = useRef(null); var cmEditorRef = useRef(null); // keep mutable refs for the latest title/body so CM6 callbacks see current values var titleRef = useRef(title); var bodyRef = useRef(body); titleRef.current = title; bodyRef.current = body; // sync when note changes useEffect(function() { if (note) { setTitle(note.title || ''); setBody(note.body || ''); titleRef.current = note.title || ''; bodyRef.current = note.body || ''; setNoteTags(note.tags || []); setDirty(false); setViewMode(_modeStore.get('editor_mode') || 'rendered'); // update CM6 content if editor exists if (cmEditorRef.current) { cmEditorRef.current.setValue(note.body || ''); } } }, [note ? note.id : null]); // ── CM6 lifecycle ───────────────────────── var needsCM = viewMode !== 'rendered'; useEffect(function() { if (!hasCM || !needsCM || !note) return; var el = cmContainerRef.current; if (!el) return; // destroy previous if (cmEditorRef.current) { cmEditorRef.current.destroy(); cmEditorRef.current = null; } var editor = CM.noteEditor(el, { value: bodyRef.current, darkMode: document.documentElement.getAttribute('data-theme') !== 'light', onChange: function(text) { bodyRef.current = text; setBody(text); setDirty(true); debounce('save', function() { doSave(titleRef.current, text); }, 1000); }, onLink: function(linkTitle) { if (onNavigateToTitle) onNavigateToTitle(linkTitle); }, linkCompleter: async function(query) { try { var res = await api.get('/search?q=' + encodeURIComponent(query)); var items = (res && res.data) || res || []; if (!Array.isArray(items)) items = []; return items.map(function(n) { return { label: n.title, id: n.id }; }); } catch (e) { return []; } }, }); cmEditorRef.current = editor; // Ctrl/Cmd+S via CM6 keymap injection var view = editor.getView(); if (view) { var handleSaveKey = function(e) { if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); doSave(titleRef.current, bodyRef.current); } }; view.dom.addEventListener('keydown', handleSaveKey); } editor.focus(); return function() { if (cmEditorRef.current) { cmEditorRef.current.destroy(); cmEditorRef.current = null; } }; }, [note ? note.id : null, needsCM]); function handleTitleChange(e) { setTitle(e.target.value); titleRef.current = e.target.value; setDirty(true); debounce('save', function() { doSave(e.target.value, bodyRef.current); }, 1000); } function handleBodyChange(e) { setBody(e.target.value); bodyRef.current = e.target.value; setDirty(true); debounce('save', function() { doSave(titleRef.current, e.target.value); }, 1000); } async function doSave(t, b) { if (!note) return; setSaving(true); try { await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b }); setDirty(false); setSaveCount(function(c) { return c + 1; }); onRefresh(); } catch (e) { console.error('Save failed:', e); } finally { setSaving(false); } } async function handleTagsChange(newTags) { if (!note) return; setNoteTags(newTags); try { await api.put('/tags/' + note.id, { tags: newTags }); if (onTagsChanged) onTagsChanged(); } catch (e) { console.error('Save tags failed:', e); } } async function handlePin() { if (!note) return; await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 }); onRefresh(); } async function handleArchive() { if (!note || !await sw.confirm('Archive this note?', { destructive: true })) return; await api.del('/notes/' + note.id); onDelete(); } function handleExport() { if (!note) return; exportNoteAsMarkdown(note, noteTags); } function handleKeyDown(e) { // Tab inserts two spaces if (e.key === 'Tab') { e.preventDefault(); var ta = e.target; var start = ta.selectionStart; var end = ta.selectionEnd; var val = ta.value; ta.value = val.substring(0, start) + ' ' + val.substring(end); ta.selectionStart = ta.selectionEnd = start + 2; setBody(ta.value); bodyRef.current = ta.value; setDirty(true); debounce('save', function() { doSave(title, ta.value); }, 1000); } // Ctrl/Cmd+S force save if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); doSave(title, body); } } // ── Outgoing links for resolved/unresolved styling ── var [outgoingLinks, setOutgoingLinks] = useState([]); useEffect(function() { if (!note) { setOutgoingLinks([]); return; } api.get('/links/' + note.id).then(function(res) { var items = (res && res.data) || res || []; setOutgoingLinks(Array.isArray(items) ? items : []); }).catch(function() { setOutgoingLinks([]); }); }, [note ? note.id : null, saveCount]); var previewHtml = useMemo(function() { var h = sw.markdown.renderSync(body, { sanitize: true }); // mark unresolved wikilinks for (var i = 0; i < outgoingLinks.length; i++) { var link = outgoingLinks[i]; if (!link.target_id) { var search = 'class="wikilink" data-wikilink="' + _escAttr(link.link_text) + '"'; var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"'; h = h.split(search).join(replace); } } return h; }, [body, outgoingLinks]); // ── Wikilink click handling in preview ── var previewRef = useRef(null); useEffect(function() { var el = previewRef.current; if (!el || viewMode === 'edit') return; function handleClick(e) { var target = e.target; if (target.classList && target.classList.contains('wikilink')) { e.preventDefault(); var linkText = target.getAttribute('data-wikilink'); if (linkText && onNavigateToTitle) onNavigateToTitle(linkText); } } el.addEventListener('click', handleClick); return function() { el.removeEventListener('click', handleClick); }; }, [viewMode, onNavigateToTitle]); // ── Run post-renderers (mermaid, katex, etc.) on preview ── useEffect(function() { var el = previewRef.current; if (!el || viewMode === 'edit') return; sw.renderers.runPostRenderers(el); }, [previewHtml, viewMode]); // ── Synced scroll in split mode ── useEffect(function() { if (viewMode !== 'split' || !cmEditorRef.current) return; var view = cmEditorRef.current.getView(); if (!view) return; var scrollDOM = view.scrollDOM; var previewEl = previewRef.current; if (!scrollDOM || !previewEl) return; var syncing = false; function onEditorScroll() { if (syncing) return; syncing = true; var ratio = scrollDOM.scrollTop / (scrollDOM.scrollHeight - scrollDOM.clientHeight || 1); previewEl.scrollTop = ratio * (previewEl.scrollHeight - previewEl.clientHeight); syncing = false; } scrollDOM.addEventListener('scroll', onEditorScroll); return function() { scrollDOM.removeEventListener('scroll', onEditorScroll); }; }, [viewMode, note ? note.id : null]); // ── Document outline headings ── var [headings, setHeadings] = useState([]); useEffect(function() { debounce('outline', function() { var h = parseHeadings(body); setHeadings(h); if (onHeadingsChange) onHeadingsChange(h); }, 300); }, [body]); // ── Expose scrollToHeading to parent via ref ── useEffect(function() { if (!scrollToHeadingRef) return; scrollToHeadingRef.current = function(heading) { // scroll preview in rendered / split if (viewMode !== 'edit' && previewRef.current) { var targets = previewRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6'); for (var i = 0; i < targets.length; i++) { if (targets[i].textContent.trim() === heading.text) { targets[i].scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } } } // scroll CM6 in edit / split if (viewMode !== 'rendered' && cmEditorRef.current) { var view = cmEditorRef.current.getView(); if (!view) return; var lineNum = Math.min(heading.line + 1, view.state.doc.lines); var lineObj = view.state.doc.line(lineNum); view.dispatch({ effects: CM.EditorView.scrollIntoView(lineObj.from, { y: 'start' }) }); } }; }, [viewMode]); // ── Active heading tracking on scroll ── useEffect(function() { if (!onActiveHeadingChange || !note) return; var scrollEl = null; var usePreview = viewMode !== 'edit'; if (usePreview && previewRef.current) { scrollEl = previewRef.current; } else if (viewMode !== 'rendered' && cmEditorRef.current) { var view = cmEditorRef.current.getView(); if (view) scrollEl = view.scrollDOM; } if (!scrollEl) return; var ticking = false; function onScroll() { if (ticking) return; ticking = true; requestAnimationFrame(function() { ticking = false; if (usePreview && previewRef.current) { var els = previewRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6'); var active = -1; for (var i = 0; i < els.length; i++) { if (els[i].getBoundingClientRect().top <= previewRef.current.getBoundingClientRect().top + 30) { active = i; } } onActiveHeadingChange(active); } else if (cmEditorRef.current) { var v = cmEditorRef.current.getView(); if (!v) return; var topLine = v.state.doc.lineAt(v.viewport.from).number - 1; var active = -1; for (var i = 0; i < headings.length; i++) { if (headings[i].line <= topLine) active = i; } onActiveHeadingChange(active); } }); } scrollEl.addEventListener('scroll', onScroll); onScroll(); // initial check return function() { scrollEl.removeEventListener('scroll', onScroll); }; }, [viewMode, note ? note.id : null, headings]); // build indented folder list for the select dropdown var folderOptions = useMemo(function() { var list = folders || []; var byId = {}; var roots = []; for (var i = 0; i < list.length; i++) { byId[list[i].id] = { folder: list[i], children: [] }; } for (var i = 0; i < list.length; i++) { var f = list[i]; if (f.parent_id && byId[f.parent_id]) { byId[f.parent_id].children.push(byId[f.id]); } else { roots.push(byId[f.id]); } } var flat = []; function walk(nodes, depth) { nodes.sort(function(a, b) { if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order; return (a.folder.name || '').localeCompare(b.folder.name || ''); }); for (var j = 0; j < nodes.length; j++) { var prefix = ''; for (var k = 0; k < depth; k++) prefix += '\u00A0\u00A0'; if (depth > 0) prefix += '\u2514 '; flat.push({ id: nodes[j].folder.id, label: prefix + nodes[j].folder.name }); walk(nodes[j].children, depth + 1); } } walk(roots, 0); return flat; }, [folders]); if (!note) { return html`
Select a note or create a new one
`; } // ── Render editor body ───────────────────── function renderEditorBody() { if (viewMode === 'rendered') { return html`
`; } if (viewMode === 'split') { var editor = hasCM ? html`
` : html`