diff --git a/CHANGELOG.md b/CHANGELOG.md
index aee3bb3..ff769f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,96 @@
# Changelog
+## [0.37.9] — 2026-03-21
+
+### Summary
+
+Preact NotesPane kit — Obsidian-grade composable note components replacing
+the old imperative `note-panel.js`, `note-graph.js`, and `notes.js` (~1,630
+lines deleted). Every piece independently importable. `NotesPane` is the
+default assembly; surfaces import individual pieces for custom layouts.
+
+### New
+
+- **NotesPane kit** (`src/js/sw/components/notes-pane/`) — 10 files:
+ - `NotesPane` — default assembly: list + editor + reader + graph + quick switcher
+ - `useNotes(opts)` — state machine (views, CRUD, pagination, search, folders,
+ sort, selection, backlinks, daily notes, wikilink navigation, pinning)
+ - `NoteList` — toolbar, search, folder/sort filters, tag cloud, pinned section,
+ selection bar, load-more pagination
+ - `NoteListItem` — single note row with title, time, preview, folder, tags, pin
+ - `NoteEditor` — title/folder/tags/content with CM6 integration, word/char count,
+ Ctrl+S save, Esc cancel, folder datalist autocomplete
+ - `NoteReader` — rendered markdown with wikilink chips, transclusion embeds,
+ sticky outline sidebar, daily note nav, backlinks panel, unlinked mentions
+ - `NoteGraph` — canvas force-directed graph with focus mode (single-click =
+ focus neighbors, double-click = open note), ghost node click → create,
+ folder colors, pan/zoom/drag, orphan toggle
+ - `renderNoteMarkdown` — marked+DOMPurify with wikilink chip rendering,
+ transclusion blocks, heading extraction for outline
+ - `SaveToNoteDialog` — create-new or append-to-existing, independently
+ importable by ChatPane or any surface
+ - `QuickSwitcher` — Cmd+K/Ctrl+K overlay: fuzzy title search, keyboard nav
+ (↑↓ Enter Esc), recent notes (localStorage), create-on-enter
+- **`sw.notesPane(container, opts)`** — SDK mount helper, lazy-imports Preact
+ NotesPane, returns imperative handle
+- **`sw-notes-pane.css`** — component styles with `sw-notes-pane` prefix (~500 lines)
+- **Obsidian-grade features:**
+ - Wikilinks (`[[Title]]`, `[[Title|Display]]`) → clickable chips
+ - Transclusions (`![[Title]]`) → inline embedded content (1-level)
+ - Quick Switcher (Ctrl+K) with fuzzy search + create-on-enter
+ - Outline sidebar (sticky, right side, scroll-to-heading)
+ - Daily notes with auto-template + prev/next day navigation
+ - Pinned notes (localStorage) at top of list
+ - Tag cloud with clickable filter chips
+ - Unlinked mentions (note titles in content not yet wikilinked)
+ - Graph focus mode (click = focus neighbors, walk the graph)
+ - Ghost node click → create missing note
+ - Word/character count in editor
+ - Keyboard shortcuts (Ctrl+S, Ctrl+N, Ctrl+D, Ctrl+K, Esc)
+
+### Deleted
+
+- `src/js/notes.js` (155 lines) — backward-compat shim
+- `src/js/note-panel.js` (976 lines) — old imperative NotePanel
+- `src/js/note-graph.js` (501 lines) — old canvas graph
+- `src/css/panels.css` — all `.note-*`, `.notes-*`, `.wikilink-*`,
+ `.transclusion-*`, `.save-note-*` rules stripped
+- `server/pages/templates/surfaces/chat.html` — `notes.js` script tag removed
+- `server/pages/templates/base.html` — `note-panel.js` + `note-graph.js`
+ script tags removed
+
+### Changed
+
+- `notes.html` — rewritten: Preact vendor globals + SDK `boot()` +
+ `sw.notesPane()` mount (same pattern as settings/admin surfaces)
+- `base.html` — added `sw-notes-pane.css` stylesheet link
+- `switchboard-sdk.js` — `sw.notes()` redirects to `sw.notesPane()` with
+ deprecation warning
+- `app.js` — removed `_initNotesListeners()` + `_registerNotesPanel()` calls
+- `ui-format.js` — `_openNoteFromTool()` made no-op (dies in v0.37.10)
+- `ui-core.js` — note tool-call link handler made no-op
+- `server/version.go` — version `0.37.8` → `0.37.9`
+- `VERSION` — `0.37.8` → `0.37.9`
+- `src/js/sw/sdk/index.js` — `sw.notesPane()` helper, `sw._sdk` → `0.37.9`
+
+### Design Notes
+
+- **Kit of Parts** — same composability pattern as ChatPane. Any surface can
+ import `NoteList` alone, or `NoteEditor` + `NoteReader`, or the full assembly.
+- **handleRef pattern** — same as ChatPane (no `forwardRef`/compat).
+- **Graph interaction model** — single click = focus (show 1-hop neighbors),
+ double click = open note. Walk the graph by clicking through focused nodes.
+ Ghost nodes (unresolved wikilinks) create on click via `navigateToLink`.
+- **Outline sidebar** — sticky right pane when note has >2 headings; falls back
+ to stacked layout on mobile (<600px).
+
+### Known Issues (minor, pre-tag)
+
+- Light mode: some notes-pane styles still reference dark theme defaults.
+ Needs CSS variable audit for `--bg-surface`, `--input-bg` in light theme.
+
+---
+
## [0.37.8] — 2026-03-21
### Summary
diff --git a/VERSION b/VERSION
index 331be98..4044086 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.37.8
+0.37.9
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 32e3986..695c79a 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -633,12 +633,42 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| v0.37.6 | Admin | Largest section count, no real-time |
| v0.37.7 | Team Admin | Inherits from Admin patterns |
| v0.37.8 | ChatPane ✅ | Composable kit (9 files), standalone stripped from old chat-pane.js |
-| v0.37.9 | NotesPane | Reusable component, NOT a surface |
+| v0.37.9 | NotesPane ✅ | Obsidian-grade kit (10 files), old notes code scorched (~1,630 lines) |
| v0.37.10 | Chat surface | Composes ChatPane + sidebar + panels |
-| v0.37.11 | Notes surface | Composes NotesPane + graph |
+| v0.37.11 | Notes surface | Composes NotesPane into full layout + enhancements (see below) |
| v0.37.12 | Extension surface container | `.pkg` mount point |
| v0.37.13 | Workflow surfaces | Form renderer, review views |
-| v0.37.# | Tag | Functionality restored, old code deleted |
+| v0.37.14 | Tag | Light mode CSS audit, functionality restored, old code deleted |
+
+**v0.37.11 — Notes Surface Enhancements:**
+
+NotesPane kit enhancements (add to `notes-pane/` as new components):
+- [ ] **Markdown toolbar** — bold, italic, heading, link, code, list, checkbox
+ buttons above editor textarea/CM6. High value for non-markdown users.
+- [ ] **Hover preview on wikilinks** — floating card on `[[Title]]` hover
+ showing first ~150 chars of target note. Obsidian signature UX.
+- [ ] **Breadcrumb trail** — navigation history bar when jumping through
+ wikilinks (note A → B → C). Back button walks the trail vs. list.
+- [ ] **Split view (editor + preview)** — side-by-side markdown editing with
+ live rendered preview. Editor and reader already exist as separate pieces.
+- [ ] **Note templates** — "New from template" in toolbar: Meeting Notes,
+ Project Brief, Weekly Review. Templates stored in `/templates/` folder.
+- [ ] **Slash commands in editor** — `/table`, `/code`, `/heading`, `/date`,
+ `/link` insert markdown snippets at cursor position.
+- [ ] **Graph minimap** — small overview of full graph in corner with
+ draggable viewport rectangle.
+
+Notes surface layout (compose kit into full-page layout):
+- [ ] Sidebar with folder tree + tag browser
+- [ ] Main content area with NotesPane
+- [ ] Resizable split panes
+- [ ] Mobile-responsive layout
+
+Polish (pre-tag):
+- [ ] Light mode CSS variable audit for `sw-notes-pane.css`
+- [ ] Smooth view transitions (fade/slide between list → reader → editor)
+- [ ] Note word count goal (optional target with progress bar)
+- [ ] Server-side favorites via note metadata (cross-device pinning)
---
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index 4e6f85e..366f716 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -19,6 +19,7 @@
+
@@ -112,8 +113,7 @@
-
-
+ {{/* note-panel.js + note-graph.js removed in v0.37.9 — see sw/components/notes-pane/ */}}
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html
index 087050c..6ed5253 100644
--- a/server/pages/templates/surfaces/chat.html
+++ b/server/pages/templates/surfaces/chat.html
@@ -403,7 +403,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* ui-settings.js and ui-admin.js removed in v0.37.7 — team admin is now at /team-admin */}}
-
+{{/* notes.js removed in v0.37.9 — see sw/components/notes-pane/ */}}
diff --git a/server/pages/templates/surfaces/notes.html b/server/pages/templates/surfaces/notes.html
index 4f51780..1b8bde0 100644
--- a/server/pages/templates/surfaces/notes.html
+++ b/server/pages/templates/surfaces/notes.html
@@ -1,7 +1,7 @@
{{/*
- Notes surface (Phase 2b).
- Server renders the layout shell. Existing notes.js + note-graph.js
- handle CRUD and rendering inside the containers.
+ Notes surface (v0.37.9).
+ Server renders the layout shell. SDK boots Preact NotesPane kit
+ into #notesMainMount via sw.notesPane().
*/}}
{{define "surface-notes"}}
@@ -16,40 +16,32 @@
{{end}}
{{define "css-notes"}}
-
-
-
-
+{{/* v0.37.9: Notes surface CSS — sw-notes-pane.css loaded in base.html */}}
{{end}}
{{define "scripts-notes"}}
-
-
-
-
-
-
-
-
-
-
-
-{{/* debug.js now loaded in base.html for all surfaces */}}
-
-
-
{{end}}
diff --git a/server/version.go b/server/version.go
index 6e724fb..e13a5bd 100644
--- a/server/version.go
+++ b/server/version.go
@@ -1,3 +1,3 @@
package main
-const Version = "0.37.8"
+const Version = "0.37.9"
diff --git a/src/css/panels.css b/src/css/panels.css
index 539bbc3..45a4c2c 100644
--- a/src/css/panels.css
+++ b/src/css/panels.css
@@ -41,7 +41,7 @@
flex: 1; overflow-y: auto; min-height: 0;
}
.side-panel-page { height: 100%; display: flex; flex-direction: column; }
-.note-panel-root { height: 100%; display: flex; flex-direction: column; }
+/* .note-panel-root removed in v0.37.9 — see sw-notes-pane.css */
.side-panel-empty {
display: flex; align-items: center; justify-content: center;
height: 100%; color: var(--text-3); font-size: 13px;
@@ -56,25 +56,7 @@
padding: 2rem; text-align: center;
}
-/* Notes inside secondary pane */
-.notes-actions-bar {
- display: flex; align-items: center; gap: 8px;
- padding: 8px 12px; border-bottom: 1px solid var(--border);
-}
-.workspace-secondary .notes-list, .side-panel .notes-list { padding: 4px 8px; }
-.workspace-secondary .notes-editor, .side-panel .notes-editor { padding: 8px 12px; }
-.workspace-secondary .note-read-content, .side-panel .note-read-content {
- max-height: none; flex: 1; overflow-y: auto;
-}
-.workspace-secondary .notes-content-input, .side-panel .notes-content-input {
- min-height: 150px;
-}
-#notesListView {
- flex: 1; overflow-y: auto; min-height: 0;
-}
-#notesEditorView {
- flex: 1; overflow-y: auto; min-height: 0;
-}
+/* Notes panel CSS removed in v0.37.9 — see sw-notes-pane.css */
/* Side panel overlay (mobile tap-to-close, mirrors sidebar-overlay) */
.side-panel-overlay {
@@ -230,317 +212,5 @@
@keyframes dot-pulse { 0%,60%,100% { opacity: 0.4; } 30% { opacity: 1; } }
-/* ── Sidebar Notes Button ───────────────── */
-
-.sidebar-notes-btn {
- padding: 4px 8px; border-top: 1px solid var(--border);
- flex-shrink: 0;
-}
-.sb-notes {
- display: flex; align-items: center; gap: 8px; width: 100%;
- padding: 8px 12px; background: none; border: none;
- border-radius: var(--radius); color: var(--text-2);
- cursor: pointer; font-size: 13px; font-family: var(--font);
- transition: background var(--transition), color var(--transition);
-}
-.sb-notes:hover { background: var(--bg-hover); color: var(--text); }
-.sidebar.collapsed .sidebar-notes-btn .sb-label { display: none; }
-.sidebar.collapsed .sb-notes { justify-content: center; padding: 8px; }
-
-/* ── Notes (inside side panel) ──────────── */
-
-/* Notes toolbar and selection bar now live inside the side panel */
-
-.notes-toolbar {
- display: flex; gap: 8px; padding: 8px 12px;
- border-bottom: 1px solid var(--border);
- background: var(--bg-raised);
- flex-wrap: wrap;
-}
-.notes-search-row {
- padding: 8px 12px 4px;
-}
-.notes-search-input {
- width: 100%; background: var(--input-bg); border: 1px solid var(--border);
- border-radius: var(--radius); color: var(--text);
- font-size: 13px; font-family: var(--font); padding: 6px 10px;
- outline: none; transition: border-color var(--transition);
-}
-.notes-search-input:focus { border-color: var(--accent); }
-.notes-filter-row {
- display: flex; gap: 8px; padding: 0 12px 8px;
-}
-.notes-editor-header {
- display: flex; align-items: center; gap: 6px;
- padding: 8px 12px; border-bottom: 1px solid var(--border);
- background: var(--bg-raised);
-}
-.notes-meta-row {
- display: flex; gap: 8px; margin-bottom: 8px;
-}
-.notes-meta-row input { flex: 1; }
-.notes-content-container { min-height: 200px; }
-.notes-read-view { padding: 12px; }
-.notes-search-wrap {
- display: flex; align-items: center; gap: 6px; flex: 1;
- background: var(--bg-surface); border: 1px solid var(--border);
- border-radius: var(--radius); padding: 0 10px;
-}
-.notes-search-wrap svg { color: var(--text-3); flex-shrink: 0; }
-.notes-search-wrap input {
- flex: 1; background: none; border: none; color: var(--text);
- font-size: 13px; font-family: var(--font); padding: 6px 0;
- outline: none;
-}
-.notes-filter-select {
- background: var(--bg-surface); border: 1px solid var(--border);
- border-radius: var(--radius); color: var(--text); font-size: 12px;
- padding: 4px 8px; font-family: var(--font); cursor: pointer;
- max-width: 160px;
-}
-
-/* Notes toolbar and selection bar now live inside the side panel */
-
-/* Selection bar */
-.notes-selection-bar {
- display: flex; align-items: center; gap: 10px;
- padding: 6px 12px;
- background: var(--accent-dim); border-top: 1px solid var(--border);
- font-size: 12px;
- position: sticky; bottom: 0;
- z-index: 5;
-}
-.notes-select-all {
- display: flex; align-items: center; gap: 6px;
- color: var(--text-2); cursor: pointer; font-size: 12px;
-}
-.notes-select-all input { cursor: pointer; }
-
-.notes-list { padding: 8px; }
-
-.note-item {
- display: flex; align-items: flex-start; gap: 8px;
- padding: 10px 12px; border-radius: var(--radius);
- cursor: pointer; border: 1px solid transparent;
- transition: background var(--transition), border-color var(--transition);
-}
-.note-item:hover {
- background: var(--bg-hover); border-color: var(--border);
-}
-.note-item.selected {
- background: var(--accent-dim); border-color: var(--accent);
-}
-.note-select-col { flex-shrink: 0; padding-top: 2px; }
-.note-checkbox { cursor: pointer; accent-color: var(--accent); }
-.note-content-col { flex: 1; min-width: 0; }
-.note-item-header {
- display: flex; align-items: baseline; justify-content: space-between;
- gap: 8px; margin-bottom: 2px;
-}
-.note-item-title {
- font-size: 13px; font-weight: 600; color: var(--text);
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-.note-item-time { font-size: 11px; color: var(--text-3); flex-shrink: 0; }
-.note-preview {
- font-size: 12px; color: var(--text-3); line-height: 1.4;
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- max-height: 1.4em;
-}
-.note-headline {
- font-size: 12px; color: var(--text-3); line-height: 1.4;
-}
-.note-headline mark {
- background: var(--accent-dim); color: var(--accent);
- padding: 0 2px; border-radius: 2px;
-}
-.note-item-meta {
- display: flex; align-items: center; gap: 4px;
- margin-top: 4px; flex-wrap: wrap;
-}
-.note-folder {
- font-size: 11px; color: var(--text-3); font-family: var(--mono);
- background: var(--bg-raised); padding: 1px 6px; border-radius: 3px;
-}
-.note-tag {
- font-size: 10px; color: var(--purple); background: var(--purple-dim);
- padding: 1px 6px; border-radius: 3px;
-}
-
-/* Notes editor */
-.notes-editor { padding: 12px; }
-.notes-editor-toolbar {
- display: flex; align-items: center; justify-content: space-between;
- margin-bottom: 8px;
-}
-
-/* Note read mode */
-.note-read-title {
- font-size: 18px; font-weight: 600; color: var(--text);
- margin: 0 0 6px; line-height: 1.3;
-}
-.note-read-meta {
- display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
- margin-bottom: 12px;
-}
-.note-read-content {
- font-size: var(--msg-font, 14px); line-height: 1.7;
- max-height: 50vh; overflow-y: auto;
- padding: 12px; background: var(--bg-surface);
- border: 1px solid var(--border); border-radius: var(--radius);
-}
-.notes-back-btn {
- background: none; border: none; color: var(--text-3);
- cursor: pointer; font-size: 12px; font-family: var(--font);
- padding: 4px 0; margin-bottom: 8px;
- transition: color var(--transition);
-}
-.notes-back-btn:hover { color: var(--text); }
-.notes-title-input {
- font-size: 16px !important; font-weight: 600;
- border: 1px solid var(--border) !important;
- padding: 8px 10px !important;
-}
-.notes-folder-input, .notes-tags-input {
- font-size: 12px !important; font-family: var(--mono);
-}
-.notes-content-input {
- font-size: 13px !important; line-height: 1.6 !important;
- min-height: 200px; resize: vertical;
- font-family: var(--mono); border: 1px solid var(--border) !important;
-}
-.notes-editor input, .notes-editor textarea {
- width: 100%; background: var(--bg-surface); color: var(--text);
- border: 1px solid var(--border); border-radius: var(--radius);
- padding: 6px 10px; font-family: var(--font); font-size: var(--msg-font, 14px);
- outline: none; transition: border-color var(--transition);
-}
-.notes-editor input:focus, .notes-editor textarea:focus {
- border-color: var(--accent);
-}
-.notes-editor-actions {
- display: flex; gap: 8px; margin-top: 12px;
-}
-
-/* ── Note Backlinks ──────────────────────── */
-
-.note-backlinks {
- margin-top: 16px; border-top: 1px solid var(--border); padding-top: 12px;
-}
-.note-backlinks-header {
- display: flex; align-items: center; gap: 8px;
- font-size: 12px; color: var(--text-2); cursor: pointer;
- padding: 4px 0; user-select: none;
-}
-.note-backlinks-header:hover { color: var(--text); }
-.note-backlinks-header .badge {
- background: var(--accent); color: var(--text-on-color); font-size: 10px;
- padding: 1px 6px; border-radius: 10px;
-}
-.note-backlinks-list { margin-top: 6px; }
-.note-backlink-item {
- display: flex; align-items: center; gap: 8px;
- padding: 6px 8px; border-radius: var(--radius); cursor: pointer;
- font-size: 13px; transition: background var(--transition);
-}
-.note-backlink-item:hover { background: var(--bg-hover); }
-.note-backlink-title { color: var(--accent); font-weight: 500; }
-.note-backlink-folder { font-size: 11px; color: var(--text-3); }
-
-/* ── Wikilink Chips (Read Mode) ──────────── */
-
-.wikilink-chip {
- display: inline-block; padding: 1px 6px; margin: 0 1px;
- border-radius: 4px; cursor: pointer; font-size: 0.9em;
- font-weight: 500; text-decoration: none; vertical-align: baseline;
- background: rgba(var(--accent-rgb, 99, 102, 241), 0.15);
- color: var(--accent, #6366f1);
- transition: background var(--transition);
-}
-.wikilink-chip:hover {
- background: rgba(var(--accent-rgb, 99, 102, 241), 0.25);
-}
-.wikilink-transclusion {
- border-left: 2px solid var(--accent, #6366f1);
- font-style: italic;
-}
-
-/* ── Transclusion Embeds ─────────────────── */
-
-.transclusion-embed {
- margin: 8px 0; border: 1px solid var(--border);
- border-left: 3px solid var(--accent, #6366f1);
- border-radius: var(--radius); overflow: hidden;
- background: var(--bg-surface);
-}
-.transclusion-header {
- padding: 6px 10px; background: var(--bg-raised);
- border-bottom: 1px solid var(--border); font-size: 12px;
-}
-.transclusion-content {
- padding: 10px 12px; font-size: 13px; line-height: 1.5;
-}
-.transclusion-content .msg-text { margin: 0; }
-
-/* ── Graph View ──────────────────────────── */
-
-.notes-graph-view {
- display: flex; flex-direction: column; height: 100%;
-}
-.notes-graph-toolbar {
- display: flex; align-items: center; gap: 8px;
- padding: 6px 10px; border-bottom: 1px solid var(--border);
- background: var(--bg-surface); flex-shrink: 0;
-}
-.notes-graph-stats {
- flex: 1; text-align: center; font-size: 11px; color: var(--text-3);
-}
-#noteGraphCanvas {
- flex: 1; width: 100%; min-height: 200px;
- background: var(--bg);
-}
-
-/* ── Daily Note Navigation ───────────────── */
-
-.note-daily-nav {
- display: flex; align-items: center; justify-content: center;
- gap: 12px; padding: 6px 10px;
- border-bottom: 1px solid var(--border); background: var(--bg-surface);
-}
-.note-daily-date {
- font-size: 13px; font-weight: 500; color: var(--text);
- min-width: 100px; text-align: center;
-}
-
-/* ── Save-to-Note Modal ──────────────────── */
-
-.save-to-note-modal {
- max-width: 460px; width: 90%;
-}
-.save-to-note-modal h3 { margin: 0 0 12px; font-size: 16px; }
-.save-note-mode-toggle {
- display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px;
-}
-.save-note-mode-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
-.save-note-preview {
- max-height: 120px; overflow-y: auto; padding: 8px;
- border: 1px solid var(--border); border-radius: var(--radius);
- background: var(--bg); font-size: 12px; line-height: 1.5;
-}
-.save-note-search-results {
- max-height: 160px; overflow-y: auto; margin-top: 4px;
- border: 1px solid var(--border); border-radius: var(--radius);
- background: var(--bg-surface);
-}
-.save-note-search-results:empty { display: none; }
-.save-note-result {
- padding: 6px 10px; cursor: pointer; font-size: 13px;
- transition: background var(--transition);
-}
-.save-note-result:hover, .save-note-result.selected {
- background: var(--bg-hover);
-}
-.save-note-result.selected {
- border-left: 3px solid var(--accent); font-weight: 500;
-}
+/* ── Old notes CSS removed in v0.37.9 — see sw-notes-pane.css ── */
diff --git a/src/css/sw-notes-pane.css b/src/css/sw-notes-pane.css
new file mode 100644
index 0000000..99c3595
--- /dev/null
+++ b/src/css/sw-notes-pane.css
@@ -0,0 +1,897 @@
+/* ==========================================
+ Chat Switchboard — Preact NotesPane Kit CSS
+ ==========================================
+ v0.37.9: Styles for the composable NotesPane kit.
+ sw-notes-pane prefixed, no conflicts with legacy.
+ ========================================== */
+
+/* ── Container ─────────────────────────────── */
+
+.sw-notes-pane {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ background: var(--bg, #0e0e10);
+}
+
+/* ── Toolbar ───────────────────────────────── */
+
+.sw-notes-pane__toolbar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border, #2e2e35);
+ background: var(--bg-surface, #18181b);
+ flex-shrink: 0;
+}
+
+.sw-notes-pane__toolbar-spacer { flex: 1; }
+
+.sw-notes-pane__toolbar-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: none;
+ border: 1px solid var(--border, #2e2e35);
+ border-radius: 4px;
+ color: var(--text-2, #9898a8);
+ font-size: 12px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
+ white-space: nowrap;
+}
+
+.sw-notes-pane__toolbar-btn:hover {
+ color: var(--accent, #6c9fff);
+ border-color: var(--accent, #6c9fff);
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+}
+
+/* ── Search + Filters ──────────────────────── */
+
+.sw-notes-pane__search-row {
+ display: flex;
+ padding: 6px 10px 0;
+ flex-shrink: 0;
+}
+
+.sw-notes-pane__search-input {
+ width: 100%;
+ background: var(--input-bg, #1a1a1f);
+ border: 1px solid var(--border, #2e2e35);
+ border-radius: 4px;
+ color: var(--text, #e8e8ed);
+ font-size: 12px;
+ font-family: inherit;
+ padding: 5px 8px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.sw-notes-pane__search-input:focus {
+ border-color: var(--accent, #6c9fff);
+}
+
+.sw-notes-pane__filter-row {
+ display: flex;
+ gap: 4px;
+ padding: 4px 10px 6px;
+ flex-shrink: 0;
+}
+
+.sw-notes-pane__filter-select {
+ flex: 1;
+ background: var(--input-bg, #1a1a1f);
+ border: 1px solid var(--border, #2e2e35);
+ border-radius: 4px;
+ color: var(--text-2, #9898a8);
+ font-size: 11px;
+ font-family: inherit;
+ padding: 3px 6px;
+ cursor: pointer;
+ outline: none;
+}
+
+.sw-notes-pane__filter-select:focus {
+ border-color: var(--accent, #6c9fff);
+}
+
+/* ── Tag Cloud ─────────────────────────────── */
+
+.sw-notes-pane__tag-cloud {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px;
+ padding: 0 10px 6px;
+ flex-shrink: 0;
+}
+
+.sw-notes-pane__tag-chip {
+ display: inline-flex;
+ padding: 1px 6px;
+ background: var(--purple-dim, rgba(167,139,250,0.1));
+ color: var(--purple, #a78bfa);
+ font-size: 10px;
+ border-radius: 3px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.sw-notes-pane__tag-chip:hover,
+.sw-notes-pane__tag-chip--active {
+ border-color: var(--purple, #a78bfa);
+ background: rgba(167,139,250,0.18);
+}
+
+/* ── Note List ─────────────────────────────── */
+
+.sw-notes-pane__list {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.sw-notes-pane__list-empty {
+ padding: 24px 16px;
+ text-align: center;
+ color: var(--text-3, #6b6b7b);
+ font-size: 13px;
+}
+
+.sw-notes-pane__pinned-divider {
+ padding: 2px 10px;
+ font-size: 10px;
+ color: var(--text-3, #6b6b7b);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-bottom: 1px solid var(--border, #2e2e35);
+ background: var(--bg-surface, #18181b);
+}
+
+/* ── Note List Item ────────────────────────── */
+
+.sw-notes-pane__item {
+ display: flex;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border, #2e2e35);
+ cursor: pointer;
+ transition: background 0.12s;
+}
+
+.sw-notes-pane__item:hover {
+ background: var(--bg-hover, #2a2a30);
+}
+
+.sw-notes-pane__item--selected {
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+}
+
+.sw-notes-pane__item-checkbox {
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.sw-notes-pane__item-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.sw-notes-pane__item-header {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+}
+
+.sw-notes-pane__item-title {
+ flex: 1;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text, #e8e8ed);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sw-notes-pane__item-time {
+ flex-shrink: 0;
+ font-size: 11px;
+ color: var(--text-3, #6b6b7b);
+}
+
+.sw-notes-pane__item-preview {
+ font-size: 12px;
+ color: var(--text-2, #9898a8);
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sw-notes-pane__item-preview mark {
+ background: var(--warning-dim, rgba(234,179,8,0.2));
+ color: var(--warning-light, #fbbf24);
+ border-radius: 2px;
+ padding: 0 1px;
+}
+
+.sw-notes-pane__item-meta {
+ display: flex;
+ gap: 4px;
+ margin-top: 3px;
+ flex-wrap: wrap;
+}
+
+.sw-notes-pane__item-folder {
+ font-size: 10px;
+ color: var(--text-3, #6b6b7b);
+ background: var(--bg-raised, #222227);
+ padding: 0 4px;
+ border-radius: 2px;
+}
+
+.sw-notes-pane__item-tag {
+ font-size: 10px;
+ color: var(--purple, #a78bfa);
+ background: var(--purple-dim, rgba(167,139,250,0.1));
+ padding: 0 4px;
+ border-radius: 2px;
+}
+
+.sw-notes-pane__item-pin {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ color: var(--text-3, #6b6b7b);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px;
+ opacity: 0.4;
+ transition: opacity 0.15s;
+}
+
+.sw-notes-pane__item:hover .sw-notes-pane__item-pin,
+.sw-notes-pane__item-pin--active {
+ opacity: 1;
+}
+
+.sw-notes-pane__item-pin--active {
+ color: var(--accent, #6c9fff);
+}
+
+/* ── Selection Bar ─────────────────────────── */
+
+.sw-notes-pane__selection-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--bg-surface, #18181b);
+ border-top: 1px solid var(--border, #2e2e35);
+ font-size: 12px;
+ color: var(--text-2, #9898a8);
+ flex-shrink: 0;
+}
+
+.sw-notes-pane__selection-bar label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+}
+
+/* ── Load More ─────────────────────────────── */
+
+.sw-notes-pane__load-more {
+ display: block;
+ width: 100%;
+ padding: 8px;
+ background: none;
+ border: none;
+ border-top: 1px solid var(--border, #2e2e35);
+ color: var(--accent, #6c9fff);
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.sw-notes-pane__load-more:hover {
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+}
+
+/* ── Editor ────────────────────────────────── */
+
+.sw-notes-pane__editor-header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border, #2e2e35);
+ background: var(--bg-surface, #18181b);
+ flex-shrink: 0;
+}
+
+.sw-notes-pane__editor-header-spacer { flex: 1; }
+
+.sw-notes-pane__editor {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 10px;
+ gap: 6px;
+}
+
+.sw-notes-pane__editor-title {
+ width: 100%;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--border, #2e2e35);
+ color: var(--text, #e8e8ed);
+ font-size: 16px;
+ font-weight: 600;
+ font-family: inherit;
+ padding: 4px 0;
+ outline: none;
+}
+
+.sw-notes-pane__editor-title:focus {
+ border-color: var(--accent, #6c9fff);
+}
+
+.sw-notes-pane__editor-meta {
+ display: flex;
+ gap: 6px;
+}
+
+.sw-notes-pane__editor-meta input {
+ flex: 1;
+ background: var(--input-bg, #1a1a1f);
+ border: 1px solid var(--border, #2e2e35);
+ border-radius: 4px;
+ color: var(--text-2, #9898a8);
+ font-size: 11px;
+ font-family: inherit;
+ padding: 3px 6px;
+ outline: none;
+}
+
+.sw-notes-pane__editor-meta input:focus {
+ border-color: var(--accent, #6c9fff);
+ color: var(--text, #e8e8ed);
+}
+
+.sw-notes-pane__editor-content {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.sw-notes-pane__editor-textarea {
+ flex: 1;
+ width: 100%;
+ min-height: 120px;
+ background: var(--input-bg, #1a1a1f);
+ border: 1px solid var(--border, #2e2e35);
+ border-radius: 4px;
+ color: var(--text, #e8e8ed);
+ font-size: 13px;
+ font-family: var(--mono, 'JetBrains Mono', monospace);
+ line-height: 1.6;
+ padding: 8px;
+ resize: none;
+ outline: none;
+}
+
+.sw-notes-pane__editor-textarea:focus {
+ border-color: var(--accent, #6c9fff);
+}
+
+.sw-notes-pane__editor-footer {
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+ color: var(--text-3, #6b6b7b);
+ padding-top: 4px;
+ flex-shrink: 0;
+}
+
+/* ── Reader ────────────────────────────────── */
+
+.sw-notes-pane__reader {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* With outline: side-by-side layout */
+.sw-notes-pane__reader--with-outline {
+ flex-direction: row;
+}
+
+.sw-notes-pane__reader-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px 16px;
+ min-height: 0;
+}
+
+/* ── Outline Sidebar (sticky, right side) ─── */
+
+.sw-notes-pane__outline-sidebar {
+ width: 160px;
+ flex-shrink: 0;
+ overflow-y: auto;
+ padding: 12px 8px;
+ border-left: 1px solid var(--border, #2e2e35);
+ position: sticky;
+ top: 0;
+ align-self: flex-start;
+ max-height: 100%;
+}
+
+@media (max-width: 600px) {
+ .sw-notes-pane__reader--with-outline { flex-direction: column; }
+ .sw-notes-pane__outline-sidebar {
+ width: 100%;
+ border-left: none;
+ border-top: 1px solid var(--border, #2e2e35);
+ position: static;
+ max-height: none;
+ }
+}
+
+.sw-notes-pane__reader-title {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text, #e8e8ed);
+ margin: 0 0 4px;
+ line-height: 1.3;
+}
+
+.sw-notes-pane__reader-meta {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+}
+
+.sw-notes-pane__reader-content {
+ font-size: 14px;
+ line-height: 1.7;
+ color: var(--text, #e8e8ed);
+}
+
+/* Reader markdown */
+.sw-notes-pane__reader-content p { margin: 0 0 0.6em; }
+.sw-notes-pane__reader-content p:last-child { margin-bottom: 0; }
+
+.sw-notes-pane__reader-content pre {
+ background: var(--bg-surface, #18181b);
+ border: 1px solid var(--border, #2e2e35);
+ border-radius: 6px;
+ padding: 10px 12px;
+ overflow-x: auto;
+ margin: 8px 0;
+ font-size: 12px;
+ line-height: 1.5;
+ font-family: var(--mono, 'JetBrains Mono', monospace);
+}
+
+.sw-notes-pane__reader-content code {
+ background: var(--bg-surface, #18181b);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 0.88em;
+ font-family: var(--mono, 'JetBrains Mono', monospace);
+}
+
+.sw-notes-pane__reader-content pre code {
+ background: none; padding: 0; border-radius: 0; font-size: inherit;
+}
+
+.sw-notes-pane__reader-content ul,
+.sw-notes-pane__reader-content ol { margin: 4px 0; padding-left: 1.5em; }
+
+.sw-notes-pane__reader-content blockquote {
+ border-left: 3px solid var(--accent, #6c9fff);
+ padding: 4px 12px;
+ margin: 8px 0;
+ color: var(--text-2, #9898a8);
+}
+
+.sw-notes-pane__reader-content a {
+ color: var(--accent, #6c9fff);
+ text-decoration: none;
+}
+.sw-notes-pane__reader-content a:hover { text-decoration: underline; }
+
+.sw-notes-pane__reader-content table { border-collapse: collapse; margin: 8px 0; font-size: 13px; }
+.sw-notes-pane__reader-content th,
+.sw-notes-pane__reader-content td { border: 1px solid var(--border, #2e2e35); padding: 4px 8px; }
+.sw-notes-pane__reader-content th { background: var(--bg-surface, #18181b); font-weight: 600; }
+
+/* ── Daily Nav ─────────────────────────────── */
+
+.sw-notes-pane__daily-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0 8px;
+ font-size: 12px;
+ color: var(--text-2, #9898a8);
+}
+
+.sw-notes-pane__daily-nav button:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+/* ── Outline / TOC ─────────────────────────── */
+
+/* Outline items shared by sidebar + inline fallback */
+.sw-notes-pane__outline-sidebar,
+.sw-notes-pane__outline {
+ /* shared base */
+}
+
+.sw-notes-pane__outline-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-3, #6b6b7b);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 0 0 4px;
+}
+
+.sw-notes-pane__outline-item {
+ display: block;
+ padding: 2px 0;
+ font-size: 12px;
+ color: var(--text-2, #9898a8);
+ cursor: pointer;
+ text-decoration: none;
+ transition: color 0.12s;
+}
+
+.sw-notes-pane__outline-item:hover {
+ color: var(--accent, #6c9fff);
+}
+
+.sw-notes-pane__outline-item--h2 { padding-left: 12px; }
+.sw-notes-pane__outline-item--h3 { padding-left: 24px; }
+
+/* ── Backlinks ─────────────────────────────── */
+
+.sw-notes-pane__backlinks {
+ border-top: 1px solid var(--border, #2e2e35);
+ padding: 8px 0;
+ margin-top: 12px;
+}
+
+.sw-notes-pane__backlinks-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-3, #6b6b7b);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.sw-notes-pane__backlinks-badge {
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+ color: var(--accent, #6c9fff);
+ font-size: 10px;
+ padding: 0 5px;
+ border-radius: 8px;
+ font-weight: 600;
+}
+
+.sw-notes-pane__backlinks-list {
+ margin-top: 4px;
+}
+
+.sw-notes-pane__backlink-item {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ padding: 3px 0;
+ font-size: 12px;
+ cursor: pointer;
+ color: var(--accent, #6c9fff);
+ transition: color 0.12s;
+}
+
+.sw-notes-pane__backlink-item:hover {
+ color: var(--accent-hover, #84b0ff);
+}
+
+.sw-notes-pane__backlink-folder {
+ font-size: 10px;
+ color: var(--text-3, #6b6b7b);
+}
+
+/* ── Wikilinks ─────────────────────────────── */
+
+.sw-notes-pane__wikilink {
+ display: inline;
+ padding: 1px 5px;
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+ color: var(--accent, #6c9fff);
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 0.92em;
+ transition: background 0.12s;
+}
+
+.sw-notes-pane__wikilink:hover {
+ background: rgba(108,159,255,0.22);
+}
+
+.sw-notes-pane__wikilink--transclusion {
+ color: var(--purple, #a78bfa);
+ background: var(--purple-dim, rgba(167,139,250,0.1));
+}
+
+.sw-notes-pane__wikilink--transclusion:hover {
+ background: rgba(167,139,250,0.2);
+}
+
+.sw-notes-pane__transclusion {
+ border: 1px solid var(--border, #2e2e35);
+ border-left: 3px solid var(--purple, #a78bfa);
+ border-radius: 4px;
+ margin: 8px 0;
+ overflow: hidden;
+}
+
+.sw-notes-pane__transclusion-header {
+ padding: 4px 8px;
+ background: var(--bg-surface, #18181b);
+ font-size: 11px;
+}
+
+.sw-notes-pane__transclusion-content {
+ padding: 8px 12px;
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--text-2, #9898a8);
+}
+
+/* ── Unlinked Mentions ─────────────────────── */
+
+.sw-notes-pane__unlinked {
+ border-top: 1px solid var(--border, #2e2e35);
+ padding: 8px 0;
+ margin-top: 8px;
+}
+
+.sw-notes-pane__unlinked-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-3, #6b6b7b);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 0 0 4px;
+}
+
+.sw-notes-pane__unlinked-item {
+ display: inline-flex;
+ padding: 1px 6px;
+ margin: 1px 2px;
+ background: var(--bg-raised, #222227);
+ color: var(--text-2, #9898a8);
+ font-size: 11px;
+ border-radius: 3px;
+ cursor: pointer;
+ border: 1px dashed var(--border, #2e2e35);
+ transition: border-color 0.12s, color 0.12s;
+}
+
+.sw-notes-pane__unlinked-item:hover {
+ border-color: var(--accent, #6c9fff);
+ color: var(--accent, #6c9fff);
+}
+
+/* ── Graph ─────────────────────────────────── */
+
+.sw-notes-pane__graph {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.sw-notes-pane__graph-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border, #2e2e35);
+ background: var(--bg-surface, #18181b);
+ flex-shrink: 0;
+}
+
+.sw-notes-pane__graph-stats {
+ font-size: 11px;
+ color: var(--text-3, #6b6b7b);
+}
+
+.sw-notes-pane__graph-canvas-wrap {
+ flex: 1;
+ min-height: 0;
+ position: relative;
+}
+
+.sw-notes-pane__graph-canvas-wrap canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* ── Quick Switcher ────────────────────────── */
+
+.sw-notes-pane__switcher-overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--overlay, rgba(0,0,0,0.55));
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 15vh;
+ z-index: 9999;
+}
+
+.sw-notes-pane__switcher {
+ width: min(420px, 90vw);
+ background: var(--bg-surface, #18181b);
+ border: 1px solid var(--border, #2e2e35);
+ border-radius: 8px;
+ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
+ overflow: hidden;
+}
+
+.sw-notes-pane__switcher-input {
+ width: 100%;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--border, #2e2e35);
+ color: var(--text, #e8e8ed);
+ font-size: 15px;
+ font-family: inherit;
+ padding: 12px 14px;
+ outline: none;
+}
+
+.sw-notes-pane__switcher-results {
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.sw-notes-pane__switcher-section {
+ padding: 4px 12px 2px;
+ font-size: 10px;
+ color: var(--text-3, #6b6b7b);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.sw-notes-pane__switcher-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.sw-notes-pane__switcher-item:hover,
+.sw-notes-pane__switcher-item--active {
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+}
+
+.sw-notes-pane__switcher-item-title {
+ flex: 1;
+ font-size: 13px;
+ color: var(--text, #e8e8ed);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sw-notes-pane__switcher-item-folder {
+ font-size: 10px;
+ color: var(--text-3, #6b6b7b);
+}
+
+.sw-notes-pane__switcher-create {
+ padding: 8px 14px;
+ border-top: 1px solid var(--border, #2e2e35);
+ font-size: 12px;
+ color: var(--accent, #6c9fff);
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.sw-notes-pane__switcher-create:hover {
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+}
+
+.sw-notes-pane__switcher-hint {
+ padding: 6px 14px;
+ font-size: 10px;
+ color: var(--text-3, #6b6b7b);
+ text-align: right;
+ border-top: 1px solid var(--border, #2e2e35);
+}
+
+/* ── Save-to-Note Dialog ───────────────────── */
+
+.sw-notes-pane__save-mode {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 10px;
+ font-size: 13px;
+}
+
+.sw-notes-pane__save-mode label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ color: var(--text-2, #9898a8);
+}
+
+.sw-notes-pane__save-search-results {
+ max-height: 150px;
+ overflow-y: auto;
+ margin-top: 4px;
+}
+
+.sw-notes-pane__save-search-item {
+ padding: 6px 8px;
+ font-size: 12px;
+ color: var(--text, #e8e8ed);
+ cursor: pointer;
+ border-radius: 3px;
+ transition: background 0.1s;
+}
+
+.sw-notes-pane__save-search-item:hover,
+.sw-notes-pane__save-search-item--selected {
+ background: var(--accent-dim, rgba(108,159,255,0.12));
+}
+
+.sw-notes-pane__save-preview {
+ max-height: 120px;
+ overflow-y: auto;
+ padding: 8px;
+ background: var(--bg-raised, #222227);
+ border-radius: 4px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--text-2, #9898a8);
+}
+
+/* ── Shared: loading spinner ───────────────── */
+
+.sw-notes-pane__loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ color: var(--text-3, #6b6b7b);
+ font-size: 13px;
+}
diff --git a/src/js/app.js b/src/js/app.js
index 72a883a..bf44851 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -322,10 +322,9 @@ function initListeners() {
_initChatListeners(); // from chat.js
// _initSettingsListeners and _initAdminListeners removed in v0.37.7
- _initNotesListeners(); // from notes.js
+ // _initNotesListeners + _registerNotesPanel removed in v0.37.9 — see sw/components/notes-pane/
_initFileListeners(); // from files.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
- _registerNotesPanel(); // from notes.js — register notes with PanelRegistry
_registerProjectPanel(); // from projects-ui.js — register project detail panel
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
diff --git a/src/js/note-graph.js b/src/js/note-graph.js
deleted file mode 100644
index b7a00eb..0000000
--- a/src/js/note-graph.js
+++ /dev/null
@@ -1,501 +0,0 @@
-// ==========================================
-// Chat Switchboard — Note Graph (Canvas)
-// ==========================================
-// Force-directed graph visualization of note
-// connections. Lazy-loaded — initializes only
-// when the graph view is opened.
-// ==========================================
-//
-// Exports: window.openNoteGraph,window.closeNoteGraph,window._render,window._graphResetZoom,window._graphToggleOrphans,window.invalidateNoteGraph
-
-
-var _graphData = null; // cached { nodes, edges, unresolved }
-var _graphState = null; // { panX, panY, zoom, animId, canvas, ctx }
-var _graphDirty = true; // invalidation flag
-
-// ── Force Simulation Parameters ─────────────
-
-const SIM = {
- repulsion: 800,
- attraction: 0.006,
- edgeLength: 130,
- damping: 0.88,
- centerGravity: 0.012,
- maxVelocity: 8,
- ticksPerFrame: 3,
- coolThreshold: 0.5, // total KE below this → stop sim
-};
-
-const BASE_RADIUS = 6;
-
-// ── Color Palette for Folders ───────────────
-
-const FOLDER_COLORS = [
- '#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6',
- '#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316',
-];
-
-var _folderColorMap = {};
-
-function folderColor(path) {
- if (!path || path === '/') return '#6b7280';
- if (!_folderColorMap[path]) {
- const idx = Object.keys(_folderColorMap).length % FOLDER_COLORS.length;
- _folderColorMap[path] = FOLDER_COLORS[idx];
- }
- return _folderColorMap[path];
-}
-
-// ── Graph Initialization ────────────────────
-
-async function openNoteGraph() {
- const listView = document.getElementById('notesListView');
- const editorView = document.getElementById('notesEditorView');
- const graphView = document.getElementById('notesGraphView');
- if (!graphView) return;
-
- listView.style.display = 'none';
- editorView.style.display = 'none';
- graphView.style.display = '';
-
- const canvas = document.getElementById('noteGraphCanvas');
- if (!canvas) return;
-
- // Size canvas to panel
- const rect = canvas.parentElement.getBoundingClientRect();
- canvas.width = rect.width;
- canvas.height = rect.height - 40; // toolbar height
-
- const ctx = canvas.getContext('2d');
-
- _graphState = {
- panX: 0, panY: 0, zoom: 1,
- animId: null, canvas, ctx,
- hoveredNode: null, dragNode: null,
- isDragging: false, lastMouse: null,
- showOrphans: true,
- };
-
- // Load data
- try {
- _graphData = await API.getNoteGraph();
- _prepareGraphNodes(canvas);
- _updateGraphStats();
- _startSimulation();
- _attachGraphListeners(canvas);
- } catch (e) {
- ctx.fillStyle = 'var(--text, #ccc)';
- ctx.font = '14px sans-serif';
- ctx.textAlign = 'center';
- ctx.fillText('Failed to load graph: ' + e.message, canvas.width / 2, canvas.height / 2);
- }
-}
-
-function closeNoteGraph() {
- if (_graphState?.animId) {
- cancelAnimationFrame(_graphState.animId);
- _graphState.animId = null;
- }
- const graphView = document.getElementById('notesGraphView');
- if (graphView) graphView.style.display = 'none';
- document.getElementById('notesListView').style.display = '';
-}
-
-// ── Prepare Nodes with Physics State ────────
-
-function _prepareGraphNodes(canvas) {
- if (!_graphData) return;
-
- const cx = canvas.width / 2;
- const cy = canvas.height / 2;
-
- // Build lookup for edge source/target
- const nodeMap = {};
- for (const n of _graphData.nodes) {
- n.x = cx + (Math.random() - 0.5) * canvas.width * 0.5;
- n.y = cy + (Math.random() - 0.5) * canvas.height * 0.5;
- n.vx = 0; n.vy = 0;
- n.ax = 0; n.ay = 0;
- n.pinned = false;
- n.hovered = false;
- nodeMap[n.id] = n;
- }
-
- // Resolve edge references
- for (const e of _graphData.edges) {
- e.sourceNode = nodeMap[e.source];
- e.targetNode = nodeMap[e.target];
- }
-
- // Optionally include ghost nodes for unresolved links
- _graphData._ghostNodes = [];
- if (_graphData.unresolved) {
- const ghostMap = {};
- for (const d of _graphData.unresolved) {
- const key = d.title.toLowerCase();
- if (!ghostMap[key]) {
- const ghost = {
- id: '__ghost_' + key, title: d.title,
- folder_path: '', tags: [], link_count: 0,
- x: cx + (Math.random() - 0.5) * canvas.width * 0.4,
- y: cy + (Math.random() - 0.5) * canvas.height * 0.4,
- vx: 0, vy: 0, ax: 0, ay: 0,
- pinned: false, hovered: false, isGhost: true,
- };
- ghostMap[key] = ghost;
- _graphData._ghostNodes.push(ghost);
- }
- }
- }
-}
-
-function _updateGraphStats() {
- const nc = document.getElementById('graphNodeCount');
- const ec = document.getElementById('graphEdgeCount');
- if (nc) nc.textContent = _graphData?.nodes?.length || 0;
- if (ec) ec.textContent = _graphData?.edges?.length || 0;
-}
-
-// ── Force Simulation Loop ───────────────────
-
-function _startSimulation() {
- if (!_graphData || !_graphState) return;
-
- function frame() {
- for (let t = 0; t < SIM.ticksPerFrame; t++) {
- _tick();
- }
- _render();
-
- // Check convergence
- let totalKE = 0;
- for (const n of _allNodes()) {
- totalKE += n.vx * n.vx + n.vy * n.vy;
- }
- if (totalKE > SIM.coolThreshold || _graphState.dragNode) {
- _graphState.animId = requestAnimationFrame(frame);
- } else {
- _graphState.animId = null;
- // One final render
- _render();
- }
- }
-
- if (_graphState.animId) cancelAnimationFrame(_graphState.animId);
- _graphState.animId = requestAnimationFrame(frame);
-}
-
-function _allNodes() {
- if (!_graphData) return [];
- const nodes = [..._graphData.nodes];
- if (_graphState?.showOrphans !== false && _graphData._ghostNodes) {
- nodes.push(..._graphData._ghostNodes);
- }
- return nodes;
-}
-
-function _tick() {
- const nodes = _allNodes();
- const edges = _graphData?.edges || [];
- const canvas = _graphState?.canvas;
- if (!canvas || nodes.length === 0) return;
-
- // Reset accelerations
- for (const n of nodes) { n.ax = 0; n.ay = 0; }
-
- // 1. Repulsion (all pairs)
- for (let i = 0; i < nodes.length; i++) {
- for (let j = i + 1; j < nodes.length; j++) {
- const dx = nodes[j].x - nodes[i].x;
- const dy = nodes[j].y - nodes[i].y;
- const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
- const f = SIM.repulsion / (dist * dist);
- const fx = (dx / dist) * f;
- const fy = (dy / dist) * f;
- nodes[i].ax -= fx; nodes[i].ay -= fy;
- nodes[j].ax += fx; nodes[j].ay += fy;
- }
- }
-
- // 2. Attraction (edges)
- for (const e of edges) {
- const s = e.sourceNode, t = e.targetNode;
- if (!s || !t) continue;
- const dx = t.x - s.x, dy = t.y - s.y;
- const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
- const f = SIM.attraction * (dist - SIM.edgeLength);
- const fx = (dx / dist) * f;
- const fy = (dy / dist) * f;
- s.ax += fx; s.ay += fy;
- t.ax -= fx; t.ay -= fy;
- }
-
- // 3. Center gravity + integration
- const cx = canvas.width / 2, cy = canvas.height / 2;
- for (const n of nodes) {
- if (n.pinned) continue;
- n.ax += (cx - n.x) * SIM.centerGravity;
- n.ay += (cy - n.y) * SIM.centerGravity;
- n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
- n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
- n.x += n.vx;
- n.y += n.vy;
- }
-}
-
-function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
-
-// ── Canvas Rendering ────────────────────────
-
-function _render() {
- const { ctx, canvas, panX, panY, zoom, hoveredNode, showOrphans } = _graphState;
- if (!ctx || !_graphData) return;
-
- const nodes = _allNodes();
- const edges = _graphData.edges || [];
-
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- ctx.save();
- ctx.translate(panX, panY);
- ctx.scale(zoom, zoom);
-
- // Edges
- for (const e of edges) {
- const s = e.sourceNode, t = e.targetNode;
- if (!s || !t) continue;
-
- const isHighlighted = hoveredNode &&
- (s.id === hoveredNode.id || t.id === hoveredNode.id);
-
- ctx.beginPath();
- ctx.moveTo(s.x, s.y);
- ctx.lineTo(t.x, t.y);
-
- if (e.is_transclusion) {
- ctx.setLineDash([4, 4]);
- ctx.strokeStyle = isHighlighted
- ? 'rgba(99, 102, 241, 0.8)' : 'rgba(99, 102, 241, 0.3)';
- } else {
- ctx.setLineDash([]);
- ctx.strokeStyle = isHighlighted
- ? 'rgba(160, 160, 160, 0.8)' : 'rgba(120, 120, 120, 0.25)';
- }
- ctx.lineWidth = isHighlighted ? 2 : 1;
- ctx.stroke();
- }
- ctx.setLineDash([]);
-
- // Nodes
- for (const n of nodes) {
- if (n.isGhost && !showOrphans) continue;
-
- const r = n.isGhost
- ? BASE_RADIUS * 0.6
- : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
-
- const isHovered = hoveredNode && n.id === hoveredNode.id;
- const isNeighbor = hoveredNode && _isNeighbor(hoveredNode, n);
- const isDimmed = hoveredNode && !isHovered && !isNeighbor;
-
- ctx.beginPath();
- ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
-
- if (n.isGhost) {
- ctx.fillStyle = isDimmed ? 'rgba(200, 50, 50, 0.1)' : 'rgba(200, 50, 50, 0.4)';
- ctx.setLineDash([2, 2]);
- ctx.strokeStyle = 'rgba(200, 50, 50, 0.5)';
- } else if (isHovered) {
- ctx.fillStyle = '#6366f1';
- } else {
- const color = folderColor(n.folder_path);
- ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7);
- ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)';
- }
-
- ctx.fill();
- if (!n.isGhost) {
- ctx.setLineDash([]);
- ctx.lineWidth = isHovered ? 2 : 1;
- ctx.stroke();
- } else {
- ctx.stroke();
- ctx.setLineDash([]);
- }
-
- // Labels
- if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) {
- const fontSize = Math.max(10, 11 / zoom);
- ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`;
- ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'top';
-
- const label = n.title.length > 30 ? n.title.slice(0, 28) + '…' : n.title;
- ctx.fillText(label, n.x, n.y + r + 4);
- }
- }
-
- ctx.restore();
-}
-
-function _isNeighbor(hovered, candidate) {
- if (!_graphData) return false;
- for (const e of _graphData.edges) {
- if ((e.source === hovered.id && e.target === candidate.id) ||
- (e.target === hovered.id && e.source === candidate.id)) {
- return true;
- }
- }
- return false;
-}
-
-function _withAlpha(hex, alpha) {
- const r = parseInt(hex.slice(1, 3), 16);
- const g = parseInt(hex.slice(3, 5), 16);
- const b = parseInt(hex.slice(5, 7), 16);
- return `rgba(${r},${g},${b},${alpha})`;
-}
-
-// ── Interaction: Pan, Zoom, Drag, Click ─────
-
-function _attachGraphListeners(canvas) {
- // Mouse → canvas coordinates (accounting for pan/zoom)
- function toGraph(e) {
- const rect = canvas.getBoundingClientRect();
- const x = (e.clientX - rect.left - _graphState.panX) / _graphState.zoom;
- const y = (e.clientY - rect.top - _graphState.panY) / _graphState.zoom;
- return { x, y };
- }
-
- function hitTest(gx, gy) {
- const nodes = _allNodes();
- for (let i = nodes.length - 1; i >= 0; i--) {
- const n = nodes[i];
- const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
- const dx = n.x - gx, dy = n.y - gy;
- if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
- }
- return null;
- }
-
- // Mousedown
- canvas.addEventListener('mousedown', (e) => {
- const g = toGraph(e);
- const node = hitTest(g.x, g.y);
- if (node && !node.isGhost) {
- _graphState.dragNode = node;
- node.pinned = true;
- _graphState.isDragging = false;
- }
- _graphState.lastMouse = { x: e.clientX, y: e.clientY };
- });
-
- // Mousemove
- canvas.addEventListener('mousemove', (e) => {
- const g = toGraph(e);
-
- if (_graphState.dragNode) {
- _graphState.isDragging = true;
- _graphState.dragNode.x = g.x;
- _graphState.dragNode.y = g.y;
- _graphState.dragNode.vx = 0;
- _graphState.dragNode.vy = 0;
- if (!_graphState.animId) _startSimulation();
- } else if (_graphState.lastMouse && e.buttons === 1) {
- // Pan
- _graphState.panX += e.clientX - _graphState.lastMouse.x;
- _graphState.panY += e.clientY - _graphState.lastMouse.y;
- _graphState.lastMouse = { x: e.clientX, y: e.clientY };
- _render();
- } else {
- // Hover
- const node = hitTest(g.x, g.y);
- if (node !== _graphState.hoveredNode) {
- _graphState.hoveredNode = node;
- canvas.style.cursor = node ? 'pointer' : 'default';
- _render();
- }
- }
- });
-
- // Mouseup
- canvas.addEventListener('mouseup', (e) => {
- if (_graphState.dragNode) {
- _graphState.dragNode.pinned = false;
-
- // Click (not drag) → open note
- if (!_graphState.isDragging && !_graphState.dragNode.isGhost) {
- openNoteEditor(_graphState.dragNode.id);
- }
- _graphState.dragNode = null;
- }
- _graphState.lastMouse = null;
- _graphState.isDragging = false;
- });
-
- // Wheel → zoom
- canvas.addEventListener('wheel', (e) => {
- e.preventDefault();
- const factor = e.deltaY > 0 ? 0.92 : 1.08;
- const newZoom = _clamp(_graphState.zoom * factor, 0.15, 4.0);
-
- // Zoom toward cursor
- const rect = canvas.getBoundingClientRect();
- const mx = e.clientX - rect.left;
- const my = e.clientY - rect.top;
-
- _graphState.panX = mx - (mx - _graphState.panX) * (newZoom / _graphState.zoom);
- _graphState.panY = my - (my - _graphState.panY) * (newZoom / _graphState.zoom);
- _graphState.zoom = newZoom;
-
- _render();
- }, { passive: false });
-
- // Resize observer — debounced to prevent notification loop
- let _roFrame = null;
- const ro = new ResizeObserver(() => {
- if (_roFrame) return; // already scheduled
- _roFrame = requestAnimationFrame(() => {
- _roFrame = null;
- const rect = canvas.parentElement.getBoundingClientRect();
- const w = Math.round(rect.width);
- const h = Math.round(rect.height - 40);
- if (canvas.width !== w || canvas.height !== h) {
- canvas.width = w;
- canvas.height = h;
- _render();
- }
- });
- });
- ro.observe(canvas.parentElement);
-}
-
-function _graphResetZoom() {
- if (!_graphState) return;
- _graphState.panX = 0;
- _graphState.panY = 0;
- _graphState.zoom = 1;
- _render();
-}
-
-function _graphToggleOrphans() {
- if (!_graphState) return;
- _graphState.showOrphans = !_graphState.showOrphans;
- _render();
-}
-
-// ── Invalidation ────────────────────────────
-
-function invalidateNoteGraph() {
- _graphData = null;
- _graphDirty = true;
- _folderColorMap = {};
-}
-
-// ── Exports ─────────────────────────────────
-sb.register('openNoteGraph', openNoteGraph);
-sb.register('closeNoteGraph', closeNoteGraph);
-sb.register('_render', _render);
-sb.register('_graphResetZoom', _graphResetZoom);
-sb.register('_graphToggleOrphans', _graphToggleOrphans);
-sb.register('invalidateNoteGraph', invalidateNoteGraph);
diff --git a/src/js/note-panel.js b/src/js/note-panel.js
deleted file mode 100644
index 02a97fa..0000000
--- a/src/js/note-panel.js
+++ /dev/null
@@ -1,976 +0,0 @@
-// ==========================================
-// Chat Switchboard – NotePanel Component
-// ==========================================
-// v0.30.1: Mountable, self-contained notes panel instances.
-// Follows the ChatPane pattern: createComponentRegistry + componentMixin.
-// NotePanel.primary is the backward-compat bridge (singleton used by notes.js shim).
-//
-// Exports: window.NotePanel
-
-
-const NotePanel = {
- ...createComponentRegistry('NotePanel'),
-
- create(opts) {
- const id = opts.id || 'notes-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
- const container = opts.container;
- if (!container) {
- console.error('[NotePanel] container is required');
- return null;
- }
-
- const instance = componentMixin({
- id,
- container,
- projectId: opts.projectId || null,
-
- // ── State (was module-level in notes.js) ──
- _editingNoteId: null,
- _notesSelectMode: false,
- _selectedNoteIds: new Set(),
- _notesSort: 'updated_desc',
- _noteEditor: null,
- _currentNote: null,
- _searchTimer: null,
- _page: 1,
- _pageSize: 50,
- _hasMore: false,
-
- // ── DOM query helper ──
- $(sel) { return this.container.querySelector(sel); },
- $$(sel) { return this.container.querySelectorAll(sel); },
-
- // ── Notes List ──
-
- async loadNotesList(folder, searchQuery, append) {
- const list = this.$('#notesList');
- if (!list) return;
- if (!append) {
- list.innerHTML = '
Loading\u2026
';
- this._page = 1;
- }
-
- try {
- let notes, isSearch = false;
- if (searchQuery) {
- isSearch = true;
- const data = await API.searchNotes(searchQuery);
- notes = data.data || [];
- this._hasMore = false;
- } else {
- const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
- const offset = (this._page - 1) * this._pageSize;
- const data = await API.listNotes(this._pageSize, offset, folderVal, '', this._notesSort);
- notes = data.data || [];
- this._hasMore = notes.length >= this._pageSize;
- }
-
- if (!append) list.innerHTML = '';
-
- if (notes.length === 0 && !append) {
- const folderVal = this.$('#notesFolderFilter')?.value || '';
- list.innerHTML = `${isSearch ? 'No results found' : (folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.')}
`;
- return;
- }
-
- // Remove old "Load more" button if present
- list.querySelector('.notes-load-more')?.remove();
-
- list.insertAdjacentHTML('beforeend', notes.map(n => this._noteListItem(n, isSearch)).join(''));
-
- // Pagination: "Load more" button
- if (this._hasMore) {
- const btn = document.createElement('button');
- btn.className = 'btn-small notes-load-more';
- btn.textContent = 'Load more\u2026';
- btn.style.cssText = 'display:block;margin:8px auto;';
- btn.addEventListener('click', () => {
- this._page++;
- this.loadNotesList(null, null, true);
- });
- list.appendChild(btn);
- }
- } catch (e) {
- if (!append) list.innerHTML = `Failed to load: ${esc(e.message)}
`;
- }
- },
-
- _noteListItem(note, isSearch) {
- const tags = (note.tags || []).map(t => `${esc(t)}`).join('');
- const folder = note.folder_path && note.folder_path !== '/' ? `${esc(note.folder_path)}` : '';
- const preview = isSearch && note.headline
- ? `${this._highlightHeadline(note.headline)}
`
- : `${esc((note.preview || note.content || '').slice(0, 120))}
`;
- const time = _relativeTime(note.updated_at);
- const checked = this._selectedNoteIds.has(note.id) ? 'checked' : '';
-
- return `
-
-
-
-
-
-
- ${preview}
-
${folder}${tags}
-
-
`;
- },
-
- _highlightHeadline(headline) {
- const escaped = esc(headline);
- return escaped.replace(/\*\*(.+?)\*\*/g, '$1');
- },
-
- // ── Multi-select ──
-
- _enterSelectMode() {
- this._notesSelectMode = true;
- this._selectedNoteIds.clear();
- const bar = this.$('#notesSelectionBar');
- if (bar) bar.style.display = '';
- this.$$('.note-select-col').forEach(el => el.style.display = '');
- this._updateSelectedCount();
- },
-
- _exitSelectMode() {
- this._notesSelectMode = false;
- this._selectedNoteIds.clear();
- const bar = this.$('#notesSelectionBar');
- if (bar) bar.style.display = 'none';
- this.$$('.note-select-col').forEach(el => el.style.display = 'none');
- this.$$('.note-item.selected').forEach(el => el.classList.remove('selected'));
- this.$$('.note-checkbox').forEach(cb => cb.checked = false);
- const sa = this.$('#notesSelectAll');
- if (sa) sa.checked = false;
- },
-
- _toggleNoteSelect(noteId, forceState) {
- if (!this._notesSelectMode) {
- this._enterSelectMode();
- }
- const has = this._selectedNoteIds.has(noteId);
- const newState = forceState !== undefined ? forceState : !has;
-
- if (newState) this._selectedNoteIds.add(noteId);
- else this._selectedNoteIds.delete(noteId);
-
- const item = this.container.querySelector(`.note-item[data-note-id="${noteId}"]`);
- if (item) {
- item.classList.toggle('selected', newState);
- const cb = item.querySelector('.note-checkbox');
- if (cb) cb.checked = newState;
- }
- this._updateSelectedCount();
- },
-
- _toggleSelectAll(checked) {
- this.$$('.note-checkbox').forEach(cb => {
- const cbId = cb.dataset.id;
- if (checked) this._selectedNoteIds.add(cbId);
- else this._selectedNoteIds.delete(cbId);
- cb.checked = checked;
- cb.closest('.note-item')?.classList.toggle('selected', checked);
- });
- this._updateSelectedCount();
- },
-
- _updateSelectedCount() {
- const el = this.$('#notesSelectedCount');
- if (el) el.textContent = this._selectedNoteIds.size;
- },
-
- async _bulkDeleteSelected() {
- if (this._selectedNoteIds.size === 0) return;
- const count = this._selectedNoteIds.size;
- if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
-
- try {
- const resp = await API.bulkDeleteNotes([...this._selectedNoteIds]);
- UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
- this._exitSelectMode();
- await this.loadNotesList();
- await this.loadNoteFolders();
- } catch (e) { UI.toast(e.message, 'error'); }
- },
-
- // ── Folders ──
-
- async loadNoteFolders() {
- const sel = this.$('#notesFolderFilter');
- if (!sel) return;
- try {
- const data = await API.listNoteFolders();
- const folders = data.folders || [];
- sel.innerHTML = '';
- folders.forEach(f => {
- sel.innerHTML += ``;
- });
- } catch (e) { /* non-critical */ }
- },
-
- // ── View switching ──
-
- showNotesList() {
- const lv = this.$('#notesListView');
- const ev = this.$('#notesEditorView');
- const gv = this.$('#notesGraphView');
- if (lv) lv.style.display = '';
- if (ev) ev.style.display = 'none';
- if (gv) gv.style.display = 'none';
- this._editingNoteId = null;
- this._destroyNoteEditor();
- const list = this.$('#notesList');
- if (list) list.innerHTML = 'Loading\u2026
';
- },
-
- // ── Note Editor ──
-
- copyNoteContent() {
- if (!this._currentNote) return;
- const text = `# ${this._currentNote.title || ''}\n\n${this._currentNote.content || ''}`;
- navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
- },
-
- async openNoteEditor(noteId) {
- const lv = this.$('#notesListView');
- const ev = this.$('#notesEditorView');
- if (lv) lv.style.display = 'none';
- if (ev) ev.style.display = '';
-
- if (noteId) {
- this._editingNoteId = noteId;
- try {
- this._currentNote = await API.getNote(noteId);
- this._populateEditFields(this._currentNote);
- this._showNoteReadMode();
- } catch (e) {
- UI.toast('Failed to load note: ' + e.message, 'error');
- this.showNotesList();
- }
- } else {
- this._editingNoteId = null;
- this._currentNote = null;
- this._clearEditFields();
- this._showNoteEditMode();
- this.$('#noteEditorTitle')?.focus();
- }
- },
-
- _populateEditFields(note) {
- const t = this.$('#noteEditorTitle');
- const f = this.$('#noteEditorFolder');
- const g = this.$('#noteEditorTags');
- if (t) t.value = note.title || '';
- if (f) f.value = note.folder_path || '';
- if (g) g.value = (note.tags || []).join(', ');
- this._setNoteEditorContent(note.content || '');
- },
-
- _clearEditFields() {
- const t = this.$('#noteEditorTitle');
- const f = this.$('#noteEditorFolder');
- const g = this.$('#noteEditorTags');
- if (t) t.value = '';
- if (f) f.value = '';
- if (g) g.value = '';
- this._setNoteEditorContent('');
- },
-
- _getNoteEditorContent() {
- if (this._noteEditor) return this._noteEditor.getValue();
- const ta = this.container.querySelector('#noteEditorContentContainer textarea');
- return ta ? ta.value : '';
- },
-
- _setNoteEditorContent(text) {
- if (this._noteEditor) {
- this._noteEditor.setValue(text);
- return;
- }
- const container = this.$('#noteEditorContentContainer');
- if (!container) return;
-
- if (window.CM?.noteEditor) {
- container.innerHTML = '';
- this._noteEditor = CM.noteEditor(container, {
- value: text,
- darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
- onChange: null,
- onLink: (title) => {
- this._navigateToLinkedNote(title);
- },
- linkCompleter: async (query) => {
- if (!query || query.length < 1) return [];
- try {
- const resp = await API.searchNoteTitles(query, 8);
- return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
- } catch { return []; }
- },
- });
- } else {
- if (!container.querySelector('textarea')) {
- container.innerHTML = '';
- }
- const ta = container.querySelector('textarea');
- if (ta) ta.value = text;
- }
- },
-
- async _navigateToLinkedNote(title) {
- try {
- const resp = await API.searchNoteTitles(title, 1);
- const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
- if (match) {
- await this.openNoteEditor(match.id);
- } else {
- if (await showConfirm(`Note "${title}" not found. Create it?`)) {
- const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
- await this.openNoteEditor(created.id);
- }
- }
- } catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
- },
-
- _destroyNoteEditor() {
- if (this._noteEditor) {
- this._noteEditor.destroy();
- this._noteEditor = null;
- }
- },
-
- _showNoteReadMode() {
- if (!this._currentNote) return;
- const n = this._currentNote;
-
- const titleEl = this.$('#noteReadTitle');
- if (titleEl) titleEl.textContent = n.title || '';
-
- const parts = [];
- if (n.folder_path && n.folder_path !== '/') parts.push(`${esc(n.folder_path)}`);
- (n.tags || []).forEach(t => parts.push(`${esc(t)}`));
- const metaEl = this.$('#noteReadMeta');
- if (metaEl) metaEl.innerHTML = parts.join(' ');
-
- const noteReadEl = this.$('#noteReadContent');
- if (noteReadEl) {
- noteReadEl.innerHTML = formatMessage(n.content || '');
- if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
- this._renderWikilinksInReadMode(noteReadEl);
- }
-
- const delBtn = this.$('#noteDeleteBtn2');
- if (delBtn) delBtn.style.display = this._editingNoteId ? '' : 'none';
-
- const rm = this.$('#noteReadMode');
- const em = this.$('#noteEditMode');
- const eb = this.$('#noteEditBtn');
- const pb = this.$('#notePreviewBtn');
- if (rm) rm.style.display = '';
- if (em) em.style.display = 'none';
- if (eb) eb.style.display = '';
- if (pb) pb.style.display = 'none';
-
- if (this._editingNoteId) this._loadBacklinks(this._editingNoteId);
- this._updateDailyNav(this._currentNote);
- },
-
- _showNoteEditMode() {
- const rm = this.$('#noteReadMode');
- const em = this.$('#noteEditMode');
- const db = this.$('#noteDeleteBtn');
- const cb = this.$('#noteCancelEditBtn');
- const eb = this.$('#noteEditBtn');
- const pb = this.$('#notePreviewBtn');
- if (rm) rm.style.display = 'none';
- if (em) em.style.display = '';
- if (db) db.style.display = this._editingNoteId ? '' : 'none';
- if (cb) cb.style.display = this._editingNoteId ? '' : 'none';
- if (eb) eb.style.display = 'none';
- if (pb) pb.style.display = '';
- },
-
- _showNotePreview() {
- const title = this.$('#noteEditorTitle')?.value.trim() || '';
- const content = this._getNoteEditorContent();
- const folderVal = this.$('#noteEditorFolder')?.value.trim() || '';
- const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
- const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
-
- const titleEl = this.$('#noteReadTitle');
- if (titleEl) titleEl.textContent = title || 'Untitled';
- const parts = [];
- if (folderVal) parts.push(`${esc(folderVal)}`);
- tags.forEach(t => parts.push(`${esc(t)}`));
- const metaEl = this.$('#noteReadMeta');
- if (metaEl) metaEl.innerHTML = parts.join(' ');
- const readEl = this.$('#noteReadContent');
- if (readEl) {
- readEl.innerHTML = formatMessage(content || '');
- if (typeof runExtensionPostRender === 'function') runExtensionPostRender(readEl);
- this._renderWikilinksInReadMode(readEl);
- }
-
- const rm = this.$('#noteReadMode');
- const em = this.$('#noteEditMode');
- const eb = this.$('#noteEditBtn');
- const pb = this.$('#notePreviewBtn');
- if (rm) rm.style.display = '';
- if (em) em.style.display = 'none';
- if (eb) eb.style.display = '';
- if (pb) pb.style.display = 'none';
- const delBtn = this.$('#noteDeleteBtn2');
- if (delBtn) delBtn.style.display = 'none';
- },
-
- // ── CRUD ──
-
- async saveNote() {
- const title = this.$('#noteEditorTitle')?.value.trim() || '';
- const content = this._getNoteEditorContent();
- const folder = this.$('#noteEditorFolder')?.value.trim() || '';
- const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
- const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
-
- if (!title) { UI.toast('Title is required', 'warning'); return; }
- if (!content) { UI.toast('Content is required', 'warning'); return; }
-
- try {
- if (this._editingNoteId) {
- const updated = await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
- this._currentNote = updated;
- UI.toast('Note updated', 'success');
- this._showNoteReadMode();
- } else {
- const created = await API.createNote(title, content, folder, tags);
- this._editingNoteId = created.id;
- this._currentNote = created;
- UI.toast('Note created', 'success');
- this._showNoteReadMode();
- }
- if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
- } catch (e) { UI.toast(e.message, 'error'); }
- },
-
- async deleteNote() {
- if (!this._editingNoteId) return;
- if (!await showConfirm('Delete this note?')) return;
- try {
- await API.deleteNote(this._editingNoteId);
- UI.toast('Note deleted', 'success');
- if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
- this.showNotesList();
- await this.loadNotesList();
- await this.loadNoteFolders();
- } catch (e) { UI.toast(e.message, 'error'); }
- },
-
- // ── Backlinks ──
-
- async _loadBacklinks(noteId) {
- const container = this.$('#noteBacklinks');
- const listEl = this.$('#noteBacklinksList');
- const countEl = this.$('#noteBacklinksCount');
- if (!container || !listEl || !countEl) return;
-
- try {
- const resp = await API.getNoteBacklinks(noteId);
- const links = resp.data || [];
- countEl.textContent = links.length;
- container.style.display = links.length > 0 ? '' : 'none';
- listEl.innerHTML = links.map(n => `
-
- ${esc(n.title)}
- ${esc(n.folder_path)}
-
- `).join('');
- } catch {
- container.style.display = 'none';
- }
- },
-
- toggleBacklinks() {
- const list = this.$('#noteBacklinksList');
- if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
- },
-
- // ── Wikilink Read-Mode Rendering ──
-
- _renderWikilinksInReadMode(containerEl) {
- if (!containerEl) return;
- const html = containerEl.innerHTML;
- let transclusionId = 0;
- const rendered = html.replace(
- /(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
- (match, bang, title, display) => {
- const titleTrimmed = title.trim();
- const displayText = display ? display.trim() : titleTrimmed;
- if (bang === '!') {
- const tid = `transclusion-${++transclusionId}`;
- return ``;
- }
- return `${esc(displayText)}`;
- }
- );
- containerEl.innerHTML = rendered;
- this._resolveTransclusions(containerEl);
- },
-
- async _resolveTransclusions(containerEl) {
- const embeds = containerEl.querySelectorAll('.transclusion-embed');
- if (!embeds.length) return;
-
- const cache = {};
- for (const el of embeds) {
- const title = el.dataset.title;
- const contentEl = el.querySelector('.transclusion-content');
- if (!title || !contentEl) continue;
-
- try {
- let note = cache[title.toLowerCase()];
- if (!note) {
- const resp = await API.searchNoteTitles(title, 1);
- const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
- if (!match) {
- contentEl.innerHTML = 'Note not found';
- continue;
- }
- note = await API.getNote(match.id);
- cache[title.toLowerCase()] = note;
- }
- const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
- const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
- return `[Embedded: ${inner}]`;
- });
- contentEl.innerHTML = formatMessage(safeContent);
- if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
- } catch {
- contentEl.innerHTML = 'Failed to load';
- }
- }
- },
-
- // ── Daily Notes ──
-
- async openDailyNote() {
- const today = new Date().toISOString().slice(0, 10);
- const title = `Daily \u2014 ${today}`;
- const folder = '/daily/';
-
- try {
- const resp = await API.searchNoteTitles(title, 1);
- const existing = (resp.data || []).find(
- n => n.title.toLowerCase() === title.toLowerCase()
- );
- if (existing) {
- await this.openNoteEditor(existing.id);
- } else {
- const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
- const created = await API.createNote(title, content, folder, ['daily']);
- await this.openNoteEditor(created.id);
- }
- } catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
- },
-
- _isDailyNote(note) {
- return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
- },
-
- _updateDailyNav(note) {
- let nav = this.$('#noteDailyNav');
- if (!this._isDailyNote(note)) {
- if (nav) nav.style.display = 'none';
- return;
- }
-
- if (!nav) {
- const toolbar = this.container.querySelector('.notes-editor-toolbar');
- if (!toolbar) return;
- nav = document.createElement('div');
- nav.id = 'noteDailyNav';
- nav.className = 'note-daily-nav';
- nav.innerHTML = `
-
-
-
- `;
- toolbar.after(nav);
- nav.querySelector('#dailyPrevBtn')?.addEventListener('click', () => this._navigateDaily(-1));
- nav.querySelector('#dailyNextBtn')?.addEventListener('click', () => this._navigateDaily(1));
- }
-
- nav.style.display = '';
- const dateStr = note.title.replace('Daily \u2014 ', '');
- const label = nav.querySelector('#dailyDateLabel');
- if (label) label.textContent = dateStr;
-
- const today = new Date().toISOString().slice(0, 10);
- const nextBtn = nav.querySelector('#dailyNextBtn');
- if (nextBtn) nextBtn.disabled = dateStr >= today;
- },
-
- async _navigateDaily(offset) {
- if (!this._currentNote || !this._isDailyNote(this._currentNote)) return;
- const dateStr = this._currentNote.title.replace('Daily \u2014 ', '');
- const d = new Date(dateStr + 'T12:00:00');
- d.setDate(d.getDate() + offset);
- const newDateStr = d.toISOString().slice(0, 10);
- const title = `Daily \u2014 ${newDateStr}`;
-
- try {
- const resp = await API.searchNoteTitles(title, 1);
- const existing = (resp.data || []).find(
- n => n.title.toLowerCase() === title.toLowerCase()
- );
- if (existing) {
- await this.openNoteEditor(existing.id);
- } else {
- const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
- const created = await API.createNote(title, content, '/daily/', ['daily']);
- await this.openNoteEditor(created.id);
- }
- } catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
- },
-
- // ── Save-to-Note (from chat messages) ──
-
- async saveMessageToNote(msgIndex) {
- const chat = App.chats?.find(c => c.id === App.activeId);
- const msg = chat?.messages?.[msgIndex];
- if (!msg) return;
-
- const sel = window.getSelection();
- let content = msg.content;
-
- content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
-
- const msgId = msg.id || '';
- const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
- if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
- const selected = sel.toString().trim();
- if (selected) content = selected;
- }
-
- const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
- const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
-
- this._showSaveToNoteModal({
- content,
- sourceChannelId: App.activeId || '',
- sourceMessageId: msg.id || '',
- defaultTitle,
- });
- },
-
- _showSaveToNoteModal(opts) {
- document.getElementById('saveToNoteModal')?.remove();
-
- const modal = document.createElement('div');
- modal.id = 'saveToNoteModal';
- modal.className = 'modal-overlay active';
- modal.innerHTML = `
-
-
Save to Note
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- document.body.appendChild(modal);
-
- let selectedAppendNote = null;
-
- modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
- radio.addEventListener('change', (e) => {
- const isAppend = e.target.value === 'append';
- document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
- document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
- document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
- selectedAppendNote = null;
- document.getElementById('saveNoteSearchResults').innerHTML = '';
- });
- });
-
- let _searchTimer;
- document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
- clearTimeout(_searchTimer);
- const q = e.target.value.trim();
- selectedAppendNote = null;
- _searchTimer = setTimeout(async () => {
- const resultsEl = document.getElementById('saveNoteSearchResults');
- if (q.length < 2) { resultsEl.innerHTML = ''; return; }
- try {
- const resp = await API.searchNoteTitles(q, 8);
- const notes = resp.data || [];
- resultsEl.innerHTML = notes.map(n =>
- `${esc(n.title)}
`
- ).join('') || 'No results
';
- resultsEl.querySelectorAll('.save-note-result').forEach(el => {
- el.addEventListener('click', () => {
- resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
- el.classList.add('selected');
- selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
- document.getElementById('saveNoteSearch').value = el.dataset.title;
- });
- });
- } catch { resultsEl.innerHTML = 'Search failed
'; }
- }, 250);
- });
-
- document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
- modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
-
- document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
- const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
-
- try {
- if (isAppend) {
- if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
- await API.updateNote(selectedAppendNote.id, {
- content: '\n\n---\n\n' + opts.content,
- mode: 'append',
- });
- UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
- } else {
- const title = document.getElementById('saveNoteTitle').value.trim();
- if (!title) { UI.toast('Title is required', 'warning'); return; }
- const folder = document.getElementById('saveNoteFolder').value.trim();
- const tagsStr = document.getElementById('saveNoteTags').value.trim();
- const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
- await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
- UI.toast('Note created', 'success');
- }
- if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
- modal.remove();
- } catch (e) { UI.toast(e.message, 'error'); }
- });
-
- document.getElementById('saveNoteTitle')?.focus();
- document.getElementById('saveNoteTitle')?.select();
- },
-
- // ── Compound onclick wrappers (for data-action dispatch) ──
-
- _toggleNoteSelectCb(el, noteId) {
- this._toggleNoteSelect(noteId, el.checked);
- },
-
- _noteItemClick(noteId) {
- if (this._notesSelectMode) {
- this._toggleNoteSelect(noteId);
- } else {
- this.openNoteEditor(noteId);
- }
- },
-
- // ── Lifecycle ──
-
- _cleanup() {
- this._destroyNoteEditor();
- clearTimeout(this._searchTimer);
- },
- }, NotePanel);
-
- NotePanel._register(id, instance);
- return instance;
- },
-};
-
-// ── Self-mount: build DOM + create + wire in one call ──
-// Canonical entry point for all consumers (notes.js sidebar, editor package, sw.notes()).
-// Uses the same IDs that NotePanel.create() expects — $() is container-scoped so no collisions.
-NotePanel.mount = function (container, opts) {
- const _opts = opts || {};
-
- const el = document.createElement('div');
- el.className = 'note-panel-root';
- el.innerHTML =
- '' +
- '' +
- '
' +
- '' +
- '' +
- '' +
- '' +
- '
' +
- '
' +
- '' +
- '
' +
- '
' +
- '' +
- '' +
- '
' +
- '
' +
- '
' +
- '' +
- '0 selected' +
- '' +
- '' +
- '
' +
- '
' +
- '' +
- '' +
- '' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '' +
- '';
- container.appendChild(el);
-
- // Create NotePanel instance — $() is container-scoped, so the IDs above "just work"
- const np = this.create({ container: el, projectId: _opts.projectId || null });
-
- // ── Wire all events (same as notes.js _registerNotesPanel) ──
- const q = (sel) => el.querySelector(sel);
-
- q('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
- q('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
- q('#notesGraphBtn').addEventListener('click', () => {
- np.showGraph();
- if (typeof openNoteGraph === 'function') openNoteGraph();
- if (_opts.onOpenGraph) _opts.onOpenGraph();
- });
- q('#notesGraphBackBtn').addEventListener('click', () => {
- np.showNotesList();
- if (typeof closeNoteGraph === 'function') closeNoteGraph();
- });
- q('#notesSelectModeBtn').addEventListener('click', () => {
- if (np._notesSelectMode) np._exitSelectMode();
- else np._enterSelectMode();
- });
-
- q('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
- q('#noteSaveBtn').addEventListener('click', () => np.saveNote());
- q('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
- q('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
- q('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
- q('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
- q('#noteCancelEditBtn').addEventListener('click', () => {
- if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
- else np.showNotesList();
- });
-
- q('#notesFolderFilter').addEventListener('change', (e) => {
- np.$('#notesSearchInput').value = '';
- np._exitSelectMode();
- np.loadNotesList(e.target.value);
- });
- q('#notesSortSelect').addEventListener('change', (e) => {
- np._notesSort = e.target.value;
- np._exitSelectMode();
- np.loadNotesList();
- });
- q('#notesSelectAll').addEventListener('change', (e) => np._toggleSelectAll(e.target.checked));
- q('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
- q('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
-
- let _searchTimer;
- q('#notesSearchInput').addEventListener('input', (e) => {
- clearTimeout(_searchTimer);
- const query = e.target.value.trim();
- _searchTimer = setTimeout(() => {
- np._exitSelectMode();
- if (query.length >= 2) {
- np.$('#notesFolderFilter').value = '';
- np.loadNotesList(null, query);
- } else if (query.length === 0) {
- np.loadNotesList();
- }
- }, 300);
- });
-
- // Add showGraph view switcher
- np.showGraph = function () {
- const lv = np.$('#notesListView');
- const ev = np.$('#notesEditorView');
- const gv = np.$('#notesGraphView');
- if (lv) lv.style.display = 'none';
- if (ev) ev.style.display = 'none';
- if (gv) gv.style.display = '';
- };
-
- return np;
-};
-
-
-// ── Exports ─────────────────────────────────
-sb.ns('NotePanel', NotePanel);
diff --git a/src/js/notes.js b/src/js/notes.js
deleted file mode 100644
index 213c83d..0000000
--- a/src/js/notes.js
+++ /dev/null
@@ -1,155 +0,0 @@
-// ==========================================
-// Chat Switchboard – Notes Panel (Shim)
-// ==========================================
-// v0.30.1: Thin backward-compat layer over NotePanel component.
-// All state and logic now lives in note-panel.js.
-// This file creates the singleton (NotePanel.primary) and wires
-// sb.register() calls so data-action attributes continue to work.
-
-
-// ── Singleton accessor ──
-function _np() { return NotePanel.primary; }
-
-
-// ── Public API (delegated to NotePanel.primary) ──
-
-async function openNotes() {
- if (PanelRegistry.isOpen('notes')) {
- PanelRegistry.close('notes');
- return;
- }
- PanelRegistry.open('notes');
- _np()?._exitSelectMode();
-}
-
-async function loadNotesList(folder, searchQuery) {
- return _np()?.loadNotesList(folder, searchQuery);
-}
-
-function showNotesList() {
- return _np()?.showNotesList();
-}
-
-async function loadNoteFolders() {
- return _np()?.loadNoteFolders();
-}
-
-async function openNoteEditor(noteId) {
- return _np()?.openNoteEditor(noteId);
-}
-
-async function saveNote() {
- return _np()?.saveNote();
-}
-
-async function deleteNote() {
- return _np()?.deleteNote();
-}
-
-async function openDailyNote() {
- return _np()?.openDailyNote();
-}
-
-async function saveMessageToNote(msgIndex) {
- return _np()?.saveMessageToNote(msgIndex);
-}
-
-function _toggleNoteSelect(id, forceState) {
- return _np()?._toggleNoteSelect(id, forceState);
-}
-
-function _navigateToLinkedNote(title) {
- return _np()?._navigateToLinkedNote(title);
-}
-
-function toggleBacklinks() {
- return _np()?.toggleBacklinks();
-}
-
-function _toggleNoteSelectCb(el, noteId) {
- return _np()?._toggleNoteSelectCb(el, noteId);
-}
-
-function _noteItemClick(noteId) {
- return _np()?._noteItemClick(noteId);
-}
-
-// Read-only accessors for backward compat (sb.register registered these as values)
-function _currentNote() { return _np()?._currentNote; }
-function _editingNoteId() { return _np()?._editingNoteId; }
-
-
-// ── Panel Registration ──
-// Creates the singleton NotePanel.primary and registers with PanelRegistry.
-
-function _registerNotesPanel() {
- const body = document.getElementById('sidePanelBody');
- if (!body || typeof PanelRegistry === 'undefined' || typeof NotePanel === 'undefined') return;
-
- // ── Create wrapper element ──
- const wrapper = document.createElement('div');
- wrapper.id = 'sidePanelNotes';
- wrapper.style.display = 'none';
- wrapper.style.height = '100%';
- body.appendChild(wrapper);
-
- // ── Mount NotePanel (DOM + create + wire — single source of truth) ──
- const np = NotePanel.mount(wrapper, {});
- NotePanel.primary = np;
-
- // ── Wire sidebar button ──
- document.getElementById('notesBtn')?.addEventListener('click', openNotes);
-
- // ── Register with PanelRegistry ──
- PanelRegistry.register('notes', {
- element: wrapper,
- label: 'Notes',
- onOpen() {
- np.loadNotesList();
- np.loadNoteFolders();
- },
- onClose() {
- np._destroyNoteEditor();
- },
- saveState() {
- const listView = np.$('#notesListView');
- const editorView = np.$('#notesEditorView');
- const graphView = np.$('#notesGraphView');
- let viewMode = 'list';
- if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
- if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
- return {
- scrollTop: listView?.scrollTop || 0,
- editingNoteId: np._editingNoteId,
- viewMode,
- };
- },
- restoreState(state) {
- if (state.scrollTop) {
- const listView = np.$('#notesListView');
- if (listView) listView.scrollTop = state.scrollTop;
- }
- },
- });
-}
-
-/** @deprecated — listeners are now wired in _registerNotesPanel() */
-function _initNotesListeners() {
- // No-op: kept for backward compatibility with app.js calling sequence.
-}
-
-// ── Exports ─────────────────────────────────
-sb.register('_currentNote', _currentNote);
-sb.register('_editingNoteId', _editingNoteId);
-sb.register('_initNotesListeners', _initNotesListeners);
-sb.register('_registerNotesPanel', _registerNotesPanel);
-sb.register('deleteNote', deleteNote);
-sb.register('openNoteEditor', openNoteEditor);
-sb.register('openNotes', openNotes);
-sb.register('saveMessageToNote', saveMessageToNote);
-sb.register('saveNote', saveNote);
-sb.register('_toggleNoteSelect', _toggleNoteSelect);
-sb.register('_navigateToLinkedNote', _navigateToLinkedNote);
-sb.register('toggleBacklinks', toggleBacklinks);
-sb.register('_toggleNoteSelectCb', _toggleNoteSelectCb);
-sb.register('_noteItemClick', _noteItemClick);
diff --git a/src/js/sw/components/notes-pane/index.js b/src/js/sw/components/notes-pane/index.js
new file mode 100644
index 0000000..03f8508
--- /dev/null
+++ b/src/js/sw/components/notes-pane/index.js
@@ -0,0 +1,194 @@
+// ==========================================
+// NotesPane Kit — Default Assembly
+// ==========================================
+// Wires useNotes + all sub-components into the standard
+// notes experience. Surfaces that need a different
+// layout import individual pieces instead.
+//
+// Usage:
+// import { NotesPane } from './components/notes-pane/index.js';
+// html`<${NotesPane} handleRef=${ref} standalone=${true} />`
+//
+// Re-exports all pieces for kit consumers:
+// import { useNotes, NoteList, NoteEditor, ... } from './components/notes-pane/index.js';
+
+import { useNotes } from './use-notes.js';
+import { NoteList } from './note-list.js';
+import { NoteListItem } from './note-list-item.js';
+import { NoteEditor } from './note-editor.js';
+import { NoteReader } from './note-reader.js';
+import { QuickSwitcher, saveRecentNote } from './quick-switcher.js';
+import { SaveToNoteDialog } from './save-to-note.js';
+import { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
+
+const { useState, useRef, useEffect, useCallback } = window.hooks;
+const html = window.html;
+
+/**
+ * Default NotesPane assembly.
+ *
+ * @param {{
+ * initialFolder?: string,
+ * initialNoteId?: string,
+ * standalone?: boolean,
+ * onNoteChange?: (note: object|null) => void,
+ * handleRef?: { current: any },
+ * className?: string,
+ * }} props
+ */
+export function NotesPane(props) {
+ const {
+ initialFolder,
+ initialNoteId,
+ standalone = true,
+ onNoteChange,
+ handleRef,
+ className,
+ } = props;
+
+ const notes = useNotes({
+ initialFolder,
+ initialNoteId,
+ onNoteChange,
+ standalone,
+ enableGraph: true,
+ enableQuickSwitcher: true,
+ });
+
+ // ── Lazy-load graph component ───────────
+ const [GraphComp, setGraphComp] = useState(null);
+ useEffect(() => {
+ if (notes.view === 'graph' && !GraphComp) {
+ import('./note-graph.js').then(m => setGraphComp(() => m.NoteGraph));
+ }
+ }, [notes.view, GraphComp]);
+
+ const graphHandleRef = useRef(null);
+
+ // ── Graph data loading ──────────────────
+ useEffect(() => {
+ if (notes.view === 'graph' && !notes.graphData) {
+ notes.loadGraph();
+ }
+ }, [notes.view]);
+
+ // ── Expose imperative handle ────────────
+ useEffect(() => {
+ if (handleRef) {
+ handleRef.current = {
+ getView() { return notes.view; },
+ setView(v) { notes.setView(v); },
+ openNote(id) { saveRecentNote({ id, title: '' }); notes.openNote(id); },
+ newNote() { notes.newNote(); },
+ openDailyNote() { notes.openDailyNote(); },
+ refreshList() { notes.refreshList(); },
+ getEditingId() { return notes.editingId; },
+ openQuickSwitcher() { notes.setQuickSwitcherOpen(true); },
+ };
+ }
+ });
+
+ // ── Quick switcher create-new handler ───
+ const onQuickSwitcherCreate = useCallback((title) => {
+ notes.newNote();
+ // The editor will open blank — we can't pre-fill via state here
+ // but the title will be empty for the user to type
+ }, [notes.newNote]);
+
+ // ── View routing ────────────────────────
+ const renderView = () => {
+ switch (notes.view) {
+ case 'list':
+ return html`
+ <${NoteList}
+ notes=${notes.notes} loading=${notes.loading}
+ hasMore=${notes.hasMore} loadMore=${notes.loadMore}
+ folders=${notes.folders} folder=${notes.folder} setFolder=${notes.setFolder}
+ sort=${notes.sort} setSort=${notes.setSort}
+ searchQuery=${notes.searchQuery} setSearchQuery=${notes.setSearchQuery}
+ tagFilter=${notes.tagFilter} setTagFilter=${notes.setTagFilter}
+ selectMode=${notes.selectMode} selectedIds=${notes.selectedIds}
+ enterSelectMode=${notes.enterSelectMode} exitSelectMode=${notes.exitSelectMode}
+ toggleSelect=${notes.toggleSelect} toggleSelectAll=${notes.toggleSelectAll}
+ bulkDelete=${notes.bulkDelete}
+ onNoteClick=${(id) => { saveRecentNote({ id, title: '' }); notes.openNote(id); }}
+ onNewNote=${notes.newNote}
+ onDailyNote=${notes.openDailyNote}
+ onGraphClick=${() => notes.setView('graph')}
+ pinnedIds=${notes.pinnedIds} togglePin=${notes.togglePin} />`;
+
+ case 'editor':
+ return html`
+ <${NoteEditor}
+ currentNote=${notes.currentNote}
+ editingId=${notes.editingId}
+ folders=${notes.folders}
+ onSave=${notes.saveNote}
+ onCancel=${() => {
+ if (notes.currentNote) {
+ notes.setEditorMode('read');
+ notes.setView('reader');
+ } else {
+ notes.setView('list');
+ notes.refreshList();
+ }
+ }}
+ onDelete=${notes.deleteNote} />`;
+
+ case 'reader':
+ return html`
+ <${NoteReader}
+ note=${notes.currentNote}
+ backlinks=${notes.backlinks}
+ onEdit=${() => {
+ notes.setEditorMode('edit');
+ notes.setView('editor');
+ }}
+ onDelete=${notes.deleteNote}
+ onCopy=${notes.copyNote}
+ onNavigateLink=${notes.navigateToLink}
+ onBack=${() => { notes.setView('list'); notes.refreshList(); }}
+ isDailyNote=${notes.isDailyNote}
+ onDailyPrev=${() => notes.navigateDaily(-1)}
+ onDailyNext=${() => notes.navigateDaily(1)} />`;
+
+ case 'graph':
+ if (!GraphComp) {
+ return html`Loading graph\u2026
`;
+ }
+ return html`
+ <${GraphComp}
+ data=${notes.graphData}
+ onNodeClick=${(id) => { notes.openNote(id); }}
+ onGhostClick=${(title) => { notes.navigateToLink(title); }}
+ onBack=${() => notes.setView('list')}
+ handleRef=${graphHandleRef} />`;
+
+ default:
+ return null;
+ }
+ };
+
+ return html`
+
+ ${renderView()}
+
+ <${QuickSwitcher}
+ open=${notes.quickSwitcherOpen}
+ onClose=${() => notes.setQuickSwitcherOpen(false)}
+ onSelect=${(id) => notes.openNote(id)}
+ onCreateNew=${onQuickSwitcherCreate} />
+
+ `;
+}
+
+// ── Kit re-exports ─────────────────────────
+export { useNotes } from './use-notes.js';
+export { NoteList } from './note-list.js';
+export { NoteListItem } from './note-list-item.js';
+export { NoteEditor } from './note-editor.js';
+export { NoteReader } from './note-reader.js';
+export { NoteGraph } from './note-graph.js';
+export { QuickSwitcher, saveRecentNote } from './quick-switcher.js';
+export { SaveToNoteDialog } from './save-to-note.js';
+export { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
diff --git a/src/js/sw/components/notes-pane/markdown.js b/src/js/sw/components/notes-pane/markdown.js
new file mode 100644
index 0000000..90ed928
--- /dev/null
+++ b/src/js/sw/components/notes-pane/markdown.js
@@ -0,0 +1,157 @@
+// ==========================================
+// NotesPane Kit — Markdown with Wikilinks
+// ==========================================
+// Renders markdown content via marked + DOMPurify,
+// then post-processes wikilinks into clickable chips
+// and transclusion blocks.
+// Independently importable.
+
+const WIKILINK_RE = /(!?)\[\[([^\[\]|]+?)(?:\|([^\]]+?))?\]\]/g;
+
+/**
+ * Render markdown with wikilink support.
+ *
+ * @param {string} content — raw markdown
+ * @param {{ onLinkClick?: (title: string) => void }} opts
+ * @returns {{ html: string, wikilinks: Array<{title: string, display: string, isTransclusion: boolean}> }}
+ */
+export function renderNoteMarkdown(content, opts = {}) {
+ if (!content) return { html: '', wikilinks: [] };
+
+ // 1. Render markdown
+ let rendered = '';
+ if (window.marked) {
+ try {
+ rendered = window.marked.parse(content, { breaks: true, gfm: true });
+ } catch {
+ rendered = _escapeHtml(content).replace(/\n/g, '
');
+ }
+ } else {
+ rendered = _escapeHtml(content).replace(/\n/g, '
');
+ }
+
+ // 2. Sanitize
+ if (window.DOMPurify) {
+ rendered = window.DOMPurify.sanitize(rendered, {
+ ADD_ATTR: ['target', 'rel'],
+ });
+ }
+
+ // 3. Extract and replace wikilinks
+ const wikilinks = [];
+ rendered = rendered.replace(WIKILINK_RE, (_match, bang, title, display) => {
+ const titleTrimmed = title.trim();
+ const displayText = display ? display.trim() : titleTrimmed;
+ const isTransclusion = bang === '!';
+
+ wikilinks.push({ title: titleTrimmed, display: displayText, isTransclusion });
+
+ if (isTransclusion) {
+ return `` +
+ `` +
+ `
Loading\u2026
`;
+ }
+
+ return `${_escapeHtml(displayText)}`;
+ });
+
+ return { html: rendered, wikilinks };
+}
+
+/**
+ * Attach click handlers to wikilink elements inside a container.
+ * @param {HTMLElement} container
+ * @param {(title: string) => void} onLinkClick
+ */
+export function attachWikilinkHandlers(container, onLinkClick) {
+ if (!container || !onLinkClick) return;
+ container.querySelectorAll('[data-link-title]').forEach(el => {
+ el.style.cursor = 'pointer';
+ el.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onLinkClick(el.dataset.linkTitle);
+ });
+ });
+}
+
+/**
+ * Resolve transclusion blocks — load content for ![[Title]] embeds.
+ * @param {HTMLElement} container
+ * @param {(title: string) => void} onLinkClick
+ */
+export async function resolveTransclusions(container, onLinkClick) {
+ const embeds = container?.querySelectorAll('.sw-notes-pane__transclusion');
+ if (!embeds?.length) return;
+
+ const api = window.sw?.api?.notes;
+ if (!api) return;
+
+ const cache = {};
+ for (const el of embeds) {
+ const title = el.dataset.title;
+ const contentEl = el.querySelector('.sw-notes-pane__transclusion-content');
+ if (!title || !contentEl) continue;
+
+ try {
+ let note = cache[title.toLowerCase()];
+ if (!note) {
+ const resp = await api.searchTitles(title, 1);
+ const match = (resp.data || resp || []).find(
+ n => n.title.toLowerCase() === title.toLowerCase()
+ );
+ if (!match) {
+ contentEl.innerHTML = 'Note not found';
+ continue;
+ }
+ note = await api.get(match.id);
+ cache[title.toLowerCase()] = note;
+ }
+ // Render content (strip nested transclusions to prevent recursion)
+ const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
+ const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
+ return `[Embedded: ${inner}]`;
+ });
+ const { html } = renderNoteMarkdown(safeContent);
+ contentEl.innerHTML = html;
+ // Attach link handlers inside transclusion
+ if (onLinkClick) attachWikilinkHandlers(contentEl, onLinkClick);
+ } catch {
+ contentEl.innerHTML = 'Failed to load';
+ }
+ }
+}
+
+/**
+ * Extract headings from markdown content for outline/TOC.
+ * @param {string} content — raw markdown
+ * @returns {Array<{level: number, text: string, id: string}>}
+ */
+export function extractHeadings(content) {
+ if (!content) return [];
+ const headings = [];
+ const lines = content.split('\n');
+ for (const line of lines) {
+ const match = line.match(/^(#{1,3})\s+(.+)/);
+ if (match) {
+ const text = match[2].trim();
+ const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
+ headings.push({ level: match[1].length, text, id });
+ }
+ }
+ return headings;
+}
+
+function _escapeHtml(str) {
+ const el = document.createElement('div');
+ el.textContent = str;
+ return el.innerHTML;
+}
+
+function _escapeAttr(str) {
+ return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
+}
diff --git a/src/js/sw/components/notes-pane/note-editor.js b/src/js/sw/components/notes-pane/note-editor.js
new file mode 100644
index 0000000..fb595fb
--- /dev/null
+++ b/src/js/sw/components/notes-pane/note-editor.js
@@ -0,0 +1,172 @@
+// ==========================================
+// NotesPane Kit — NoteEditor
+// ==========================================
+// Editor view: title, folder, tags, content with
+// CM6 integration (falls back to textarea).
+// Word/char count, keyboard shortcuts.
+// Independently importable.
+
+const { useState, useRef, useCallback, useEffect } = window.hooks;
+const html = window.html;
+
+/**
+ * @param {{
+ * currentNote: object|null,
+ * editingId: string|null,
+ * folders: Array,
+ * onSave: (fields: object) => Promise,
+ * onCancel: () => void,
+ * onDelete: () => void,
+ * }} props
+ */
+export function NoteEditor(props) {
+ const { currentNote, editingId, folders, onSave, onCancel, onDelete } = props;
+
+ const [title, setTitle] = useState(currentNote?.title || '');
+ const [folderPath, setFolderPath] = useState(currentNote?.folder_path || '');
+ const [tags, setTags] = useState((currentNote?.tags || []).join(', '));
+ const [content, setContent] = useState(currentNote?.content || '');
+ const [saving, setSaving] = useState(false);
+
+ const titleRef = useRef(null);
+ const contentRef = useRef(null);
+ const cmEditorRef = useRef(null);
+ const containerRef = useRef(null);
+
+ // ── Populate from note ──────────────────
+ useEffect(() => {
+ setTitle(currentNote?.title || '');
+ setFolderPath(currentNote?.folder_path || '');
+ setTags((currentNote?.tags || []).join(', '));
+ setContent(currentNote?.content || '');
+ // Destroy old CM editor if any
+ if (cmEditorRef.current) {
+ cmEditorRef.current.destroy();
+ cmEditorRef.current = null;
+ }
+ }, [currentNote?.id]);
+
+ // ── Auto-focus title on new note ────────
+ useEffect(() => {
+ if (!editingId && titleRef.current) {
+ titleRef.current.focus();
+ }
+ }, [editingId]);
+
+ // ── CM6 integration ─────────────────────
+ useEffect(() => {
+ const cmContainer = containerRef.current;
+ if (!cmContainer) return;
+
+ if (window.CM?.noteEditor && !cmEditorRef.current) {
+ cmContainer.innerHTML = '';
+ cmEditorRef.current = window.CM.noteEditor(cmContainer, {
+ value: content,
+ darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
+ onChange: (val) => setContent(val),
+ onLink: null, // handled by reader, not editor
+ linkCompleter: async (query) => {
+ if (!query || query.length < 1) return [];
+ try {
+ const resp = await window.sw.api.notes.searchTitles(query, 8);
+ return (resp.data || resp || []).map(n => ({ label: n.title, id: n.id }));
+ } catch { return []; }
+ },
+ });
+ }
+
+ return () => {
+ if (cmEditorRef.current) {
+ cmEditorRef.current.destroy();
+ cmEditorRef.current = null;
+ }
+ };
+ }, []);
+
+ // ── Get content (CM or textarea) ────────
+ const getContent = useCallback(() => {
+ if (cmEditorRef.current) return cmEditorRef.current.getValue();
+ return content;
+ }, [content]);
+
+ // ── Word/char count ─────────────────────
+ const wordCount = content ? content.trim().split(/\s+/).filter(Boolean).length : 0;
+ const charCount = content ? content.length : 0;
+
+ // ── Save handler ────────────────────────
+ const handleSave = useCallback(async () => {
+ if (saving) return;
+ setSaving(true);
+ const tagsArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
+ const ok = await onSave({
+ title: title.trim(),
+ content: getContent(),
+ folder_path: folderPath.trim(),
+ tags: tagsArr,
+ });
+ setSaving(false);
+ }, [title, folderPath, tags, getContent, onSave, saving]);
+
+ // ── Keyboard shortcuts ──────────────────
+ useEffect(() => {
+ const handler = (e) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+ e.preventDefault();
+ handleSave();
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ onCancel();
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [handleSave, onCancel]);
+
+ return html`
+
+
+
+ `;
+}
diff --git a/src/js/sw/components/notes-pane/note-graph.js b/src/js/sw/components/notes-pane/note-graph.js
new file mode 100644
index 0000000..d97eb32
--- /dev/null
+++ b/src/js/sw/components/notes-pane/note-graph.js
@@ -0,0 +1,561 @@
+// ==========================================
+// NotesPane Kit — NoteGraph (Canvas)
+// ==========================================
+// Force-directed graph visualization of note connections.
+// Self-contained Preact component with physics sim,
+// pan/zoom/drag, hover highlighting, folder colors.
+// Lazy-loadable via dynamic import().
+// Independently importable.
+
+const { useRef, useEffect, useState, useCallback } = window.hooks;
+const html = window.html;
+
+// ── Simulation Parameters ───────────────────
+const SIM = {
+ repulsion: 800,
+ attraction: 0.006,
+ edgeLength: 130,
+ damping: 0.88,
+ centerGravity: 0.012,
+ maxVelocity: 8,
+ ticksPerFrame: 3,
+ coolThreshold: 0.5,
+};
+
+const BASE_RADIUS = 6;
+
+const FOLDER_COLORS = [
+ '#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6',
+ '#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316',
+];
+
+function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
+
+function _withAlpha(hex, alpha) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r},${g},${b},${alpha})`;
+}
+
+/**
+ * @param {{
+ * data: { nodes: Array, edges: Array, unresolved?: Array } | null,
+ * onNodeClick: (id: string) => void,
+ * onGhostClick: (title: string) => void,
+ * onBack: () => void,
+ * handleRef?: { current: any },
+ * }} props
+ */
+export function NoteGraph(props) {
+ const { data, onNodeClick, onGhostClick, onBack, handleRef } = props;
+
+ const canvasRef = useRef(null);
+ const wrapRef = useRef(null);
+ const stateRef = useRef(null);
+ const [showOrphans, setShowOrphans] = useState(true);
+ const [focusNodeId, setFocusNodeId] = useState(null);
+ const [stats, setStats] = useState({ nodes: 0, edges: 0 });
+
+ // ── Prepare graph data with physics state ──
+ useEffect(() => {
+ if (!data || !canvasRef.current) return;
+
+ const canvas = canvasRef.current;
+ const rect = wrapRef.current.getBoundingClientRect();
+ canvas.width = Math.round(rect.width);
+ canvas.height = Math.round(rect.height);
+
+ const cx = canvas.width / 2;
+ const cy = canvas.height / 2;
+
+ // Add physics state to nodes
+ const nodeMap = {};
+ const folderColorMap = {};
+
+ function folderColor(path) {
+ if (!path || path === '/') return '#6b7280';
+ if (!folderColorMap[path]) {
+ const idx = Object.keys(folderColorMap).length % FOLDER_COLORS.length;
+ folderColorMap[path] = FOLDER_COLORS[idx];
+ }
+ return folderColorMap[path];
+ }
+
+ const nodes = data.nodes.map(n => {
+ const node = {
+ ...n,
+ x: cx + (Math.random() - 0.5) * canvas.width * 0.5,
+ y: cy + (Math.random() - 0.5) * canvas.height * 0.5,
+ vx: 0, vy: 0, ax: 0, ay: 0,
+ pinned: false, isGhost: false,
+ };
+ nodeMap[n.id] = node;
+ return node;
+ });
+
+ // Resolve edge references
+ const edges = data.edges.map(e => ({
+ ...e,
+ sourceNode: nodeMap[e.source],
+ targetNode: nodeMap[e.target],
+ }));
+
+ // Ghost nodes for unresolved links
+ const ghostNodes = [];
+ if (data.unresolved) {
+ const ghostMap = {};
+ for (const d of data.unresolved) {
+ const key = d.title.toLowerCase();
+ if (!ghostMap[key]) {
+ const ghost = {
+ id: '__ghost_' + key, title: d.title,
+ folder_path: '', tags: [], link_count: 0,
+ x: cx + (Math.random() - 0.5) * canvas.width * 0.4,
+ y: cy + (Math.random() - 0.5) * canvas.height * 0.4,
+ vx: 0, vy: 0, ax: 0, ay: 0,
+ pinned: false, isGhost: true,
+ };
+ ghostMap[key] = ghost;
+ ghostNodes.push(ghost);
+ }
+ }
+ }
+
+ setStats({ nodes: nodes.length, edges: edges.length });
+
+ const state = {
+ nodes, edges, ghostNodes, nodeMap, folderColorMap, folderColor,
+ panX: 0, panY: 0, zoom: 1,
+ animId: null, canvas, ctx: canvas.getContext('2d'),
+ hoveredNode: null, dragNode: null,
+ isDragging: false, lastMouse: null,
+ showOrphans: true,
+ focusNodeId: null,
+ onNodeClick, onGhostClick,
+ onFocusNode: (id) => setFocusNodeId(id),
+ };
+
+ stateRef.current = state;
+
+ // Start simulation
+ _startSim(state, onNodeClick);
+
+ // Attach listeners
+ const cleanup = _attachListeners(state, onNodeClick);
+
+ // Resize observer
+ let roFrame = null;
+ const ro = new ResizeObserver(() => {
+ if (roFrame) return;
+ roFrame = requestAnimationFrame(() => {
+ roFrame = null;
+ const r = wrapRef.current.getBoundingClientRect();
+ const w = Math.round(r.width);
+ const h = Math.round(r.height);
+ if (canvas.width !== w || canvas.height !== h) {
+ canvas.width = w;
+ canvas.height = h;
+ _render(state);
+ }
+ });
+ });
+ ro.observe(wrapRef.current);
+
+ return () => {
+ if (state.animId) cancelAnimationFrame(state.animId);
+ state.animId = null;
+ cleanup();
+ ro.disconnect();
+ };
+ }, [data, onNodeClick]);
+
+ // ── Sync showOrphans + focusNodeId into state ──
+ useEffect(() => {
+ if (stateRef.current) {
+ stateRef.current.showOrphans = showOrphans;
+ stateRef.current.focusNodeId = focusNodeId;
+ _render(stateRef.current);
+ }
+ }, [showOrphans, focusNodeId]);
+
+ // ── Expose imperative handle ────────────
+ useEffect(() => {
+ if (handleRef) {
+ handleRef.current = {
+ resetZoom() {
+ const s = stateRef.current;
+ if (!s) return;
+ s.panX = 0; s.panY = 0; s.zoom = 1;
+ _render(s);
+ },
+ toggleOrphans() {
+ setShowOrphans(p => !p);
+ },
+ focusNode(id) {
+ setFocusNodeId(id);
+ },
+ clearFocus() {
+ setFocusNodeId(null);
+ },
+ };
+ }
+ });
+
+ // ── Focus mode label ───────────────────
+ const focusLabel = focusNodeId && stateRef.current
+ ? (stateRef.current.nodes.find(n => n.id === focusNodeId)?.title || 'Unknown')
+ : null;
+
+ return html`
+
+
+
+
+ ${stats.nodes} notes · ${stats.edges} links
+
+
+ ${focusNodeId && html`
+ Focus: ${focusLabel}
+ `}
+
+
+
+
+
+
+
+ `;
+}
+
+// ── Simulation ──────────────────────────────
+
+function _allNodes(state) {
+ const nodes = [...state.nodes];
+ if (state.showOrphans !== false && state.ghostNodes) {
+ nodes.push(...state.ghostNodes);
+ }
+ return nodes;
+}
+
+function _startSim(state, onNodeClick) {
+ function frame() {
+ for (let t = 0; t < SIM.ticksPerFrame; t++) _tick(state);
+ _render(state);
+
+ let totalKE = 0;
+ for (const n of _allNodes(state)) {
+ totalKE += n.vx * n.vx + n.vy * n.vy;
+ }
+ if (totalKE > SIM.coolThreshold || state.dragNode) {
+ state.animId = requestAnimationFrame(frame);
+ } else {
+ state.animId = null;
+ _render(state);
+ }
+ }
+
+ if (state.animId) cancelAnimationFrame(state.animId);
+ state.animId = requestAnimationFrame(frame);
+}
+
+function _tick(state) {
+ const nodes = _allNodes(state);
+ const edges = state.edges;
+ if (nodes.length === 0) return;
+
+ for (const n of nodes) { n.ax = 0; n.ay = 0; }
+
+ // Repulsion
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ const dx = nodes[j].x - nodes[i].x;
+ const dy = nodes[j].y - nodes[i].y;
+ const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
+ const f = SIM.repulsion / (dist * dist);
+ const fx = (dx / dist) * f;
+ const fy = (dy / dist) * f;
+ nodes[i].ax -= fx; nodes[i].ay -= fy;
+ nodes[j].ax += fx; nodes[j].ay += fy;
+ }
+ }
+
+ // Attraction (edges)
+ for (const e of edges) {
+ const s = e.sourceNode, t = e.targetNode;
+ if (!s || !t) continue;
+ const dx = t.x - s.x, dy = t.y - s.y;
+ const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
+ const f = SIM.attraction * (dist - SIM.edgeLength);
+ const fx = (dx / dist) * f;
+ const fy = (dy / dist) * f;
+ s.ax += fx; s.ay += fy;
+ t.ax -= fx; t.ay -= fy;
+ }
+
+ // Center gravity + integration
+ const cx = state.canvas.width / 2, cy = state.canvas.height / 2;
+ for (const n of nodes) {
+ if (n.pinned) continue;
+ n.ax += (cx - n.x) * SIM.centerGravity;
+ n.ay += (cy - n.y) * SIM.centerGravity;
+ n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
+ n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
+ n.x += n.vx;
+ n.y += n.vy;
+ }
+}
+
+// ── Rendering ───────────────────────────────
+
+function _render(state) {
+ const { ctx, canvas, panX, panY, zoom, hoveredNode, focusNodeId } = state;
+ if (!ctx) return;
+
+ const nodes = _allNodes(state);
+ const edges = state.edges;
+
+ // Build focus neighbor set for focus mode
+ const focusNeighborIds = new Set();
+ if (focusNodeId) {
+ focusNeighborIds.add(focusNodeId);
+ for (const e of edges) {
+ if (e.source === focusNodeId) focusNeighborIds.add(e.target);
+ if (e.target === focusNodeId) focusNeighborIds.add(e.source);
+ }
+ }
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+ ctx.translate(panX, panY);
+ ctx.scale(zoom, zoom);
+
+ // Edges
+ for (const e of edges) {
+ const s = e.sourceNode, t = e.targetNode;
+ if (!s || !t) continue;
+
+ // Focus mode: hide edges not touching the focus neighborhood
+ if (focusNodeId && !focusNeighborIds.has(e.source) && !focusNeighborIds.has(e.target)) continue;
+ const isFocusDimmed = focusNodeId && !(focusNeighborIds.has(e.source) && focusNeighborIds.has(e.target));
+
+ const isHighlighted = hoveredNode && (s.id === hoveredNode.id || t.id === hoveredNode.id);
+
+ ctx.beginPath();
+ ctx.moveTo(s.x, s.y);
+ ctx.lineTo(t.x, t.y);
+
+ if (e.is_transclusion) {
+ ctx.setLineDash([4, 4]);
+ ctx.strokeStyle = isHighlighted ? 'rgba(99,102,241,0.8)'
+ : isFocusDimmed ? 'rgba(99,102,241,0.08)' : 'rgba(99,102,241,0.3)';
+ } else {
+ ctx.setLineDash([]);
+ ctx.strokeStyle = isHighlighted ? 'rgba(160,160,160,0.8)'
+ : isFocusDimmed ? 'rgba(120,120,120,0.06)' : 'rgba(120,120,120,0.25)';
+ }
+ ctx.lineWidth = isHighlighted ? 2 : 1;
+ ctx.stroke();
+ }
+ ctx.setLineDash([]);
+
+ // Nodes
+ for (const n of nodes) {
+ if (n.isGhost && !state.showOrphans) continue;
+
+ const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
+ const isHovered = hoveredNode && n.id === hoveredNode.id;
+ const isNeighbor = hoveredNode && _isNeighbor(state, hoveredNode, n);
+ const isFocusVisible = !focusNodeId || focusNeighborIds.has(n.id);
+ const isDimmed = (hoveredNode && !isHovered && !isNeighbor) || !isFocusVisible;
+
+ ctx.beginPath();
+ ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
+
+ if (n.isGhost) {
+ ctx.fillStyle = isDimmed ? 'rgba(200,50,50,0.1)' : 'rgba(200,50,50,0.4)';
+ ctx.setLineDash([2, 2]);
+ ctx.strokeStyle = 'rgba(200,50,50,0.5)';
+ } else if (isHovered) {
+ ctx.fillStyle = '#6366f1';
+ } else {
+ const color = state.folderColor(n.folder_path);
+ ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7);
+ ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)';
+ }
+
+ ctx.fill();
+ if (!n.isGhost) {
+ ctx.setLineDash([]);
+ ctx.lineWidth = isHovered ? 2 : 1;
+ ctx.stroke();
+ } else {
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // Labels
+ if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) {
+ const fontSize = Math.max(10, 11 / zoom);
+ ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`;
+ ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ const label = n.title.length > 30 ? n.title.slice(0, 28) + '\u2026' : n.title;
+ ctx.fillText(label, n.x, n.y + r + 4);
+ }
+ }
+
+ ctx.restore();
+}
+
+function _isNeighbor(state, hovered, candidate) {
+ for (const e of state.edges) {
+ if ((e.source === hovered.id && e.target === candidate.id) ||
+ (e.target === hovered.id && e.source === candidate.id)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// ── Interaction ─────────────────────────────
+
+function _attachListeners(state, onNodeClick) {
+ const canvas = state.canvas;
+
+ function toGraph(e) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: (e.clientX - rect.left - state.panX) / state.zoom,
+ y: (e.clientY - rect.top - state.panY) / state.zoom,
+ };
+ }
+
+ function hitTest(gx, gy) {
+ const nodes = _allNodes(state);
+ for (let i = nodes.length - 1; i >= 0; i--) {
+ const n = nodes[i];
+ const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
+ const dx = n.x - gx, dy = n.y - gy;
+ if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
+ }
+ return null;
+ }
+
+ const DRAG_THRESHOLD = 5; // px — must move at least this far to count as drag
+
+ const onMouseDown = (e) => {
+ const g = toGraph(e);
+ const node = hitTest(g.x, g.y);
+ if (node) {
+ state.dragNode = node;
+ if (!node.isGhost) node.pinned = true;
+ state.isDragging = false;
+ }
+ state.lastMouse = { x: e.clientX, y: e.clientY };
+ state.mouseDownPos = { x: e.clientX, y: e.clientY };
+ };
+
+ const onMouseMove = (e) => {
+ const g = toGraph(e);
+ if (state.dragNode) {
+ // Only count as drag if moved past threshold
+ if (!state.isDragging && state.mouseDownPos) {
+ const dx = e.clientX - state.mouseDownPos.x;
+ const dy = e.clientY - state.mouseDownPos.y;
+ if (dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD) return;
+ }
+ state.isDragging = true;
+ state.dragNode.x = g.x;
+ state.dragNode.y = g.y;
+ state.dragNode.vx = 0;
+ state.dragNode.vy = 0;
+ if (!state.animId) _startSim(state, onNodeClick);
+ } else if (state.lastMouse && e.buttons === 1) {
+ state.panX += e.clientX - state.lastMouse.x;
+ state.panY += e.clientY - state.lastMouse.y;
+ state.lastMouse = { x: e.clientX, y: e.clientY };
+ _render(state);
+ } else {
+ const node = hitTest(g.x, g.y);
+ if (node !== state.hoveredNode) {
+ state.hoveredNode = node;
+ canvas.style.cursor = node ? 'pointer' : 'default';
+ _render(state);
+ }
+ }
+ };
+
+ const onMouseUp = (e) => {
+ if (state.dragNode) {
+ const clicked = state.dragNode;
+ clicked.pinned = false;
+ if (!state.isDragging) {
+ if (clicked.isGhost && state.onGhostClick) {
+ // Ghost node → create note with that title
+ state.onGhostClick(clicked.title);
+ } else if (!clicked.isGhost) {
+ // Single click → focus on this node (show neighbors)
+ if (state.onFocusNode) {
+ state.onFocusNode(clicked.id === state.focusNodeId ? null : clicked.id);
+ }
+ }
+ }
+ state.dragNode = null;
+ } else if (!state.isDragging) {
+ // Clicked empty space → clear focus
+ if (state.onFocusNode && state.focusNodeId) {
+ state.onFocusNode(null);
+ }
+ }
+ state.lastMouse = null;
+ state.isDragging = false;
+ };
+
+ // Double-click → open note
+ const onDblClick = (e) => {
+ const g = toGraph(e);
+ const node = hitTest(g.x, g.y);
+ if (node && !node.isGhost && onNodeClick) {
+ onNodeClick(node.id);
+ } else if (node && node.isGhost && state.onGhostClick) {
+ state.onGhostClick(node.title);
+ }
+ };
+
+ const onWheel = (e) => {
+ e.preventDefault();
+ const factor = e.deltaY > 0 ? 0.92 : 1.08;
+ const newZoom = _clamp(state.zoom * factor, 0.15, 4.0);
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ state.panX = mx - (mx - state.panX) * (newZoom / state.zoom);
+ state.panY = my - (my - state.panY) * (newZoom / state.zoom);
+ state.zoom = newZoom;
+ _render(state);
+ };
+
+ canvas.addEventListener('mousedown', onMouseDown);
+ canvas.addEventListener('mousemove', onMouseMove);
+ canvas.addEventListener('mouseup', onMouseUp);
+ canvas.addEventListener('dblclick', onDblClick);
+ canvas.addEventListener('wheel', onWheel, { passive: false });
+
+ return () => {
+ canvas.removeEventListener('mousedown', onMouseDown);
+ canvas.removeEventListener('mousemove', onMouseMove);
+ canvas.removeEventListener('mouseup', onMouseUp);
+ canvas.removeEventListener('dblclick', onDblClick);
+ canvas.removeEventListener('wheel', onWheel);
+ };
+}
diff --git a/src/js/sw/components/notes-pane/note-list-item.js b/src/js/sw/components/notes-pane/note-list-item.js
new file mode 100644
index 0000000..bd18e0c
--- /dev/null
+++ b/src/js/sw/components/notes-pane/note-list-item.js
@@ -0,0 +1,103 @@
+// ==========================================
+// NotesPane Kit — NoteListItem
+// ==========================================
+// Single note row in the list view.
+// Independently importable.
+
+const html = window.html;
+
+function _relativeTime(iso) {
+ if (!iso) return '';
+ const diff = Date.now() - new Date(iso).getTime();
+ const s = Math.floor(diff / 1000);
+ if (s < 60) return 'just now';
+ const m = Math.floor(s / 60);
+ if (m < 60) return m + 'm ago';
+ const h = Math.floor(m / 60);
+ if (h < 24) return h + 'h ago';
+ const d = Math.floor(h / 24);
+ if (d < 30) return d + 'd ago';
+ return new Date(iso).toLocaleDateString();
+}
+
+function _esc(s) {
+ const el = document.createElement('span');
+ el.textContent = s;
+ return el.innerHTML;
+}
+
+function _highlightHeadline(headline) {
+ const escaped = _esc(headline);
+ return escaped.replace(/\*\*(.+?)\*\*/g, '$1');
+}
+
+/**
+ * @param {{
+ * note: object,
+ * isSearch?: boolean,
+ * onClick: (id: string) => void,
+ * selectMode?: boolean,
+ * selected?: boolean,
+ * onToggleSelect?: (id: string) => void,
+ * pinned?: boolean,
+ * onTogglePin?: (id: string) => void,
+ * }} props
+ */
+export function NoteListItem(props) {
+ const { note, isSearch, onClick, selectMode, selected, onToggleSelect, pinned, onTogglePin } = props;
+
+ const preview = isSearch && note.headline
+ ? html``
+ : html`
+ ${(note.preview || note.content || '').slice(0, 120)}
+
`;
+
+ const tags = (note.tags || []).map(t =>
+ html`${t}`
+ );
+
+ const folderBadge = note.folder_path && note.folder_path !== '/'
+ ? html`${note.folder_path}`
+ : null;
+
+ const onItemClick = (e) => {
+ if (selectMode && onToggleSelect) {
+ onToggleSelect(note.id);
+ } else {
+ onClick(note.id);
+ }
+ };
+
+ const onCheckChange = (e) => {
+ e.stopPropagation();
+ if (onToggleSelect) onToggleSelect(note.id);
+ };
+
+ const onPinClick = (e) => {
+ e.stopPropagation();
+ if (onTogglePin) onTogglePin(note.id);
+ };
+
+ return html`
+
+ ${selectMode && html`
+
`}
+
+
+ ${preview}
+ ${(folderBadge || tags.length > 0) && html`
+
${folderBadge}${tags}
`}
+
+ ${onTogglePin && html`
+
`}
+
`;
+}
diff --git a/src/js/sw/components/notes-pane/note-list.js b/src/js/sw/components/notes-pane/note-list.js
new file mode 100644
index 0000000..47cf8d5
--- /dev/null
+++ b/src/js/sw/components/notes-pane/note-list.js
@@ -0,0 +1,163 @@
+// ==========================================
+// NotesPane Kit — NoteList
+// ==========================================
+// List view: toolbar, search, filters, tag cloud,
+// scrollable note list with pinned section,
+// selection bar, and pagination.
+// Independently importable.
+
+import { NoteListItem } from './note-list-item.js';
+
+const { useState, useRef, useCallback, useEffect } = window.hooks;
+const html = window.html;
+
+/**
+ * @param {{
+ * notes: Array, loading: boolean, hasMore: boolean, loadMore: () => void,
+ * folders: Array, folder: string, setFolder: (f: string) => void,
+ * sort: string, setSort: (s: string) => void,
+ * searchQuery: string, setSearchQuery: (q: string) => void,
+ * tagFilter: string, setTagFilter: (t: string) => void,
+ * selectMode: boolean, selectedIds: Set, enterSelectMode: () => void,
+ * exitSelectMode: () => void, toggleSelect: (id: string) => void,
+ * toggleSelectAll: (checked: boolean) => void, bulkDelete: () => void,
+ * onNoteClick: (id: string) => void, onNewNote: () => void,
+ * onDailyNote: () => void, onGraphClick: () => void,
+ * pinnedIds: Set, togglePin: (id: string) => void,
+ * }} props
+ */
+export function NoteList(props) {
+ const {
+ notes, loading, hasMore, loadMore,
+ folders, folder, setFolder,
+ sort, setSort,
+ searchQuery, setSearchQuery,
+ tagFilter, setTagFilter,
+ selectMode, selectedIds, enterSelectMode, exitSelectMode,
+ toggleSelect, toggleSelectAll, bulkDelete,
+ onNoteClick, onNewNote, onDailyNote, onGraphClick,
+ pinnedIds, togglePin,
+ } = props;
+
+ const searchTimerRef = useRef(null);
+ const searchInputRef = useRef(null);
+
+ const onSearchInput = useCallback((e) => {
+ const val = e.target.value;
+ clearTimeout(searchTimerRef.current);
+ searchTimerRef.current = setTimeout(() => {
+ if (val.trim().length >= 2 || val.trim().length === 0) {
+ setSearchQuery(val.trim());
+ }
+ }, 300);
+ }, [setSearchQuery]);
+
+ // ── Tag cloud: collect tags from visible notes ──
+ const tagCounts = {};
+ for (const n of notes) {
+ for (const t of (n.tags || [])) {
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
+ }
+ }
+ const topTags = Object.entries(tagCounts)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 15);
+
+ // ── Split pinned vs unpinned ──
+ const isSearch = searchQuery && searchQuery.length >= 2;
+ const pinnedNotes = isSearch ? [] : notes.filter(n => pinnedIds.has(n.id));
+ const unpinnedNotes = isSearch ? notes : notes.filter(n => !pinnedIds.has(n.id));
+
+ return html`
+
+
+
+ ${onGraphClick && html`
+ `}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${topTags.length > 0 && html`
+
+ ${topTags.map(([tag, count]) => html`
+ setTagFilter(tagFilter === tag ? '' : tag)}>
+ ${tag}
+ `)}
+
`}
+
+
+ ${loading && notes.length === 0 && html`
+
Loading\u2026
`}
+
+ ${!loading && notes.length === 0 && html`
+
+ ${isSearch ? 'No results found' : (folder ? 'No notes in this folder' : 'No notes yet. Create one!')}
+
`}
+
+ ${pinnedNotes.length > 0 && html`
+
Pinned
+ ${pinnedNotes.map(n => html`
+ <${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
+ onClick=${onNoteClick} selectMode=${selectMode}
+ selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
+ pinned=${true} onTogglePin=${togglePin} />`)}
+ ${unpinnedNotes.length > 0 && html`
+
Notes
`}`}
+
+ ${unpinnedNotes.map(n => html`
+ <${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
+ onClick=${onNoteClick} selectMode=${selectMode}
+ selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
+ pinned=${false} onTogglePin=${togglePin} />`)}
+
+ ${hasMore && html`
+
`}
+
+
+ ${selectMode && html`
+
+
+ ${selectedIds.size} selected
+
+
+
`}
+ `;
+}
diff --git a/src/js/sw/components/notes-pane/note-reader.js b/src/js/sw/components/notes-pane/note-reader.js
new file mode 100644
index 0000000..dd8f5a2
--- /dev/null
+++ b/src/js/sw/components/notes-pane/note-reader.js
@@ -0,0 +1,184 @@
+// ==========================================
+// NotesPane Kit — NoteReader
+// ==========================================
+// Read-mode view: rendered markdown with wikilink chips,
+// transclusion embeds, outline/TOC, backlinks panel,
+// unlinked mentions, daily note navigation.
+// Independently importable.
+
+import { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
+
+const { useState, useRef, useCallback, useEffect } = window.hooks;
+const html = window.html;
+
+/**
+ * @param {{
+ * note: object,
+ * backlinks: Array,
+ * onEdit: () => void,
+ * onDelete: () => void,
+ * onCopy: () => void,
+ * onNavigateLink: (title: string) => void,
+ * onBack: () => void,
+ * isDailyNote: boolean,
+ * onDailyPrev: () => void,
+ * onDailyNext: () => void,
+ * }} props
+ */
+export function NoteReader(props) {
+ const {
+ note, backlinks, onEdit, onDelete, onCopy, onNavigateLink,
+ onBack, isDailyNote, onDailyPrev, onDailyNext,
+ } = props;
+
+ const contentRef = useRef(null);
+ const [backlinksOpen, setBacklinksOpen] = useState(true);
+ const [unlinkedMentions, setUnlinkedMentions] = useState([]);
+
+ // ── Render content + wikilinks ──────────
+ useEffect(() => {
+ if (!contentRef.current || !note) return;
+ const { html: rendered } = renderNoteMarkdown(note.content || '');
+ contentRef.current.innerHTML = rendered;
+
+ // Attach wikilink click handlers
+ attachWikilinkHandlers(contentRef.current, onNavigateLink);
+
+ // Resolve transclusions
+ resolveTransclusions(contentRef.current, onNavigateLink);
+
+ // Add IDs to headings for outline scroll-to
+ contentRef.current.querySelectorAll('h1, h2, h3').forEach(el => {
+ const id = el.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
+ el.id = 'heading-' + id;
+ });
+ }, [note?.id, note?.content, onNavigateLink]);
+
+ // ── Extract headings for outline ────────
+ const headings = note ? extractHeadings(note.content || '') : [];
+
+ // ── Find unlinked mentions ──────────────
+ useEffect(() => {
+ if (!note?.content) { setUnlinkedMentions([]); return; }
+ // Debounce: fetch note titles and check for unlinked mentions
+ const timer = setTimeout(async () => {
+ try {
+ const api = window.sw?.api?.notes;
+ if (!api) return;
+ // Get all note titles (limited)
+ const resp = await api.list({ limit: 200, offset: 0 });
+ const allNotes = resp.data || resp || [];
+ const content = note.content.toLowerCase();
+
+ // Find titles mentioned in content but not wikilinked
+ const linked = new Set();
+ const linkRe = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
+ let m;
+ while ((m = linkRe.exec(note.content)) !== null) {
+ linked.add(m[1].trim().toLowerCase());
+ }
+
+ const mentions = allNotes
+ .filter(n => n.id !== note.id)
+ .filter(n => {
+ const t = n.title.toLowerCase();
+ return t.length > 2 && content.includes(t) && !linked.has(t);
+ })
+ .map(n => n.title)
+ .slice(0, 10);
+
+ setUnlinkedMentions(mentions);
+ } catch { setUnlinkedMentions([]); }
+ }, 500);
+ return () => clearTimeout(timer);
+ }, [note?.id, note?.content]);
+
+ if (!note) return null;
+
+ const folderBadge = note.folder_path && note.folder_path !== '/'
+ ? html`${note.folder_path}`
+ : null;
+
+ const tagChips = (note.tags || []).map(t =>
+ html`${t}`
+ );
+
+ const today = new Date().toISOString().slice(0, 10);
+ const dailyDate = isDailyNote ? note.title.replace('Daily \u2014 ', '') : null;
+
+ const scrollToHeading = (id) => {
+ const el = contentRef.current?.querySelector('#heading-' + id);
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ };
+
+ return html`
+
+
+ 2 ? ' sw-notes-pane__reader--with-outline' : '')}>
+ ${headings.length > 2 && html`
+ `}
+
+
+
+ `;
+}
diff --git a/src/js/sw/components/notes-pane/quick-switcher.js b/src/js/sw/components/notes-pane/quick-switcher.js
new file mode 100644
index 0000000..e6e061d
--- /dev/null
+++ b/src/js/sw/components/notes-pane/quick-switcher.js
@@ -0,0 +1,159 @@
+// ==========================================
+// NotesPane Kit — QuickSwitcher
+// ==========================================
+// Cmd+K / Ctrl+K style quick-open overlay.
+// Fuzzy title search, keyboard nav, recent notes,
+// create-on-enter. Obsidian's signature UX.
+// Independently importable.
+
+const { useState, useRef, useCallback, useEffect } = window.hooks;
+const html = window.html;
+
+const RECENTS_KEY = 'sw_notes_recent';
+const MAX_RECENTS = 5;
+
+function _loadRecents() {
+ try {
+ return JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]').slice(0, MAX_RECENTS);
+ } catch { return []; }
+}
+
+function _saveRecent(note) {
+ try {
+ const recents = _loadRecents().filter(r => r.id !== note.id);
+ recents.unshift({ id: note.id, title: note.title, folder_path: note.folder_path });
+ localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
+ } catch {}
+}
+
+/**
+ * @param {{
+ * open: boolean,
+ * onClose: () => void,
+ * onSelect: (id: string) => void,
+ * onCreateNew: (title: string) => void,
+ * }} props
+ */
+export function QuickSwitcher(props) {
+ const { open, onClose, onSelect, onCreateNew } = props;
+
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [recents, setRecents] = useState([]);
+ const inputRef = useRef(null);
+ const searchTimerRef = useRef(null);
+
+ // ── Load recents on open ────────────────
+ useEffect(() => {
+ if (open) {
+ setQuery('');
+ setResults([]);
+ setActiveIndex(0);
+ setRecents(_loadRecents());
+ setTimeout(() => inputRef.current?.focus(), 50);
+ }
+ }, [open]);
+
+ // ── Search ──────────────────────────────
+ useEffect(() => {
+ clearTimeout(searchTimerRef.current);
+ if (!query || query.length < 1) {
+ setResults([]);
+ setActiveIndex(0);
+ return;
+ }
+ searchTimerRef.current = setTimeout(async () => {
+ try {
+ const resp = await window.sw.api.notes.searchTitles(query, 10);
+ setResults(resp.data || resp || []);
+ setActiveIndex(0);
+ } catch { setResults([]); }
+ }, 150);
+ }, [query]);
+
+ // ── Visible items ───────────────────────
+ const items = query ? results : recents;
+ const hasExactMatch = results.some(r => r.title.toLowerCase() === query.toLowerCase());
+ const showCreate = query && !hasExactMatch;
+
+ // ── Keyboard navigation ─────────────────
+ const onKeyDown = useCallback((e) => {
+ const totalItems = items.length + (showCreate ? 1 : 0);
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setActiveIndex(i => (i + 1) % Math.max(totalItems, 1));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActiveIndex(i => (i - 1 + totalItems) % Math.max(totalItems, 1));
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (activeIndex < items.length) {
+ _select(items[activeIndex]);
+ } else if (showCreate) {
+ onCreateNew(query);
+ onClose();
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ onClose();
+ }
+ }, [items, activeIndex, showCreate, query, onClose, onCreateNew]);
+
+ const _select = (note) => {
+ _saveRecent(note);
+ onSelect(note.id);
+ onClose();
+ };
+
+ if (!open) return null;
+
+ return html`
+ { if (e.target === e.currentTarget) onClose(); }}>
+
+
setQuery(e.target.value)}
+ onKeyDown=${onKeyDown} />
+
+
+ ${!query && recents.length > 0 && html`
+
Recent
`}
+
+ ${query && results.length === 0 && html`
+
+ No notes found
+
`}
+
+ ${items.map((note, i) => html`
+
_select(note)}
+ onMouseEnter=${() => setActiveIndex(i)}>
+ ${note.title}
+ ${note.folder_path && note.folder_path !== '/' && html`
+ ${note.folder_path}`}
+
`)}
+
+ ${showCreate && html`
+
{ onCreateNew(query); onClose(); }}
+ onMouseEnter=${() => setActiveIndex(items.length)}>
+ + Create "${query}"
+
`}
+
+
+
+ ↑↓ navigate · Enter select · Esc close
+
+
+
+ `;
+}
+
+// Re-export for external use (e.g., saving recents from openNote)
+export { _saveRecent as saveRecentNote };
diff --git a/src/js/sw/components/notes-pane/save-to-note.js b/src/js/sw/components/notes-pane/save-to-note.js
new file mode 100644
index 0000000..ed7f3be
--- /dev/null
+++ b/src/js/sw/components/notes-pane/save-to-note.js
@@ -0,0 +1,188 @@
+// ==========================================
+// NotesPane Kit — SaveToNoteDialog
+// ==========================================
+// "Save to note" dialog: create-new or append-to-existing.
+// Independently importable by any surface (ChatPane, etc.).
+
+import { renderNoteMarkdown } from './markdown.js';
+
+const { useState, useRef, useCallback, useEffect } = window.hooks;
+const html = window.html;
+
+/**
+ * @param {{
+ * open: boolean,
+ * onClose: () => void,
+ * content: string,
+ * sourceChannelId?: string,
+ * sourceMessageId?: string,
+ * defaultTitle?: string,
+ * }} props
+ */
+export function SaveToNoteDialog(props) {
+ const { open, onClose, content, sourceChannelId, sourceMessageId, defaultTitle } = props;
+
+ const [mode, setMode] = useState('create'); // 'create' | 'append'
+ const [title, setTitle] = useState(defaultTitle || '');
+ const [folderPath, setFolderPath] = useState('');
+ const [tags, setTags] = useState('');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [selectedNote, setSelectedNote] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ const searchTimerRef = useRef(null);
+ const titleInputRef = useRef(null);
+
+ // ── Reset on open ───────────────────────
+ useEffect(() => {
+ if (open) {
+ setMode('create');
+ setTitle(defaultTitle || '');
+ setFolderPath('');
+ setTags('');
+ setSearchQuery('');
+ setSearchResults([]);
+ setSelectedNote(null);
+ setTimeout(() => titleInputRef.current?.focus(), 50);
+ }
+ }, [open, defaultTitle]);
+
+ // ── Search for append target ────────────
+ useEffect(() => {
+ clearTimeout(searchTimerRef.current);
+ if (mode !== 'append' || searchQuery.length < 2) {
+ setSearchResults([]);
+ return;
+ }
+ searchTimerRef.current = setTimeout(async () => {
+ try {
+ const resp = await window.sw.api.notes.searchTitles(searchQuery, 8);
+ setSearchResults(resp.data || resp || []);
+ } catch { setSearchResults([]); }
+ }, 250);
+ }, [searchQuery, mode]);
+
+ // ── Save ────────────────────────────────
+ const handleSave = useCallback(async () => {
+ if (saving) return;
+ setSaving(true);
+ const api = window.sw.api.notes;
+ const toast = window.sw?.toast;
+
+ try {
+ if (mode === 'append') {
+ if (!selectedNote) {
+ toast?.('Select a note to append to', 'warning');
+ setSaving(false);
+ return;
+ }
+ await api.update(selectedNote.id, {
+ content: '\n\n---\n\n' + content,
+ mode: 'append',
+ });
+ toast?.(`Appended to "${selectedNote.title}"`, 'success');
+ } else {
+ if (!title.trim()) {
+ toast?.('Title is required', 'warning');
+ setSaving(false);
+ return;
+ }
+ const tagsArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
+ await api.create({
+ title: title.trim(),
+ content,
+ folder_path: folderPath.trim(),
+ tags: tagsArr,
+ source_channel_id: sourceChannelId,
+ source_message_id: sourceMessageId,
+ });
+ toast?.('Note created', 'success');
+ }
+ onClose();
+ } catch (e) {
+ toast?.(e.message, 'error');
+ }
+ setSaving(false);
+ }, [mode, title, folderPath, tags, content, selectedNote, sourceChannelId, sourceMessageId, onClose, saving]);
+
+ if (!open) return null;
+
+ // Preview
+ const previewContent = content ? content.slice(0, 300) + (content.length > 300 ? '\u2026' : '') : '';
+ const { html: previewHtml } = renderNoteMarkdown(previewContent);
+
+ return html`
+ { if (e.target === e.currentTarget) onClose(); }}>
+
+
+
Save to Note
+
+
+
+
+
+
+ ${mode === 'create' && html`
+
`}
+
+ ${mode === 'append' && html`
+
+
setSearchQuery(e.target.value)} />
+ ${searchResults.length > 0 && html`
+
+ ${searchResults.map(n => html`
+
{ setSelectedNote(n); setSearchQuery(n.title); }}>
+ ${n.title}
+
`)}
+
`}
+
`}
+
+
+
+ Preview (${content?.length || 0} chars)
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
diff --git a/src/js/sw/components/notes-pane/use-notes.js b/src/js/sw/components/notes-pane/use-notes.js
new file mode 100644
index 0000000..56d609c
--- /dev/null
+++ b/src/js/sw/components/notes-pane/use-notes.js
@@ -0,0 +1,475 @@
+// ==========================================
+// NotesPane Kit — useNotes Hook
+// ==========================================
+// Core state machine: views, CRUD, pagination, search,
+// folders, sort, selection, backlinks, daily notes,
+// wikilink navigation, pinning, quick switcher.
+// Independently importable.
+//
+// Usage:
+// const notes = useNotes({ onNoteChange });
+// notes.openNote(id);
+
+const { useState, useRef, useCallback, useEffect } = window.hooks;
+
+const PAGE_SIZE = 50;
+const PINS_KEY = 'sw_notes_pinned';
+
+function _loadPins() {
+ try {
+ const raw = localStorage.getItem(PINS_KEY);
+ return raw ? new Set(JSON.parse(raw)) : new Set();
+ } catch { return new Set(); }
+}
+
+function _savePins(set) {
+ try { localStorage.setItem(PINS_KEY, JSON.stringify([...set])); } catch {}
+}
+
+/**
+ * @param {{
+ * initialFolder?: string,
+ * initialNoteId?: string,
+ * onNoteChange?: (note: object|null) => void,
+ * standalone?: boolean,
+ * enableGraph?: boolean,
+ * enableQuickSwitcher?: boolean,
+ * }} opts
+ */
+export function useNotes(opts = {}) {
+ const {
+ initialFolder,
+ initialNoteId,
+ onNoteChange,
+ enableGraph = true,
+ enableQuickSwitcher = true,
+ } = opts;
+
+ // ── View state ──────────────────────────
+ const [view, _setView] = useState(initialNoteId ? 'reader' : 'list');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // ── List state ──────────────────────────
+ const [notes, setNotes] = useState([]);
+ const [folder, setFolder] = useState(initialFolder || '');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sort, setSort] = useState('updated_desc');
+ const [tagFilter, setTagFilter] = useState('');
+ const pageRef = useRef(1);
+ const [hasMore, setHasMore] = useState(false);
+ const [folders, setFolders] = useState([]);
+
+ // ── Selection state ─────────────────────
+ const [selectMode, setSelectMode] = useState(false);
+ const [selectedIds, setSelectedIds] = useState(new Set());
+
+ // ── Editor/Reader state ─────────────────
+ const [currentNote, setCurrentNote] = useState(null);
+ const [editingId, setEditingId] = useState(initialNoteId || null);
+ const [editorMode, setEditorMode] = useState('read'); // 'read' | 'edit' | 'preview'
+ const [backlinks, setBacklinks] = useState([]);
+
+ // ── Graph state ─────────────────────────
+ const [graphData, setGraphData] = useState(null);
+ const graphDirtyRef = useRef(true);
+
+ // ── Quick Switcher ──────────────────────
+ const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
+
+ // ── Pinning ─────────────────────────────
+ const [pinnedIds, setPinnedIds] = useState(_loadPins);
+
+ // ── API shortcut ────────────────────────
+ const api = () => window.sw.api.notes;
+ const toast = (msg, v) => window.sw?.toast?.(msg, v);
+ const confirm = (msg) => window.sw?.confirm?.(msg) ?? Promise.resolve(window.confirm(msg));
+
+ // ── View switching ──────────────────────
+ const setView = useCallback((v) => {
+ _setView(v);
+ if (v === 'list') {
+ setCurrentNote(null);
+ setEditingId(null);
+ setBacklinks([]);
+ if (onNoteChange) onNoteChange(null);
+ }
+ }, [onNoteChange]);
+
+ // ── Load notes list ─────────────────────
+ const _loadList = useCallback(async (append = false) => {
+ setLoading(true);
+ setError(null);
+ try {
+ let result, isSearch = false;
+ if (searchQuery && searchQuery.length >= 2) {
+ isSearch = true;
+ const data = await api().search(searchQuery);
+ result = data.data || data || [];
+ setHasMore(false);
+ } else {
+ const page = append ? pageRef.current : 1;
+ if (!append) pageRef.current = 1;
+ const offset = (page - 1) * PAGE_SIZE;
+ const data = await api().list({ limit: PAGE_SIZE, offset, folder, tag: tagFilter, sort });
+ result = data.data || data || [];
+ setHasMore(result.length >= PAGE_SIZE);
+ }
+ if (append) {
+ setNotes(prev => [...prev, ...result]);
+ } else {
+ setNotes(result);
+ }
+ } catch (e) {
+ setError(e.message);
+ }
+ setLoading(false);
+ }, [searchQuery, folder, tagFilter, sort]);
+
+ const refreshList = useCallback(() => {
+ pageRef.current = 1;
+ _loadList(false);
+ }, [_loadList]);
+
+ const loadMore = useCallback(() => {
+ pageRef.current += 1;
+ _loadList(true);
+ }, [_loadList]);
+
+ // ── Load folders ────────────────────────
+ const _loadFolders = useCallback(async () => {
+ try {
+ const data = await api().folders();
+ setFolders(data.folders || data || []);
+ } catch {}
+ }, []);
+
+ // ── Initial load ────────────────────────
+ const didMountRef = useRef(false);
+ useEffect(() => {
+ if (!didMountRef.current) {
+ didMountRef.current = true;
+ _loadList();
+ _loadFolders();
+ if (initialNoteId) {
+ _openNoteById(initialNoteId);
+ }
+ }
+ }, []);
+
+ // ── Reload on filter changes ────────────
+ const prevFiltersRef = useRef({ folder, sort, tagFilter, searchQuery });
+ useEffect(() => {
+ const prev = prevFiltersRef.current;
+ if (prev.folder !== folder || prev.sort !== sort ||
+ prev.tagFilter !== tagFilter || prev.searchQuery !== searchQuery) {
+ prevFiltersRef.current = { folder, sort, tagFilter, searchQuery };
+ if (didMountRef.current) {
+ pageRef.current = 1;
+ _loadList(false);
+ }
+ }
+ }, [folder, sort, tagFilter, searchQuery, _loadList]);
+
+ // ── Selection ───────────────────────────
+ const enterSelectMode = useCallback(() => {
+ setSelectMode(true);
+ setSelectedIds(new Set());
+ }, []);
+
+ const exitSelectMode = useCallback(() => {
+ setSelectMode(false);
+ setSelectedIds(new Set());
+ }, []);
+
+ const toggleSelect = useCallback((id) => {
+ setSelectedIds(prev => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const toggleSelectAll = useCallback((checked) => {
+ if (checked) {
+ setSelectedIds(new Set(notes.map(n => n.id)));
+ } else {
+ setSelectedIds(new Set());
+ }
+ }, [notes]);
+
+ const bulkDelete = useCallback(async () => {
+ if (selectedIds.size === 0) return;
+ const count = selectedIds.size;
+ if (!await confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
+ try {
+ const resp = await api().bulkDelete([...selectedIds]);
+ toast(`Deleted ${resp.deleted || count} note${count !== 1 ? 's' : ''}`, 'success');
+ exitSelectMode();
+ graphDirtyRef.current = true;
+ refreshList();
+ _loadFolders();
+ } catch (e) { toast(e.message, 'error'); }
+ }, [selectedIds, exitSelectMode, refreshList, _loadFolders]);
+
+ // ── Open note ───────────────────────────
+ const _openNoteById = useCallback(async (id) => {
+ try {
+ const note = await api().get(id);
+ setCurrentNote(note);
+ setEditingId(id);
+ setEditorMode('read');
+ _setView('reader');
+ if (onNoteChange) onNoteChange(note);
+ // Load backlinks
+ try {
+ const bl = await api().backlinks(id);
+ setBacklinks(bl.data || bl || []);
+ } catch { setBacklinks([]); }
+ } catch (e) {
+ toast('Failed to load note: ' + e.message, 'error');
+ }
+ }, [onNoteChange]);
+
+ const openNote = useCallback((id) => _openNoteById(id), [_openNoteById]);
+
+ const newNote = useCallback(() => {
+ setCurrentNote(null);
+ setEditingId(null);
+ setEditorMode('edit');
+ setBacklinks([]);
+ _setView('editor');
+ if (onNoteChange) onNoteChange(null);
+ }, [onNoteChange]);
+
+ // ── Save note ───────────────────────────
+ const saveNote = useCallback(async (fields) => {
+ const { title, content, folder_path, tags } = fields;
+ if (!title) { toast('Title is required', 'warning'); return false; }
+ if (!content) { toast('Content is required', 'warning'); return false; }
+
+ try {
+ if (editingId) {
+ const updated = await api().update(editingId, {
+ title, content, folder_path, tags, mode: 'replace',
+ });
+ setCurrentNote(updated);
+ toast('Note updated', 'success');
+ setEditorMode('read');
+ _setView('reader');
+ // Reload backlinks
+ try {
+ const bl = await api().backlinks(editingId);
+ setBacklinks(bl.data || bl || []);
+ } catch {}
+ } else {
+ const created = await api().create({ title, content, folder_path, tags });
+ setCurrentNote(created);
+ setEditingId(created.id);
+ toast('Note created', 'success');
+ setEditorMode('read');
+ _setView('reader');
+ }
+ graphDirtyRef.current = true;
+ return true;
+ } catch (e) {
+ toast(e.message, 'error');
+ return false;
+ }
+ }, [editingId]);
+
+ // ── Delete note ─────────────────────────
+ const deleteNote = useCallback(async () => {
+ if (!editingId) return;
+ if (!await confirm('Delete this note?')) return;
+ try {
+ await api().del(editingId);
+ toast('Note deleted', 'success');
+ graphDirtyRef.current = true;
+ setView('list');
+ refreshList();
+ _loadFolders();
+ } catch (e) { toast(e.message, 'error'); }
+ }, [editingId, setView, refreshList, _loadFolders]);
+
+ // ── Copy note ───────────────────────────
+ const copyNote = useCallback(() => {
+ if (!currentNote) return;
+ const text = `# ${currentNote.title || ''}\n\n${currentNote.content || ''}`;
+ navigator.clipboard.writeText(text)
+ .then(() => toast('Copied to clipboard', 'success'))
+ .catch(() => toast('Copy failed', 'error'));
+ }, [currentNote]);
+
+ // ── Wikilink navigation ─────────────────
+ const navigateToLink = useCallback(async (title) => {
+ try {
+ const resp = await api().searchTitles(title, 1);
+ const match = (resp.data || resp || []).find(
+ n => n.title.toLowerCase() === title.toLowerCase()
+ );
+ if (match) {
+ await _openNoteById(match.id);
+ } else {
+ if (await confirm(`Note "${title}" not found. Create it?`)) {
+ const created = await api().create({
+ title,
+ content: `# ${title}\n\n`,
+ folder_path: '/',
+ tags: [],
+ });
+ await _openNoteById(created.id);
+ graphDirtyRef.current = true;
+ }
+ }
+ } catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
+ }, [_openNoteById]);
+
+ // ── Daily Notes ─────────────────────────
+ const _isDailyNote = useCallback((note) => {
+ return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
+ }, []);
+
+ const isDailyNote = _isDailyNote(currentNote);
+
+ const openDailyNote = useCallback(async () => {
+ const today = new Date().toISOString().slice(0, 10);
+ const title = `Daily \u2014 ${today}`;
+ try {
+ const resp = await api().searchTitles(title, 1);
+ const existing = (resp.data || resp || []).find(
+ n => n.title.toLowerCase() === title.toLowerCase()
+ );
+ if (existing) {
+ await _openNoteById(existing.id);
+ } else {
+ const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
+ const created = await api().create({
+ title, content, folder_path: '/daily/', tags: ['daily'],
+ });
+ await _openNoteById(created.id);
+ graphDirtyRef.current = true;
+ }
+ } catch (e) { toast('Failed to open daily note: ' + e.message, 'error'); }
+ }, [_openNoteById]);
+
+ const navigateDaily = useCallback(async (offset) => {
+ if (!currentNote || !_isDailyNote(currentNote)) return;
+ const dateStr = currentNote.title.replace('Daily \u2014 ', '');
+ const d = new Date(dateStr + 'T12:00:00');
+ d.setDate(d.getDate() + offset);
+ const newDate = d.toISOString().slice(0, 10);
+ const title = `Daily \u2014 ${newDate}`;
+ try {
+ const resp = await api().searchTitles(title, 1);
+ const existing = (resp.data || resp || []).find(
+ n => n.title.toLowerCase() === title.toLowerCase()
+ );
+ if (existing) {
+ await _openNoteById(existing.id);
+ } else {
+ const content = `# ${newDate}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
+ const created = await api().create({
+ title, content, folder_path: '/daily/', tags: ['daily'],
+ });
+ await _openNoteById(created.id);
+ graphDirtyRef.current = true;
+ }
+ } catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
+ }, [currentNote, _isDailyNote, _openNoteById]);
+
+ // ── Graph ───────────────────────────────
+ const loadGraph = useCallback(async () => {
+ try {
+ const data = await api().graph();
+ setGraphData(data);
+ graphDirtyRef.current = false;
+ return data;
+ } catch (e) {
+ toast('Failed to load graph: ' + e.message, 'error');
+ return null;
+ }
+ }, []);
+
+ const invalidateGraph = useCallback(() => {
+ graphDirtyRef.current = true;
+ }, []);
+
+ // ── Pinning ─────────────────────────────
+ const togglePin = useCallback((id) => {
+ setPinnedIds(prev => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ _savePins(next);
+ return next;
+ });
+ }, []);
+
+ // ── Keyboard shortcuts ──────────────────
+ useEffect(() => {
+ const handler = (e) => {
+ // Quick switcher: Ctrl/Cmd + K
+ if (enableQuickSwitcher && (e.ctrlKey || e.metaKey) && e.key === 'k') {
+ e.preventDefault();
+ setQuickSwitcherOpen(prev => !prev);
+ return;
+ }
+
+ // Ctrl/Cmd + N: new note
+ if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
+ // Only prevent when notes pane is focused context
+ if (document.querySelector('.sw-notes-pane')) {
+ e.preventDefault();
+ newNote();
+ return;
+ }
+ }
+
+ // Ctrl/Cmd + D: daily note
+ if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
+ if (document.querySelector('.sw-notes-pane')) {
+ e.preventDefault();
+ openDailyNote();
+ return;
+ }
+ }
+ };
+
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [enableQuickSwitcher, newNote, openDailyNote]);
+
+ return {
+ // View
+ view, setView,
+ // List
+ notes, loading, error,
+ folder, setFolder,
+ searchQuery, setSearchQuery,
+ sort, setSort,
+ hasMore, loadMore,
+ folders, refreshList,
+ tagFilter, setTagFilter,
+ // Selection
+ selectMode, selectedIds,
+ enterSelectMode, exitSelectMode,
+ toggleSelect, toggleSelectAll,
+ bulkDelete,
+ // Editor/Reader
+ currentNote, editingId,
+ editorMode, setEditorMode,
+ openNote, newNote,
+ saveNote, deleteNote, copyNote,
+ backlinks, navigateToLink,
+ // Daily
+ openDailyNote, isDailyNote, navigateDaily,
+ // Graph
+ graphData, loadGraph, invalidateGraph,
+ // Quick Switcher
+ quickSwitcherOpen, setQuickSwitcherOpen,
+ // Pinning
+ pinnedIds, togglePin,
+ // Misc
+ clearError: useCallback(() => setError(null), []),
+ };
+}
diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js
index 0c51982..23425d6 100644
--- a/src/js/sw/sdk/index.js
+++ b/src/js/sw/sdk/index.js
@@ -127,8 +127,19 @@ export async function boot() {
});
};
+ // NotesPane render helper — surfaces call sw.notesPane(container, opts)
+ // Returns Promise.
+ sw.notesPane = function (container, opts = {}) {
+ return import('../components/notes-pane/index.js').then(({ NotesPane }) => {
+ const handleRef = { current: null };
+ const { render } = preact;
+ render(html`<${NotesPane} handleRef=${handleRef} ...${opts} />`, container);
+ return handleRef.current;
+ });
+ };
+
// Marker for idempotency
- sw._sdk = '0.37.8';
+ sw._sdk = '0.37.9';
// 8. Expose globally
window.sw = sw;
@@ -150,7 +161,7 @@ export async function boot() {
// 10. Signal ready
events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
- console.log('[sw] SDK v0.37.8 ready');
+ console.log('[sw] SDK v0.37.9 ready');
return sw;
}
diff --git a/src/js/switchboard-sdk.js b/src/js/switchboard-sdk.js
index 3a9bce8..a00d2ea 100644
--- a/src/js/switchboard-sdk.js
+++ b/src/js/switchboard-sdk.js
@@ -479,11 +479,12 @@ const Switchboard = {
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
*/
sw.notes = function (container, opts) {
- if (typeof NotePanel === 'undefined') {
- console.error('[Switchboard] NotePanel not available');
- return null;
+ // v0.37.9: Prefer new Preact NotesPane via sw.notesPane() when SDK is loaded
+ if (window.sw?.notesPane) {
+ return window.sw.notesPane(container, opts || {});
}
- return NotePanel.mount(container, opts || {});
+ console.warn('[Switchboard] sw.notes() — use sw.notesPane() instead (v0.37.9+)');
+ return null;
};
/**
diff --git a/src/js/ui-core.js b/src/js/ui-core.js
index bb67ec3..b071c93 100644
--- a/src/js/ui-core.js
+++ b/src/js/ui-core.js
@@ -1271,7 +1271,7 @@ const UI = {
const link = document.createElement('button');
link.className = 'tool-note-link';
link.textContent = '📝 View note';
- link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
+ link.onclick = () => { /* v0.37.9: notes navigation moved to Preact NotesPane */ };
el.appendChild(link);
}
} catch (e) { /* not JSON or no useful summary */ }
diff --git a/src/js/ui-format.js b/src/js/ui-format.js
index b1f3d72..6321210 100644
--- a/src/js/ui-format.js
+++ b/src/js/ui-format.js
@@ -604,8 +604,10 @@ function _copyCodeBlock(codeId) {
if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
}
function _openNoteFromTool(noteId) {
- openNotes();
- setTimeout(() => openNoteEditor(noteId), 300);
+ // v0.37.9: old notes.js removed — navigate to notes surface instead
+ if (window.sw?.notesPane) {
+ console.log('[ui-format] Note tool link — navigate to /notes');
+ }
}
// ── Exports ─────────────────────────────────