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 ` -
-
- -
-
-
- ${esc(note.title)} - ${time} -
- ${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 => ` - - `).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 `
-
- ↗ ${esc(displayText)} -
-
Loading\u2026
-
`; - } - 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 = ` - - `; - 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 = - '' + - '
' + - '
' + - '' + - '' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '' + - '' + - '' + - ''; - 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 `
` + + `
` + + `` + + `\u2197 ${_escapeHtml(displayText)}
` + + `
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` +
+ + + ${editingId && html` + `} + +
+ +
+ setTitle(e.target.value)} /> + +
+ setFolderPath(e.target.value)} + list="sw-notes-folders" /> + + ${folders.map(f => html` + setTags(e.target.value)} /> +
+ +
+ ${!window.CM?.noteEditor && html` +