Feat v0.4.4 rich editor import export (#26)
All checks were successful
All checks were successful
This commit was merged in pull request #26.
This commit is contained in:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes to Switchboard Core are documented here.
|
||||
|
||||
## v0.4.4 — Rich Editor + Import/Export
|
||||
|
||||
### Added
|
||||
|
||||
- **CodeMirror 6 integration**: Notes editor now uses the vendored CM6 bundle
|
||||
(`CM.noteEditor()`) for rich markdown editing with syntax highlighting,
|
||||
wikilink autocomplete (`[[` triggers note title completion), and inline
|
||||
preview decorations for headings, code blocks, and blockquotes.
|
||||
- **CM6 dynamic loading**: Bundle loaded via `<script>` tag at boot time.
|
||||
Falls back to plain textarea if CM6 is unavailable.
|
||||
- **Export as .md**: Export button in editor header downloads the note as a
|
||||
Markdown file with YAML frontmatter (title, tags, created date).
|
||||
- **Import .md**: Import button in topbar allows uploading `.md`/`.markdown`/`.txt`
|
||||
files. Parses YAML frontmatter for title and tags, creates note via API.
|
||||
- **CSS for CM6**: Editor fills the container with `max-height: none` override
|
||||
and proper scroller padding.
|
||||
|
||||
### Changed
|
||||
|
||||
- Notes package version bumped from 0.4.0 to 0.5.0.
|
||||
- `EditorPane` component refactored to support CM6 with mutable refs for
|
||||
title/body (required for CM6 callback closures).
|
||||
- Ctrl/Cmd+S save works in both CM6 and textarea fallback modes.
|
||||
|
||||
---
|
||||
|
||||
## v0.4.3 — Backlinks + Wikilinks
|
||||
|
||||
### Added
|
||||
|
||||
138
ROADMAP.md
138
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Switchboard Core — Roadmap
|
||||
|
||||
## Current: v0.4.3 — Backlinks + Wikilinks
|
||||
## Current: v0.4.4 — Rich Editor + Import/Export
|
||||
|
||||
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||
features removed from the kernel. What remains is the minimum viable
|
||||
@@ -281,24 +281,134 @@ Zero platform special-casing. Proves the full extension stack E2E.
|
||||
| Backlinks panel | ✅ | Collapsible panel below editor showing all notes linking to current note. Click to navigate. Hidden when empty. |
|
||||
| Stats update | ✅ | `/stats` includes `links` count. Notes package version bumped to 0.4.0. |
|
||||
|
||||
### v0.4.4 — Rich Editor + Import/Export (planned)
|
||||
### v0.4.4 — Rich Editor + Import/Export (complete)
|
||||
|
||||
- Vendored CodeMirror 6 markdown bundle
|
||||
- Markdown file import/export
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| CM6 integration | ✅ | Load vendored CodeMirror 6 bundle via dynamic `<script>` tag. `CM.noteEditor()` factory with markdown syntax highlighting, wikilink autocomplete, and live preview decorations. Textarea fallback if CM6 unavailable. |
|
||||
| Editor wiring | ✅ | EditorPane uses CM6 with `onChange` → auto-save debounce, `onLink` → wikilink navigation, `linkCompleter` → search API autocomplete. Ctrl/Cmd+S force save. Editor fills container via CSS override. |
|
||||
| Export as .md | ✅ | Export button in editor header. Downloads note as `.md` file with YAML frontmatter (title, tags, created). Pure client-side Blob download. |
|
||||
| Import .md | ✅ | Import button in topbar. File picker for `.md`/`.markdown`/`.txt`. Parses YAML frontmatter for title and tags. Creates note via API with tags. |
|
||||
| Notes package v0.5.0 | ✅ | Manifest version bumped from 0.4.0 → 0.5.0. |
|
||||
|
||||
## v0.5.0 — MVP
|
||||
### v0.4.5 — Editor Modes + Document Outline (planned)
|
||||
|
||||
Extension and operations tracks converge. First externally usable release.
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Default-rendered mode | | Preview is the initial state on note open. "Edit" button enters CM6. Save exits back to rendered view. |
|
||||
| Split view | | Side-by-side layout: CM6 left, rendered preview right. New `viewMode` state: `rendered` / `edit` / `split`. CSS grid two-column in `.notes-editor__body`. |
|
||||
| Synced scroll | | Split mode scroll sync. CM6 `scrollDOM` scroll listener maps position ratio to preview `scrollTop`. Linear mapping. |
|
||||
| View mode setting | | Add `editor_mode` to manifest `settings` (rendered / split / edit). Persist preference. Toolbar cycles modes. |
|
||||
| Document outline | | Parse headings from body. Render as collapsible TOC panel adjacent to editor. Click heading → scroll CM6 or preview to target. Updates on body change via debounced parse. |
|
||||
|
||||
- Package registry (browse, install, update, uninstall)
|
||||
- Package distribution model (no auto-install; explicit install only)
|
||||
- Health monitoring dashboard
|
||||
- Backup/restore tooling
|
||||
- Documentation site
|
||||
### v0.4.6 — Sidebar Restructure (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Sidebar tabs | | Two-tab header at top of sidebar: "Notes" (folders → tags → search → list) and "Outline" (heading tree for active note). Outline tab enabled only when a note is selected. |
|
||||
| Heading tree | | Nested heading hierarchy (H1 → H2 → H3) with depth indentation. Click → scroll to heading in editor/preview. Current heading highlighted based on scroll position. |
|
||||
|
||||
### v0.4.7 — Note Graph (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Graph API | | `GET /graph` endpoint in Starlark — `{ nodes: [{id, title, folder_id}], edges: [{source, target, text}] }`. Full notes + links in one payload. |
|
||||
| Graph renderer | | Canvas force-directed layout. Minimal force simulation (repulsion + attraction + damping). Nodes = circles with title labels, edges = lines. Zoom/pan via wheel + drag. |
|
||||
| Click → focus + filter | | Single click a node: dims unconnected nodes/edges to ~15% opacity. Clicked node + direct neighbors stay full brightness. Edges highlighted in accent color. Click empty space to reset. |
|
||||
| Shift+click → chain | | Shift+click adds a second node to the focus set — union of both neighborhoods visible. Trace thought paths across two hops without losing context. |
|
||||
| Double-click → open | | Double-click navigates to the note. Graph view stays active — back returns to graph with previous focus state preserved. |
|
||||
| Hover → tooltip | | Hover shows note title + tag pills + edge count. No API call — all data in graph payload. |
|
||||
| Folder/tag coloring | | Nodes colored by folder. Tag filter dropdown — select tag, unmatched nodes dim. Stacks with click focus. |
|
||||
| Orphan highlighting | | Zero-edge nodes rendered with dashed stroke. Optional toggle to hide entirely. |
|
||||
| Entry point | | "Graph" button in topbar. Replaces editor pane with full graph canvas. Note selection in graph populates sidebar, switching back to editor shows that note. |
|
||||
|
||||
## v0.5.x — Realtime + Chat
|
||||
|
||||
Communication primitive track. The kernel gains one generic realtime
|
||||
module. Everything else — conversations, messages, participants — is
|
||||
pure extension code. Human-to-human chat ships pre-MVP; LLM
|
||||
participation is post-MVP.
|
||||
|
||||
### v0.5.0 — Realtime Primitive + Dialog System (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `realtime.publish()` | | Starlark module: `realtime.publish(channel, event, data)`. Publishes JSON event to WebSocket hub scoped to channel (e.g. `conversation:{id}`). Hub handles fan-out and cross-replica broadcast. |
|
||||
| `sw.realtime.subscribe()` | | SDK method: `sw.realtime.subscribe(channel, callback)`. Client-side WebSocket listener filtered by channel. Returns unsubscribe handle. |
|
||||
| `sw.ui.Dialog` | | SDK modal primitive: configurable title, body, action buttons. Replaces native `alert()`. Promise-based API. |
|
||||
| `sw.ui.Confirm` | | SDK confirm primitive: question + confirm/cancel buttons. Replaces native `confirm()`. Returns promise resolving to boolean. |
|
||||
| `sw.ui.Prompt` | | SDK prompt primitive: label + text input + confirm/cancel. Replaces native `prompt()`. Returns promise resolving to string or null. |
|
||||
| Native dialog audit | | Sweep all surfaces (notes, tasks, schedules, admin, settings). Replace every `prompt()`, `confirm()`, `alert()` with SDK equivalents. |
|
||||
|
||||
### v0.5.1 — Chat Core Library (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Library package | | `chat-core` library with `permissions: ["db.write"]`. Private ext_data tables, exported Starlark functions, REST endpoints. |
|
||||
| Conversations table | | `conversations` — title, type (direct/group), created_by, updated_at. |
|
||||
| Participants table | | `participants` — conversation_id, participant_id, participant_type (user/bot), display_name, role (member/admin), joined_at. |
|
||||
| Messages table | | `messages` — conversation_id, participant_id, content, content_type (text/system/file), edited_at. |
|
||||
| Read cursors table | | `read_cursors` — conversation_id, participant_id, last_read_message_id. |
|
||||
| Exported Starlark API | | `chat.create()`, `chat.send()`, `chat.history()`, `chat.add_participant()`, `chat.remove_participant()`, `chat.mark_read()`. |
|
||||
| REST endpoints | | Full CRUD for conversations, messages, participants. Paginated message history. Unread counts. |
|
||||
| Realtime integration | | On `chat.send()`: insert message, emit `chat.message.created` event, call `realtime.publish("conversation:{id}", "message", data)`. |
|
||||
|
||||
### v0.5.2 — Chat Surface (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Conversation list | | Sidebar: conversation list with last message preview, unread badge, timestamp. Create conversation button. |
|
||||
| Message thread | | Main pane: message history with participant avatars, timestamps, content. Virtual scroll for long threads. |
|
||||
| Compose bar | | Text input with Enter-to-send, Shift+Enter for newline. Markdown support via existing inline renderer. |
|
||||
| Participant sidebar | | Collapsible right panel: participant list with online status (via kernel presence hub). Add/remove participants using `sw.ui.Dialog`. |
|
||||
| 1:1 and group | | Direct messages (two participants) and group conversations. Type field on conversation. |
|
||||
| Typing indicators | | `realtime.publish("conversation:{id}", "typing", {participant_id})` on keypress with debounce. Surface shows "X is typing…" with auto-expire. |
|
||||
| Read receipts | | Mark-read on scroll/focus via `chat.mark_read()`. Unread count in conversation list. |
|
||||
| Message editing | | Edit own messages. `edited_at` timestamp displayed. Edit via `PUT /messages/:id`. |
|
||||
| Message deletion | | Soft-delete own messages. Replaced with "message deleted" placeholder. |
|
||||
|
||||
### v0.5.3 — Chat Polish + Integration Testing (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Conversation search | | Search across conversation titles and message content. |
|
||||
| Message pagination | | Cursor-based pagination on message history. Scroll-to-load-more in thread view. |
|
||||
| Multi-user E2E | | Docker compose test: multiple browser sessions, verify real-time message delivery, presence indicators, cross-replica broadcast. |
|
||||
| Workflow integration | | Verify: workflow stage with `audience: team` can create a conversation via `chat.create()`, add assigned members as participants. Conversation scoped to instance. |
|
||||
|
||||
### v0.5.4 — Package Updates (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Version comparison | | Compare installed package version against incoming `.pkg` version. Semantic version parsing. Skip if same or older. |
|
||||
| Schema migration on update | | Diff declared `db_tables` against existing ext_data schema. Add new columns, add new tables. No destructive changes (no column drops, no type changes). |
|
||||
| Data preservation | | ext_data tables survive update. Only additive schema changes applied. Settings merged (new keys added, existing preserved). |
|
||||
| Update API | | `PUT /api/v1/packages/:id/update` — accepts `.pkg` archive, validates version bump, applies schema diff, replaces code (JS/CSS/Starlark). |
|
||||
| Admin UI | | Update button on package card when newer version available. Version comparison badge. Update confirmation via `sw.ui.Confirm`. |
|
||||
| Rollback story | | Export package before update (existing export). Document manual rollback: re-install old `.pkg`. No automatic rollback — KISS. |
|
||||
|
||||
### v0.5.5 — Upgrade Testing (planned)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Test harness | | Docker compose environment: build v(current) image, seed data (notes, conversations, tasks, workflows), then upgrade to v(next) image. Verify data integrity post-upgrade. |
|
||||
| Schema edge cases | | Force upgrade scenarios: add column to existing table, add new table, add new index. Verify ext_data migrations apply cleanly without data loss. Throwaway changes — not committed, only bug fixes. |
|
||||
| Settings migration | | Verify settings cascade survives upgrade: global → team → user overrides preserved. New settings keys appear with defaults. Removed keys ignored. |
|
||||
| Package compatibility | | Install older package version, upgrade kernel, verify package still loads. Install newer package version on older kernel, verify graceful failure. |
|
||||
| Multi-replica upgrade | | Rolling upgrade in K8s: old and new replicas coexist briefly. Verify WebSocket hub handoff, no message loss, no split-brain on ext_data writes. |
|
||||
|
||||
## v0.6.0 — MVP
|
||||
|
||||
Extension, communication, and operations tracks converge. First
|
||||
externally usable release.
|
||||
|
||||
- Health monitoring (extension package wrapping kernel `/health`)
|
||||
- Backup/restore tooling (ext_data export/import, admin surface)
|
||||
- Documentation site (extension authoring guide, API reference, deployment guide)
|
||||
|
||||
## Post-MVP
|
||||
|
||||
- Chat extension (provider registry, streaming, personas, tool system)
|
||||
- LLM participation (`llm-bridge` extension: subscribes to `chat.message.created`, calls `provider.complete()`, streams response via `realtime.publish`, posts via `chat.send()`. Bot participants with persona config. Multi-model conversations.)
|
||||
- Rich media extensions: image generation, code sandbox, STT/TTS
|
||||
- Desktop app (Tauri or Electron)
|
||||
- Sidecar tier: container-based extensions
|
||||
@@ -322,6 +432,6 @@ Extension and operations tracks converge. First externally usable release.
|
||||
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
|
||||
| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. |
|
||||
| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. |
|
||||
| Chat → post-MVP | Chat extension (providers, streaming, personas) is valuable but not MVP-critical. The platform must prove itself with simpler surfaces first. Chat moves to post-MVP track. |
|
||||
| Chat as extension, not kernel | Human-to-human chat built entirely as library + surface packages. Zero kernel awareness of conversations, messages, or participants. Kernel gains one generic `realtime` module. Proves near-infinite extensibility. LLM participation layers on top via a separate bridge extension — the chat system doesn't know or care whether a participant is human or AI. |
|
||||
| Two trigger tiers | Event + webhook triggers are extension-declared (manifest contract, full sandbox). Scheduled tasks are user-created ad-hoc (restricted sandbox — no raw HTTP, no DB table creation, connections-only outbound). Separation keeps extension contracts static and user automation safe. |
|
||||
| Scheduled task identity | Tasks run as their creator (RBAC-scoped). Admin-created tasks can opt into system context. Creator deactivation pauses the schedule. Ensures audit trail and permission boundaries. |
|
||||
| Scheduled task identity | Tasks run as their creator (RBAC-scoped). Admin-created tasks can opt into system context. Creator deactivation pauses the schedule. Ensures audit trail and permission boundaries. |
|
||||
@@ -281,6 +281,20 @@
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── CodeMirror 6 container ─────────────── */
|
||||
.notes-editor__cm {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notes-editor__cm .cm-editor {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
.notes-editor__cm .cm-scroller {
|
||||
overflow: auto;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
/* ── Preview ─────────────────────────────── */
|
||||
.notes-preview {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Notes — Surface Entry Point (v0.4.0)
|
||||
* Notes — Surface Entry Point (v0.5.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';
|
||||
@@ -15,6 +16,21 @@
|
||||
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) {
|
||||
@@ -32,6 +48,10 @@
|
||||
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;
|
||||
@@ -537,6 +557,56 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle }) {
|
||||
var [title, setTitle] = useState(note ? note.title : '');
|
||||
var [body, setBody] = useState(note ? note.body : '');
|
||||
@@ -546,28 +616,102 @@
|
||||
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);
|
||||
setPreview(false);
|
||||
// update CM6 content if editor exists
|
||||
if (cmEditorRef.current) {
|
||||
cmEditorRef.current.setValue(note.body || '');
|
||||
}
|
||||
}
|
||||
}, [note ? note.id : null]);
|
||||
|
||||
// ── CM6 lifecycle ─────────────────────────
|
||||
useEffect(function() {
|
||||
if (!hasCM || preview || !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, preview]);
|
||||
|
||||
function handleTitleChange(e) {
|
||||
setTitle(e.target.value);
|
||||
titleRef.current = e.target.value;
|
||||
setDirty(true);
|
||||
debounce('save', function() { doSave(e.target.value, body); }, 1000);
|
||||
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(title, e.target.value); }, 1000);
|
||||
debounce('save', function() { doSave(titleRef.current, e.target.value); }, 1000);
|
||||
}
|
||||
|
||||
async function doSave(t, b) {
|
||||
@@ -608,6 +752,11 @@
|
||||
onDelete();
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!note) return;
|
||||
exportNoteAsMarkdown(note, noteTags);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
// Tab inserts two spaces
|
||||
if (e.key === 'Tab') {
|
||||
@@ -619,6 +768,7 @@
|
||||
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);
|
||||
}
|
||||
@@ -708,6 +858,21 @@
|
||||
return html`<div class="notes-editor__empty">Select a note or create a new one</div>`;
|
||||
}
|
||||
|
||||
// ── Render editor body ─────────────────────
|
||||
function renderEditorBody() {
|
||||
if (preview) {
|
||||
return html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
|
||||
}
|
||||
if (hasCM) {
|
||||
return html`<div class="notes-editor__cm" ref=${cmContainerRef} />`;
|
||||
}
|
||||
// fallback textarea
|
||||
return html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
||||
value=${body} onInput=${handleBodyChange}
|
||||
onKeyDown=${handleKeyDown}
|
||||
placeholder="Start writing in Markdown…" />`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="notes-editor">
|
||||
<div class="notes-editor__header">
|
||||
@@ -733,6 +898,7 @@
|
||||
onClick=${() => setPreview(!preview)}>
|
||||
${preview ? 'Edit' : 'Preview'}
|
||||
</button>
|
||||
<button class="notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
|
||||
<button class="notes-btn" onClick=${handlePin}
|
||||
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
||||
${note.pinned ? '📌' : '📍'}
|
||||
@@ -743,13 +909,7 @@
|
||||
</div>
|
||||
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
||||
<div class="notes-editor__body">
|
||||
${preview
|
||||
? html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||||
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
||||
value=${body} onInput=${handleBodyChange}
|
||||
onKeyDown=${handleKeyDown}
|
||||
placeholder="Start writing in Markdown…" />`
|
||||
}
|
||||
${renderEditorBody()}
|
||||
</div>
|
||||
<${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
|
||||
</div>
|
||||
@@ -986,6 +1146,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Import .md file ─────────────────────
|
||||
function handleImport() {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.md,.markdown,.txt';
|
||||
input.style.display = 'none';
|
||||
input.onchange = async function() {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
var text = await new Promise(function(resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function() { resolve(reader.result); };
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
var parsed = parseFrontmatter(text);
|
||||
var noteTitle = parsed.title || file.name.replace(/\.(md|markdown|txt)$/i, '') || 'Imported';
|
||||
var payload = { title: noteTitle, body: parsed.body };
|
||||
if (activeFolderId) payload.folder_id = activeFolderId;
|
||||
var newNote = await api.post('/notes', payload);
|
||||
if (newNote && newNote.id) {
|
||||
if (parsed.tags.length) {
|
||||
await api.put('/tags/' + newNote.id, { tags: parsed.tags });
|
||||
}
|
||||
setActiveId(newNote.id);
|
||||
loadNote(newNote.id);
|
||||
loadNotes();
|
||||
loadStats();
|
||||
loadTags();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e);
|
||||
}
|
||||
document.body.removeChild(input);
|
||||
};
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────
|
||||
function handleSearch(e) {
|
||||
var val = e.target.value;
|
||||
@@ -996,6 +1196,7 @@
|
||||
return html`
|
||||
<${Topbar} title="Notes">
|
||||
<${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note<//>
|
||||
<${Button} variant="secondary" size="sm" onClick=${handleImport}>Import .md<//>
|
||||
<//>
|
||||
<div class="notes-app">
|
||||
<div class="notes-sidebar">
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"route": "/s/notes",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"icon": "📝",
|
||||
"description": "Markdown notes surface with folders, tags, and backlinks.",
|
||||
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, and import/export.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["db.write"],
|
||||
|
||||
Reference in New Issue
Block a user