Changeset 0.37.9 (#221)
This commit is contained in:
91
CHANGELOG.md
91
CHANGELOG.md
@@ -1,5 +1,96 @@
|
|||||||
# Changelog
|
# 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
|
## [0.37.8] — 2026-03-21
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -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.6 | Admin | Largest section count, no real-time |
|
||||||
| v0.37.7 | Team Admin | Inherits from Admin patterns |
|
| 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.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.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.12 | Extension surface container | `.pkg` mount point |
|
||||||
| v0.37.13 | Workflow surfaces | Form renderer, review views |
|
| 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<link rel="stylesheet" href="{{.BasePath}}/css/pane-container.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/pane-container.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-pane.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-pane.css?v={{.Version}}">
|
||||||
|
<link rel="stylesheet" href="{{.BasePath}}/css/sw-notes-pane.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
|
||||||
@@ -112,8 +113,7 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat-pane.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat-pane.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-panel.js?v={{.Version}}"></script>
|
{{/* note-panel.js + note-graph.js removed in v0.37.9 — see sw/components/notes-pane/ */}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-graph.js?v={{.Version}}"></script>
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
|
||||||
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
|
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ window.addEventListener('unhandledrejection', function(e) {
|
|||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script>
|
||||||
{{/* ui-settings.js and ui-admin.js removed in v0.37.7 — team admin is now at /team-admin */}}
|
{{/* ui-settings.js and ui-admin.js removed in v0.37.7 — team admin is now at /team-admin */}}
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notes.js?v={{$.Version}}"></script>
|
{{/* notes.js removed in v0.37.9 — see sw/components/notes-pane/ */}}
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tools-toggle.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tools-toggle.js?v={{$.Version}}"></script>
|
||||||
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
|
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{/*
|
{{/*
|
||||||
Notes surface (Phase 2b).
|
Notes surface (v0.37.9).
|
||||||
Server renders the layout shell. Existing notes.js + note-graph.js
|
Server renders the layout shell. SDK boots Preact NotesPane kit
|
||||||
handle CRUD and rendering inside the containers.
|
into #notesMainMount via sw.notesPane().
|
||||||
*/}}
|
*/}}
|
||||||
|
|
||||||
{{define "surface-notes"}}
|
{{define "surface-notes"}}
|
||||||
@@ -16,40 +16,32 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "css-notes"}}
|
{{define "css-notes"}}
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/persona-kb.css?v={{.Version}}">
|
{{/* v0.37.9: Notes surface CSS — sw-notes-pane.css loaded in base.html */}}
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/memory.css?v={{.Version}}">
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/notifications.css?v={{.Version}}">
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/channel-models.css?v={{.Version}}">
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/branding/custom.css" onerror="this.remove()">
|
<link rel="stylesheet" href="{{.BasePath}}/branding/custom.css" onerror="this.remove()">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts-notes"}}
|
{{define "scripts-notes"}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat.js?v={{.Version}}"></script>
|
{{/* v0.37.9: Notes surface — Preact vendor globals + SDK boot + NotesPane */}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/files.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}">
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
|
// Vendor: Preact + htm → globals
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/tokens.js?v={{.Version}}"></script>
|
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/extensions.js?v={{.Version}}"></script>
|
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notes.js?v={{.Version}}"></script>
|
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
const html = htm.bind(h);
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
|
window.preact = window.preact || { h, render };
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
window.hooks = window.hooks || hooksModule;
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
window.html = window.html || html;
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/panels.js?v={{.Version}}"></script>
|
|
||||||
{{/* debug.js now loaded in base.html for all surfaces */}}
|
// Boot SDK
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>
|
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
|
const sw = await boot();
|
||||||
<script nonce="{{.CSPNonce}}">
|
|
||||||
// Notes surface boot: open notes panel in standalone mode
|
// Mount NotesPane
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (window.__SURFACE__ === 'notes' && typeof openNotes === 'function') {
|
|
||||||
// Mount notes list into the main container
|
|
||||||
const mount = document.getElementById('notesMainMount');
|
const mount = document.getElementById('notesMainMount');
|
||||||
if (mount) {
|
if (mount) sw.notesPane(mount, { standalone: true });
|
||||||
// Notes panel will build into the mount point
|
|
||||||
mount.dataset.notesStandalone = 'true';
|
// Optional: mount chat assist pane
|
||||||
}
|
const assist = document.getElementById('notesAssistMount');
|
||||||
openNotes();
|
if (assist) sw.chatPane(assist, { standalone: true });
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
const Version = "0.37.8"
|
const Version = "0.37.9"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
flex: 1; overflow-y: auto; min-height: 0;
|
flex: 1; overflow-y: auto; min-height: 0;
|
||||||
}
|
}
|
||||||
.side-panel-page { height: 100%; display: flex; flex-direction: column; }
|
.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 {
|
.side-panel-empty {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
height: 100%; color: var(--text-3); font-size: 13px;
|
height: 100%; color: var(--text-3); font-size: 13px;
|
||||||
@@ -56,25 +56,7 @@
|
|||||||
padding: 2rem; text-align: center;
|
padding: 2rem; text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notes inside secondary pane */
|
/* Notes panel CSS removed in v0.37.9 — see sw-notes-pane.css */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Side panel overlay (mobile tap-to-close, mirrors sidebar-overlay) */
|
/* Side panel overlay (mobile tap-to-close, mirrors sidebar-overlay) */
|
||||||
.side-panel-overlay {
|
.side-panel-overlay {
|
||||||
@@ -230,317 +212,5 @@
|
|||||||
@keyframes dot-pulse { 0%,60%,100% { opacity: 0.4; } 30% { opacity: 1; } }
|
@keyframes dot-pulse { 0%,60%,100% { opacity: 0.4; } 30% { opacity: 1; } }
|
||||||
|
|
||||||
|
|
||||||
/* ── Sidebar Notes Button ───────────────── */
|
/* ── Old notes CSS removed in v0.37.9 — see sw-notes-pane.css ── */
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
897
src/css/sw-notes-pane.css
Normal file
897
src/css/sw-notes-pane.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -322,10 +322,9 @@ function initListeners() {
|
|||||||
|
|
||||||
_initChatListeners(); // from chat.js
|
_initChatListeners(); // from chat.js
|
||||||
// _initSettingsListeners and _initAdminListeners removed in v0.37.7
|
// _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
|
_initFileListeners(); // from files.js
|
||||||
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
|
_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
|
_registerProjectPanel(); // from projects-ui.js — register project detail panel
|
||||||
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
|
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
|
||||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
|
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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 = '<div class="loading">Loading\u2026</div>';
|
|
||||||
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 = `<div class="empty-hint">${isSearch ? 'No results found' : (folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.')}</div>`;
|
|
||||||
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 = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_noteListItem(note, isSearch) {
|
|
||||||
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
|
|
||||||
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
|
|
||||||
const preview = isSearch && note.headline
|
|
||||||
? `<div class="note-headline">${this._highlightHeadline(note.headline)}</div>`
|
|
||||||
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
|
|
||||||
const time = _relativeTime(note.updated_at);
|
|
||||||
const checked = this._selectedNoteIds.has(note.id) ? 'checked' : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="note-item ${this._selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
|
|
||||||
<div class="note-select-col" style="display:${this._notesSelectMode ? '' : 'none'}">
|
|
||||||
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
|
|
||||||
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
|
|
||||||
</div>
|
|
||||||
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
|
|
||||||
<div class="note-item-header">
|
|
||||||
<span class="note-item-title">${esc(note.title)}</span>
|
|
||||||
<span class="note-item-time">${time}</span>
|
|
||||||
</div>
|
|
||||||
${preview}
|
|
||||||
<div class="note-item-meta">${folder}${tags}</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
},
|
|
||||||
|
|
||||||
_highlightHeadline(headline) {
|
|
||||||
const escaped = esc(headline);
|
|
||||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 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 = '<option value="">All folders</option>';
|
|
||||||
folders.forEach(f => {
|
|
||||||
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
|
|
||||||
});
|
|
||||||
} 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 = '<div class="loading">Loading\u2026</div>';
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 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 = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>';
|
|
||||||
}
|
|
||||||
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(`<span class="note-folder">${esc(n.folder_path)}</span>`);
|
|
||||||
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
|
||||||
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(`<span class="note-folder">${esc(folderVal)}</span>`);
|
|
||||||
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
|
||||||
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 => `
|
|
||||||
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
|
|
||||||
<span class="note-backlink-title">${esc(n.title)}</span>
|
|
||||||
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
|
|
||||||
</div>
|
|
||||||
`).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 `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
|
|
||||||
<div class="transclusion-header">
|
|
||||||
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="transclusion-content"><span class="text-muted">Loading\u2026</span></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
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 = '<span class="text-muted">Note not found</span>';
|
|
||||||
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 = '<span class="text-muted">Failed to load</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 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 = `
|
|
||||||
<button class="btn-small" id="dailyPrevBtn" title="Previous day">\u2039 Prev</button>
|
|
||||||
<span class="note-daily-date" id="dailyDateLabel"></span>
|
|
||||||
<button class="btn-small" id="dailyNextBtn" title="Next day">Next \u203a</button>
|
|
||||||
`;
|
|
||||||
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 = `
|
|
||||||
<div class="modal save-to-note-modal">
|
|
||||||
<h3>Save to Note</h3>
|
|
||||||
<div class="save-note-mode-toggle">
|
|
||||||
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
|
|
||||||
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
|
|
||||||
</div>
|
|
||||||
<div id="saveNoteCreateFields">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
|
|
||||||
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="saveNoteAppendFields" style="display:none">
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
|
|
||||||
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
|
|
||||||
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '\u2026' : ''}</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
|
|
||||||
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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 =>
|
|
||||||
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
|
|
||||||
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
|
|
||||||
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 = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
|
|
||||||
}, 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 =
|
|
||||||
'<!-- List view -->' +
|
|
||||||
'<div id="notesListView">' +
|
|
||||||
'<div class="notes-toolbar">' +
|
|
||||||
'<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>' +
|
|
||||||
'<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>' +
|
|
||||||
'<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>' +
|
|
||||||
'<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="notes-search-row">' +
|
|
||||||
'<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="notes-filter-row">' +
|
|
||||||
'<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>' +
|
|
||||||
'<select id="notesSortSelect" class="notes-filter-select">' +
|
|
||||||
'<option value="updated_desc">Last updated</option>' +
|
|
||||||
'<option value="created_desc">Created</option>' +
|
|
||||||
'<option value="alpha">Alphabetical</option>' +
|
|
||||||
'</select>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="notesList" class="notes-list"></div>' +
|
|
||||||
'<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">' +
|
|
||||||
'<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>' +
|
|
||||||
'<span id="notesSelectedCount">0</span> selected' +
|
|
||||||
'<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>' +
|
|
||||||
'<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<!-- Editor view -->' +
|
|
||||||
'<div id="notesEditorView" style="display:none;">' +
|
|
||||||
'<div class="notes-editor-header">' +
|
|
||||||
'<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
|
|
||||||
'<div style="flex:1"></div>' +
|
|
||||||
'<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>' +
|
|
||||||
'<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>' +
|
|
||||||
'<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>' +
|
|
||||||
'<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>' +
|
|
||||||
'<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="noteEditMode" class="notes-editor">' +
|
|
||||||
'<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">' +
|
|
||||||
'<div class="notes-meta-row">' +
|
|
||||||
'<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">' +
|
|
||||||
'<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="noteEditorContentContainer" class="notes-content-container">' +
|
|
||||||
'<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="noteReadMode" style="display:none;">' +
|
|
||||||
'<div class="notes-read-view">' +
|
|
||||||
'<h3 id="noteReadTitle" class="note-read-title"></h3>' +
|
|
||||||
'<div id="noteReadMeta" class="note-read-meta"></div>' +
|
|
||||||
'<div id="noteReadContent" class="note-read-content msg-content"></div>' +
|
|
||||||
'<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="noteBacklinks" class="note-backlinks" style="display:none;">' +
|
|
||||||
'<div class="note-backlinks-header" data-action="toggleBacklinks">' +
|
|
||||||
'Backlinks <span id="noteBacklinksCount" class="badge">0</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="noteBacklinksList" class="note-backlinks-list"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<!-- Graph view -->' +
|
|
||||||
'<div id="notesGraphView" class="notes-graph-view" style="display:none;">' +
|
|
||||||
'<div class="notes-graph-toolbar">' +
|
|
||||||
'<button id="notesGraphBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
|
|
||||||
'<span class="notes-graph-stats">' +
|
|
||||||
'<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links' +
|
|
||||||
'</span>' +
|
|
||||||
'<div style="flex:1"></div>' +
|
|
||||||
'<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>' +
|
|
||||||
'<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>' +
|
|
||||||
'</div>';
|
|
||||||
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);
|
|
||||||
155
src/js/notes.js
155
src/js/notes.js
@@ -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);
|
|
||||||
194
src/js/sw/components/notes-pane/index.js
Normal file
194
src/js/sw/components/notes-pane/index.js
Normal file
@@ -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`<div class="sw-notes-pane__loading">Loading graph\u2026</div>`;
|
||||||
|
}
|
||||||
|
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`
|
||||||
|
<div class=${'sw-notes-pane' + (className ? ' ' + className : '')}>
|
||||||
|
${renderView()}
|
||||||
|
|
||||||
|
<${QuickSwitcher}
|
||||||
|
open=${notes.quickSwitcherOpen}
|
||||||
|
onClose=${() => notes.setQuickSwitcherOpen(false)}
|
||||||
|
onSelect=${(id) => notes.openNote(id)}
|
||||||
|
onCreateNew=${onQuickSwitcherCreate} />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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';
|
||||||
157
src/js/sw/components/notes-pane/markdown.js
Normal file
157
src/js/sw/components/notes-pane/markdown.js
Normal file
@@ -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, '<br>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rendered = _escapeHtml(content).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `<div class="sw-notes-pane__transclusion" data-title="${_escapeAttr(titleTrimmed)}">` +
|
||||||
|
`<div class="sw-notes-pane__transclusion-header">` +
|
||||||
|
`<span class="sw-notes-pane__wikilink sw-notes-pane__wikilink--transclusion" ` +
|
||||||
|
`data-link-title="${_escapeAttr(titleTrimmed)}">` +
|
||||||
|
`\u2197 ${_escapeHtml(displayText)}</span></div>` +
|
||||||
|
`<div class="sw-notes-pane__transclusion-content">Loading\u2026</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="sw-notes-pane__wikilink" ` +
|
||||||
|
`data-link-title="${_escapeAttr(titleTrimmed)}">${_escapeHtml(displayText)}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = '<span style="color:var(--text-3)">Note not found</span>';
|
||||||
|
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 = '<span style="color:var(--text-3)">Failed to load</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
172
src/js/sw/components/notes-pane/note-editor.js
Normal file
172
src/js/sw/components/notes-pane/note-editor.js
Normal file
@@ -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<boolean>,
|
||||||
|
* 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`
|
||||||
|
<div class="sw-notes-pane__editor-header">
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onCancel}>← Back</button>
|
||||||
|
<span class="sw-notes-pane__editor-header-spacer" />
|
||||||
|
${editingId && html`
|
||||||
|
<button class="sw-notes-pane__toolbar-btn"
|
||||||
|
style="color:var(--danger);border-color:var(--danger)"
|
||||||
|
onClick=${onDelete}>Delete</button>`}
|
||||||
|
<button class="sw-notes-pane__toolbar-btn"
|
||||||
|
style="color:var(--success);border-color:var(--success)"
|
||||||
|
onClick=${handleSave} disabled=${saving}>
|
||||||
|
${saving ? 'Saving\u2026' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__editor">
|
||||||
|
<input class="sw-notes-pane__editor-title" type="text"
|
||||||
|
ref=${titleRef} placeholder="Note title"
|
||||||
|
value=${title} onInput=${e => setTitle(e.target.value)} />
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__editor-meta">
|
||||||
|
<input type="text" placeholder="Folder (e.g. /work/project)"
|
||||||
|
value=${folderPath} onInput=${e => setFolderPath(e.target.value)}
|
||||||
|
list="sw-notes-folders" />
|
||||||
|
<datalist id="sw-notes-folders">
|
||||||
|
${folders.map(f => html`<option key=${f.path} value=${f.path} />`)}
|
||||||
|
</datalist>
|
||||||
|
<input type="text" placeholder="Tags (comma-separated)"
|
||||||
|
value=${tags} onInput=${e => setTags(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__editor-content" ref=${containerRef}>
|
||||||
|
${!window.CM?.noteEditor && html`
|
||||||
|
<textarea class="sw-notes-pane__editor-textarea"
|
||||||
|
ref=${contentRef}
|
||||||
|
placeholder="Write your note in markdown\u2026"
|
||||||
|
value=${content}
|
||||||
|
onInput=${e => setContent(e.target.value)} />`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__editor-footer">
|
||||||
|
<span>${wordCount} words · ${charCount} chars</span>
|
||||||
|
<span style="color:var(--text-3)">Ctrl+S to save · Esc to cancel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
561
src/js/sw/components/notes-pane/note-graph.js
Normal file
561
src/js/sw/components/notes-pane/note-graph.js
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
// ==========================================
|
||||||
|
// NotesPane Kit — NoteGraph (Canvas)
|
||||||
|
// ==========================================
|
||||||
|
// Force-directed graph visualization of note connections.
|
||||||
|
// Self-contained Preact component with physics sim,
|
||||||
|
// pan/zoom/drag, hover highlighting, folder colors.
|
||||||
|
// Lazy-loadable via dynamic import().
|
||||||
|
// Independently importable.
|
||||||
|
|
||||||
|
const { useRef, useEffect, useState, useCallback } = window.hooks;
|
||||||
|
const html = window.html;
|
||||||
|
|
||||||
|
// ── Simulation Parameters ───────────────────
|
||||||
|
const SIM = {
|
||||||
|
repulsion: 800,
|
||||||
|
attraction: 0.006,
|
||||||
|
edgeLength: 130,
|
||||||
|
damping: 0.88,
|
||||||
|
centerGravity: 0.012,
|
||||||
|
maxVelocity: 8,
|
||||||
|
ticksPerFrame: 3,
|
||||||
|
coolThreshold: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_RADIUS = 6;
|
||||||
|
|
||||||
|
const FOLDER_COLORS = [
|
||||||
|
'#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6',
|
||||||
|
'#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316',
|
||||||
|
];
|
||||||
|
|
||||||
|
function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||||||
|
|
||||||
|
function _withAlpha(hex, alpha) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* data: { nodes: Array, edges: Array, unresolved?: Array } | null,
|
||||||
|
* onNodeClick: (id: string) => void,
|
||||||
|
* onGhostClick: (title: string) => void,
|
||||||
|
* onBack: () => void,
|
||||||
|
* handleRef?: { current: any },
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function NoteGraph(props) {
|
||||||
|
const { data, onNodeClick, onGhostClick, onBack, handleRef } = props;
|
||||||
|
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const wrapRef = useRef(null);
|
||||||
|
const stateRef = useRef(null);
|
||||||
|
const [showOrphans, setShowOrphans] = useState(true);
|
||||||
|
const [focusNodeId, setFocusNodeId] = useState(null);
|
||||||
|
const [stats, setStats] = useState({ nodes: 0, edges: 0 });
|
||||||
|
|
||||||
|
// ── Prepare graph data with physics state ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !canvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const rect = wrapRef.current.getBoundingClientRect();
|
||||||
|
canvas.width = Math.round(rect.width);
|
||||||
|
canvas.height = Math.round(rect.height);
|
||||||
|
|
||||||
|
const cx = canvas.width / 2;
|
||||||
|
const cy = canvas.height / 2;
|
||||||
|
|
||||||
|
// Add physics state to nodes
|
||||||
|
const nodeMap = {};
|
||||||
|
const folderColorMap = {};
|
||||||
|
|
||||||
|
function folderColor(path) {
|
||||||
|
if (!path || path === '/') return '#6b7280';
|
||||||
|
if (!folderColorMap[path]) {
|
||||||
|
const idx = Object.keys(folderColorMap).length % FOLDER_COLORS.length;
|
||||||
|
folderColorMap[path] = FOLDER_COLORS[idx];
|
||||||
|
}
|
||||||
|
return folderColorMap[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = data.nodes.map(n => {
|
||||||
|
const node = {
|
||||||
|
...n,
|
||||||
|
x: cx + (Math.random() - 0.5) * canvas.width * 0.5,
|
||||||
|
y: cy + (Math.random() - 0.5) * canvas.height * 0.5,
|
||||||
|
vx: 0, vy: 0, ax: 0, ay: 0,
|
||||||
|
pinned: false, isGhost: false,
|
||||||
|
};
|
||||||
|
nodeMap[n.id] = node;
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve edge references
|
||||||
|
const edges = data.edges.map(e => ({
|
||||||
|
...e,
|
||||||
|
sourceNode: nodeMap[e.source],
|
||||||
|
targetNode: nodeMap[e.target],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ghost nodes for unresolved links
|
||||||
|
const ghostNodes = [];
|
||||||
|
if (data.unresolved) {
|
||||||
|
const ghostMap = {};
|
||||||
|
for (const d of data.unresolved) {
|
||||||
|
const key = d.title.toLowerCase();
|
||||||
|
if (!ghostMap[key]) {
|
||||||
|
const ghost = {
|
||||||
|
id: '__ghost_' + key, title: d.title,
|
||||||
|
folder_path: '', tags: [], link_count: 0,
|
||||||
|
x: cx + (Math.random() - 0.5) * canvas.width * 0.4,
|
||||||
|
y: cy + (Math.random() - 0.5) * canvas.height * 0.4,
|
||||||
|
vx: 0, vy: 0, ax: 0, ay: 0,
|
||||||
|
pinned: false, isGhost: true,
|
||||||
|
};
|
||||||
|
ghostMap[key] = ghost;
|
||||||
|
ghostNodes.push(ghost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStats({ nodes: nodes.length, edges: edges.length });
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
nodes, edges, ghostNodes, nodeMap, folderColorMap, folderColor,
|
||||||
|
panX: 0, panY: 0, zoom: 1,
|
||||||
|
animId: null, canvas, ctx: canvas.getContext('2d'),
|
||||||
|
hoveredNode: null, dragNode: null,
|
||||||
|
isDragging: false, lastMouse: null,
|
||||||
|
showOrphans: true,
|
||||||
|
focusNodeId: null,
|
||||||
|
onNodeClick, onGhostClick,
|
||||||
|
onFocusNode: (id) => setFocusNodeId(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
// Start simulation
|
||||||
|
_startSim(state, onNodeClick);
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
const cleanup = _attachListeners(state, onNodeClick);
|
||||||
|
|
||||||
|
// Resize observer
|
||||||
|
let roFrame = null;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (roFrame) return;
|
||||||
|
roFrame = requestAnimationFrame(() => {
|
||||||
|
roFrame = null;
|
||||||
|
const r = wrapRef.current.getBoundingClientRect();
|
||||||
|
const w = Math.round(r.width);
|
||||||
|
const h = Math.round(r.height);
|
||||||
|
if (canvas.width !== w || canvas.height !== h) {
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
_render(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ro.observe(wrapRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (state.animId) cancelAnimationFrame(state.animId);
|
||||||
|
state.animId = null;
|
||||||
|
cleanup();
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [data, onNodeClick]);
|
||||||
|
|
||||||
|
// ── Sync showOrphans + focusNodeId into state ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (stateRef.current) {
|
||||||
|
stateRef.current.showOrphans = showOrphans;
|
||||||
|
stateRef.current.focusNodeId = focusNodeId;
|
||||||
|
_render(stateRef.current);
|
||||||
|
}
|
||||||
|
}, [showOrphans, focusNodeId]);
|
||||||
|
|
||||||
|
// ── Expose imperative handle ────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (handleRef) {
|
||||||
|
handleRef.current = {
|
||||||
|
resetZoom() {
|
||||||
|
const s = stateRef.current;
|
||||||
|
if (!s) return;
|
||||||
|
s.panX = 0; s.panY = 0; s.zoom = 1;
|
||||||
|
_render(s);
|
||||||
|
},
|
||||||
|
toggleOrphans() {
|
||||||
|
setShowOrphans(p => !p);
|
||||||
|
},
|
||||||
|
focusNode(id) {
|
||||||
|
setFocusNodeId(id);
|
||||||
|
},
|
||||||
|
clearFocus() {
|
||||||
|
setFocusNodeId(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Focus mode label ───────────────────
|
||||||
|
const focusLabel = focusNodeId && stateRef.current
|
||||||
|
? (stateRef.current.nodes.find(n => n.id === focusNodeId)?.title || 'Unknown')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="sw-notes-pane__graph">
|
||||||
|
<div class="sw-notes-pane__graph-toolbar">
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onBack}>← Back</button>
|
||||||
|
<span class="sw-notes-pane__graph-stats">
|
||||||
|
${stats.nodes} notes · ${stats.edges} links
|
||||||
|
</span>
|
||||||
|
<span class="sw-notes-pane__toolbar-spacer" />
|
||||||
|
${focusNodeId && html`
|
||||||
|
<span style="font-size:11px;color:var(--accent)">Focus: ${focusLabel}</span>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${() => setFocusNodeId(null)}>
|
||||||
|
Show All
|
||||||
|
</button>`}
|
||||||
|
<button class="sw-notes-pane__toolbar-btn"
|
||||||
|
onClick=${() => setShowOrphans(p => !p)}>
|
||||||
|
Orphans ${showOrphans ? 'On' : 'Off'}
|
||||||
|
</button>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn"
|
||||||
|
onClick=${() => handleRef?.current?.resetZoom()}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sw-notes-pane__graph-canvas-wrap" ref=${wrapRef}>
|
||||||
|
<canvas ref=${canvasRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simulation ──────────────────────────────
|
||||||
|
|
||||||
|
function _allNodes(state) {
|
||||||
|
const nodes = [...state.nodes];
|
||||||
|
if (state.showOrphans !== false && state.ghostNodes) {
|
||||||
|
nodes.push(...state.ghostNodes);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startSim(state, onNodeClick) {
|
||||||
|
function frame() {
|
||||||
|
for (let t = 0; t < SIM.ticksPerFrame; t++) _tick(state);
|
||||||
|
_render(state);
|
||||||
|
|
||||||
|
let totalKE = 0;
|
||||||
|
for (const n of _allNodes(state)) {
|
||||||
|
totalKE += n.vx * n.vx + n.vy * n.vy;
|
||||||
|
}
|
||||||
|
if (totalKE > SIM.coolThreshold || state.dragNode) {
|
||||||
|
state.animId = requestAnimationFrame(frame);
|
||||||
|
} else {
|
||||||
|
state.animId = null;
|
||||||
|
_render(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.animId) cancelAnimationFrame(state.animId);
|
||||||
|
state.animId = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tick(state) {
|
||||||
|
const nodes = _allNodes(state);
|
||||||
|
const edges = state.edges;
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
|
for (const n of nodes) { n.ax = 0; n.ay = 0; }
|
||||||
|
|
||||||
|
// Repulsion
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
const dx = nodes[j].x - nodes[i].x;
|
||||||
|
const dy = nodes[j].y - nodes[i].y;
|
||||||
|
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||||
|
const f = SIM.repulsion / (dist * dist);
|
||||||
|
const fx = (dx / dist) * f;
|
||||||
|
const fy = (dy / dist) * f;
|
||||||
|
nodes[i].ax -= fx; nodes[i].ay -= fy;
|
||||||
|
nodes[j].ax += fx; nodes[j].ay += fy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attraction (edges)
|
||||||
|
for (const e of edges) {
|
||||||
|
const s = e.sourceNode, t = e.targetNode;
|
||||||
|
if (!s || !t) continue;
|
||||||
|
const dx = t.x - s.x, dy = t.y - s.y;
|
||||||
|
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||||
|
const f = SIM.attraction * (dist - SIM.edgeLength);
|
||||||
|
const fx = (dx / dist) * f;
|
||||||
|
const fy = (dy / dist) * f;
|
||||||
|
s.ax += fx; s.ay += fy;
|
||||||
|
t.ax -= fx; t.ay -= fy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center gravity + integration
|
||||||
|
const cx = state.canvas.width / 2, cy = state.canvas.height / 2;
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.pinned) continue;
|
||||||
|
n.ax += (cx - n.x) * SIM.centerGravity;
|
||||||
|
n.ay += (cy - n.y) * SIM.centerGravity;
|
||||||
|
n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
|
||||||
|
n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
|
||||||
|
n.x += n.vx;
|
||||||
|
n.y += n.vy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ───────────────────────────────
|
||||||
|
|
||||||
|
function _render(state) {
|
||||||
|
const { ctx, canvas, panX, panY, zoom, hoveredNode, focusNodeId } = state;
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const nodes = _allNodes(state);
|
||||||
|
const edges = state.edges;
|
||||||
|
|
||||||
|
// Build focus neighbor set for focus mode
|
||||||
|
const focusNeighborIds = new Set();
|
||||||
|
if (focusNodeId) {
|
||||||
|
focusNeighborIds.add(focusNodeId);
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.source === focusNodeId) focusNeighborIds.add(e.target);
|
||||||
|
if (e.target === focusNodeId) focusNeighborIds.add(e.source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(panX, panY);
|
||||||
|
ctx.scale(zoom, zoom);
|
||||||
|
|
||||||
|
// Edges
|
||||||
|
for (const e of edges) {
|
||||||
|
const s = e.sourceNode, t = e.targetNode;
|
||||||
|
if (!s || !t) continue;
|
||||||
|
|
||||||
|
// Focus mode: hide edges not touching the focus neighborhood
|
||||||
|
if (focusNodeId && !focusNeighborIds.has(e.source) && !focusNeighborIds.has(e.target)) continue;
|
||||||
|
const isFocusDimmed = focusNodeId && !(focusNeighborIds.has(e.source) && focusNeighborIds.has(e.target));
|
||||||
|
|
||||||
|
const isHighlighted = hoveredNode && (s.id === hoveredNode.id || t.id === hoveredNode.id);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(s.x, s.y);
|
||||||
|
ctx.lineTo(t.x, t.y);
|
||||||
|
|
||||||
|
if (e.is_transclusion) {
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.strokeStyle = isHighlighted ? 'rgba(99,102,241,0.8)'
|
||||||
|
: isFocusDimmed ? 'rgba(99,102,241,0.08)' : 'rgba(99,102,241,0.3)';
|
||||||
|
} else {
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.strokeStyle = isHighlighted ? 'rgba(160,160,160,0.8)'
|
||||||
|
: isFocusDimmed ? 'rgba(120,120,120,0.06)' : 'rgba(120,120,120,0.25)';
|
||||||
|
}
|
||||||
|
ctx.lineWidth = isHighlighted ? 2 : 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.isGhost && !state.showOrphans) continue;
|
||||||
|
|
||||||
|
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
|
||||||
|
const isHovered = hoveredNode && n.id === hoveredNode.id;
|
||||||
|
const isNeighbor = hoveredNode && _isNeighbor(state, hoveredNode, n);
|
||||||
|
const isFocusVisible = !focusNodeId || focusNeighborIds.has(n.id);
|
||||||
|
const isDimmed = (hoveredNode && !isHovered && !isNeighbor) || !isFocusVisible;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||||
|
|
||||||
|
if (n.isGhost) {
|
||||||
|
ctx.fillStyle = isDimmed ? 'rgba(200,50,50,0.1)' : 'rgba(200,50,50,0.4)';
|
||||||
|
ctx.setLineDash([2, 2]);
|
||||||
|
ctx.strokeStyle = 'rgba(200,50,50,0.5)';
|
||||||
|
} else if (isHovered) {
|
||||||
|
ctx.fillStyle = '#6366f1';
|
||||||
|
} else {
|
||||||
|
const color = state.folderColor(n.folder_path);
|
||||||
|
ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7);
|
||||||
|
ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)';
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fill();
|
||||||
|
if (!n.isGhost) {
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.lineWidth = isHovered ? 2 : 1;
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) {
|
||||||
|
const fontSize = Math.max(10, 11 / zoom);
|
||||||
|
ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`;
|
||||||
|
ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
const label = n.title.length > 30 ? n.title.slice(0, 28) + '\u2026' : n.title;
|
||||||
|
ctx.fillText(label, n.x, n.y + r + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isNeighbor(state, hovered, candidate) {
|
||||||
|
for (const e of state.edges) {
|
||||||
|
if ((e.source === hovered.id && e.target === candidate.id) ||
|
||||||
|
(e.target === hovered.id && e.source === candidate.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interaction ─────────────────────────────
|
||||||
|
|
||||||
|
function _attachListeners(state, onNodeClick) {
|
||||||
|
const canvas = state.canvas;
|
||||||
|
|
||||||
|
function toGraph(e) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left - state.panX) / state.zoom,
|
||||||
|
y: (e.clientY - rect.top - state.panY) / state.zoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTest(gx, gy) {
|
||||||
|
const nodes = _allNodes(state);
|
||||||
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||||
|
const n = nodes[i];
|
||||||
|
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
|
||||||
|
const dx = n.x - gx, dy = n.y - gy;
|
||||||
|
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 5; // px — must move at least this far to count as drag
|
||||||
|
|
||||||
|
const onMouseDown = (e) => {
|
||||||
|
const g = toGraph(e);
|
||||||
|
const node = hitTest(g.x, g.y);
|
||||||
|
if (node) {
|
||||||
|
state.dragNode = node;
|
||||||
|
if (!node.isGhost) node.pinned = true;
|
||||||
|
state.isDragging = false;
|
||||||
|
}
|
||||||
|
state.lastMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
state.mouseDownPos = { x: e.clientX, y: e.clientY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e) => {
|
||||||
|
const g = toGraph(e);
|
||||||
|
if (state.dragNode) {
|
||||||
|
// Only count as drag if moved past threshold
|
||||||
|
if (!state.isDragging && state.mouseDownPos) {
|
||||||
|
const dx = e.clientX - state.mouseDownPos.x;
|
||||||
|
const dy = e.clientY - state.mouseDownPos.y;
|
||||||
|
if (dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD) return;
|
||||||
|
}
|
||||||
|
state.isDragging = true;
|
||||||
|
state.dragNode.x = g.x;
|
||||||
|
state.dragNode.y = g.y;
|
||||||
|
state.dragNode.vx = 0;
|
||||||
|
state.dragNode.vy = 0;
|
||||||
|
if (!state.animId) _startSim(state, onNodeClick);
|
||||||
|
} else if (state.lastMouse && e.buttons === 1) {
|
||||||
|
state.panX += e.clientX - state.lastMouse.x;
|
||||||
|
state.panY += e.clientY - state.lastMouse.y;
|
||||||
|
state.lastMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
_render(state);
|
||||||
|
} else {
|
||||||
|
const node = hitTest(g.x, g.y);
|
||||||
|
if (node !== state.hoveredNode) {
|
||||||
|
state.hoveredNode = node;
|
||||||
|
canvas.style.cursor = node ? 'pointer' : 'default';
|
||||||
|
_render(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = (e) => {
|
||||||
|
if (state.dragNode) {
|
||||||
|
const clicked = state.dragNode;
|
||||||
|
clicked.pinned = false;
|
||||||
|
if (!state.isDragging) {
|
||||||
|
if (clicked.isGhost && state.onGhostClick) {
|
||||||
|
// Ghost node → create note with that title
|
||||||
|
state.onGhostClick(clicked.title);
|
||||||
|
} else if (!clicked.isGhost) {
|
||||||
|
// Single click → focus on this node (show neighbors)
|
||||||
|
if (state.onFocusNode) {
|
||||||
|
state.onFocusNode(clicked.id === state.focusNodeId ? null : clicked.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.dragNode = null;
|
||||||
|
} else if (!state.isDragging) {
|
||||||
|
// Clicked empty space → clear focus
|
||||||
|
if (state.onFocusNode && state.focusNodeId) {
|
||||||
|
state.onFocusNode(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lastMouse = null;
|
||||||
|
state.isDragging = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Double-click → open note
|
||||||
|
const onDblClick = (e) => {
|
||||||
|
const g = toGraph(e);
|
||||||
|
const node = hitTest(g.x, g.y);
|
||||||
|
if (node && !node.isGhost && onNodeClick) {
|
||||||
|
onNodeClick(node.id);
|
||||||
|
} else if (node && node.isGhost && state.onGhostClick) {
|
||||||
|
state.onGhostClick(node.title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
||||||
|
const newZoom = _clamp(state.zoom * factor, 0.15, 4.0);
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
state.panX = mx - (mx - state.panX) * (newZoom / state.zoom);
|
||||||
|
state.panY = my - (my - state.panY) * (newZoom / state.zoom);
|
||||||
|
state.zoom = newZoom;
|
||||||
|
_render(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', onMouseDown);
|
||||||
|
canvas.addEventListener('mousemove', onMouseMove);
|
||||||
|
canvas.addEventListener('mouseup', onMouseUp);
|
||||||
|
canvas.addEventListener('dblclick', onDblClick);
|
||||||
|
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('mousedown', onMouseDown);
|
||||||
|
canvas.removeEventListener('mousemove', onMouseMove);
|
||||||
|
canvas.removeEventListener('mouseup', onMouseUp);
|
||||||
|
canvas.removeEventListener('dblclick', onDblClick);
|
||||||
|
canvas.removeEventListener('wheel', onWheel);
|
||||||
|
};
|
||||||
|
}
|
||||||
103
src/js/sw/components/notes-pane/note-list-item.js
Normal file
103
src/js/sw/components/notes-pane/note-list-item.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// ==========================================
|
||||||
|
// NotesPane Kit — NoteListItem
|
||||||
|
// ==========================================
|
||||||
|
// Single note row in the list view.
|
||||||
|
// Independently importable.
|
||||||
|
|
||||||
|
const html = window.html;
|
||||||
|
|
||||||
|
function _relativeTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
if (s < 60) return 'just now';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return m + 'm ago';
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return h + 'h ago';
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 30) return d + 'd ago';
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.textContent = s;
|
||||||
|
return el.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _highlightHeadline(headline) {
|
||||||
|
const escaped = _esc(headline);
|
||||||
|
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* note: object,
|
||||||
|
* isSearch?: boolean,
|
||||||
|
* onClick: (id: string) => void,
|
||||||
|
* selectMode?: boolean,
|
||||||
|
* selected?: boolean,
|
||||||
|
* onToggleSelect?: (id: string) => void,
|
||||||
|
* pinned?: boolean,
|
||||||
|
* onTogglePin?: (id: string) => void,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function NoteListItem(props) {
|
||||||
|
const { note, isSearch, onClick, selectMode, selected, onToggleSelect, pinned, onTogglePin } = props;
|
||||||
|
|
||||||
|
const preview = isSearch && note.headline
|
||||||
|
? html`<div class="sw-notes-pane__item-preview"
|
||||||
|
dangerouslySetInnerHTML=${{ __html: _highlightHeadline(note.headline) }} />`
|
||||||
|
: html`<div class="sw-notes-pane__item-preview">
|
||||||
|
${(note.preview || note.content || '').slice(0, 120)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const tags = (note.tags || []).map(t =>
|
||||||
|
html`<span class="sw-notes-pane__item-tag" key=${t}>${t}</span>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderBadge = note.folder_path && note.folder_path !== '/'
|
||||||
|
? html`<span class="sw-notes-pane__item-folder">${note.folder_path}</span>`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const onItemClick = (e) => {
|
||||||
|
if (selectMode && onToggleSelect) {
|
||||||
|
onToggleSelect(note.id);
|
||||||
|
} else {
|
||||||
|
onClick(note.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCheckChange = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onToggleSelect) onToggleSelect(note.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPinClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onTogglePin) onTogglePin(note.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class=${'sw-notes-pane__item' + (selected ? ' sw-notes-pane__item--selected' : '')}
|
||||||
|
onClick=${onItemClick}>
|
||||||
|
${selectMode && html`
|
||||||
|
<input type="checkbox" class="sw-notes-pane__item-checkbox"
|
||||||
|
checked=${selected} onChange=${onCheckChange} />`}
|
||||||
|
<div class="sw-notes-pane__item-body">
|
||||||
|
<div class="sw-notes-pane__item-header">
|
||||||
|
<span class="sw-notes-pane__item-title">${note.title}</span>
|
||||||
|
<span class="sw-notes-pane__item-time">${_relativeTime(note.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
${preview}
|
||||||
|
${(folderBadge || tags.length > 0) && html`
|
||||||
|
<div class="sw-notes-pane__item-meta">${folderBadge}${tags}</div>`}
|
||||||
|
</div>
|
||||||
|
${onTogglePin && html`
|
||||||
|
<button class=${'sw-notes-pane__item-pin' + (pinned ? ' sw-notes-pane__item-pin--active' : '')}
|
||||||
|
onClick=${onPinClick} title=${pinned ? 'Unpin' : 'Pin'}>
|
||||||
|
${pinned ? '\u{1F4CC}' : '\u{1F4CC}'}
|
||||||
|
</button>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
163
src/js/sw/components/notes-pane/note-list.js
Normal file
163
src/js/sw/components/notes-pane/note-list.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// ==========================================
|
||||||
|
// NotesPane Kit — NoteList
|
||||||
|
// ==========================================
|
||||||
|
// List view: toolbar, search, filters, tag cloud,
|
||||||
|
// scrollable note list with pinned section,
|
||||||
|
// selection bar, and pagination.
|
||||||
|
// Independently importable.
|
||||||
|
|
||||||
|
import { NoteListItem } from './note-list-item.js';
|
||||||
|
|
||||||
|
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||||
|
const html = window.html;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* notes: Array, loading: boolean, hasMore: boolean, loadMore: () => void,
|
||||||
|
* folders: Array, folder: string, setFolder: (f: string) => void,
|
||||||
|
* sort: string, setSort: (s: string) => void,
|
||||||
|
* searchQuery: string, setSearchQuery: (q: string) => void,
|
||||||
|
* tagFilter: string, setTagFilter: (t: string) => void,
|
||||||
|
* selectMode: boolean, selectedIds: Set, enterSelectMode: () => void,
|
||||||
|
* exitSelectMode: () => void, toggleSelect: (id: string) => void,
|
||||||
|
* toggleSelectAll: (checked: boolean) => void, bulkDelete: () => void,
|
||||||
|
* onNoteClick: (id: string) => void, onNewNote: () => void,
|
||||||
|
* onDailyNote: () => void, onGraphClick: () => void,
|
||||||
|
* pinnedIds: Set, togglePin: (id: string) => void,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function NoteList(props) {
|
||||||
|
const {
|
||||||
|
notes, loading, hasMore, loadMore,
|
||||||
|
folders, folder, setFolder,
|
||||||
|
sort, setSort,
|
||||||
|
searchQuery, setSearchQuery,
|
||||||
|
tagFilter, setTagFilter,
|
||||||
|
selectMode, selectedIds, enterSelectMode, exitSelectMode,
|
||||||
|
toggleSelect, toggleSelectAll, bulkDelete,
|
||||||
|
onNoteClick, onNewNote, onDailyNote, onGraphClick,
|
||||||
|
pinnedIds, togglePin,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const searchTimerRef = useRef(null);
|
||||||
|
const searchInputRef = useRef(null);
|
||||||
|
|
||||||
|
const onSearchInput = useCallback((e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
clearTimeout(searchTimerRef.current);
|
||||||
|
searchTimerRef.current = setTimeout(() => {
|
||||||
|
if (val.trim().length >= 2 || val.trim().length === 0) {
|
||||||
|
setSearchQuery(val.trim());
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, [setSearchQuery]);
|
||||||
|
|
||||||
|
// ── Tag cloud: collect tags from visible notes ──
|
||||||
|
const tagCounts = {};
|
||||||
|
for (const n of notes) {
|
||||||
|
for (const t of (n.tags || [])) {
|
||||||
|
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topTags = Object.entries(tagCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 15);
|
||||||
|
|
||||||
|
// ── Split pinned vs unpinned ──
|
||||||
|
const isSearch = searchQuery && searchQuery.length >= 2;
|
||||||
|
const pinnedNotes = isSearch ? [] : notes.filter(n => pinnedIds.has(n.id));
|
||||||
|
const unpinnedNotes = isSearch ? notes : notes.filter(n => !pinnedIds.has(n.id));
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="sw-notes-pane__toolbar">
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onNewNote} title="New note">+ New</button>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyNote} title="Daily note">Today</button>
|
||||||
|
${onGraphClick && html`
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onGraphClick} title="Graph view">Graph</button>`}
|
||||||
|
<span class="sw-notes-pane__toolbar-spacer" />
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||||
|
title=${selectMode ? 'Cancel selection' : 'Select mode'}>
|
||||||
|
${selectMode ? 'Cancel' : 'Select'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__search-row">
|
||||||
|
<input class="sw-notes-pane__search-input" type="text"
|
||||||
|
placeholder="Search notes\u2026" autocomplete="off"
|
||||||
|
ref=${searchInputRef} onInput=${onSearchInput}
|
||||||
|
value=${searchQuery} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__filter-row">
|
||||||
|
<select class="sw-notes-pane__filter-select"
|
||||||
|
value=${folder} onChange=${e => { setFolder(e.target.value); }}>
|
||||||
|
<option value="">All folders</option>
|
||||||
|
${folders.map(f => html`
|
||||||
|
<option key=${f.path} value=${f.path}>${f.path} (${f.count})</option>`)}
|
||||||
|
</select>
|
||||||
|
<select class="sw-notes-pane__filter-select"
|
||||||
|
value=${sort} onChange=${e => setSort(e.target.value)}>
|
||||||
|
<option value="updated_desc">Last updated</option>
|
||||||
|
<option value="created_desc">Created</option>
|
||||||
|
<option value="alpha">Alphabetical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${topTags.length > 0 && html`
|
||||||
|
<div class="sw-notes-pane__tag-cloud">
|
||||||
|
${topTags.map(([tag, count]) => html`
|
||||||
|
<span key=${tag}
|
||||||
|
class=${'sw-notes-pane__tag-chip' + (tagFilter === tag ? ' sw-notes-pane__tag-chip--active' : '')}
|
||||||
|
onClick=${() => setTagFilter(tagFilter === tag ? '' : tag)}>
|
||||||
|
${tag}
|
||||||
|
</span>`)}
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__list">
|
||||||
|
${loading && notes.length === 0 && html`
|
||||||
|
<div class="sw-notes-pane__loading">Loading\u2026</div>`}
|
||||||
|
|
||||||
|
${!loading && notes.length === 0 && html`
|
||||||
|
<div class="sw-notes-pane__list-empty">
|
||||||
|
${isSearch ? 'No results found' : (folder ? 'No notes in this folder' : 'No notes yet. Create one!')}
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
${pinnedNotes.length > 0 && html`
|
||||||
|
<div class="sw-notes-pane__pinned-divider">Pinned</div>
|
||||||
|
${pinnedNotes.map(n => html`
|
||||||
|
<${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
|
||||||
|
onClick=${onNoteClick} selectMode=${selectMode}
|
||||||
|
selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
|
||||||
|
pinned=${true} onTogglePin=${togglePin} />`)}
|
||||||
|
${unpinnedNotes.length > 0 && html`
|
||||||
|
<div class="sw-notes-pane__pinned-divider">Notes</div>`}`}
|
||||||
|
|
||||||
|
${unpinnedNotes.map(n => html`
|
||||||
|
<${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
|
||||||
|
onClick=${onNoteClick} selectMode=${selectMode}
|
||||||
|
selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
|
||||||
|
pinned=${false} onTogglePin=${togglePin} />`)}
|
||||||
|
|
||||||
|
${hasMore && html`
|
||||||
|
<button class="sw-notes-pane__load-more" onClick=${loadMore}>
|
||||||
|
Load more\u2026
|
||||||
|
</button>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${selectMode && html`
|
||||||
|
<div class="sw-notes-pane__selection-bar">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
checked=${selectedIds.size === notes.length && notes.length > 0}
|
||||||
|
onChange=${e => toggleSelectAll(e.target.checked)} />
|
||||||
|
All
|
||||||
|
</label>
|
||||||
|
<span>${selectedIds.size} selected</span>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${bulkDelete}
|
||||||
|
style="color:var(--danger);border-color:var(--danger)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${exitSelectMode}>Cancel</button>
|
||||||
|
</div>`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
184
src/js/sw/components/notes-pane/note-reader.js
Normal file
184
src/js/sw/components/notes-pane/note-reader.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// ==========================================
|
||||||
|
// NotesPane Kit — NoteReader
|
||||||
|
// ==========================================
|
||||||
|
// Read-mode view: rendered markdown with wikilink chips,
|
||||||
|
// transclusion embeds, outline/TOC, backlinks panel,
|
||||||
|
// unlinked mentions, daily note navigation.
|
||||||
|
// Independently importable.
|
||||||
|
|
||||||
|
import { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
|
||||||
|
|
||||||
|
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||||
|
const html = window.html;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* note: object,
|
||||||
|
* backlinks: Array,
|
||||||
|
* onEdit: () => void,
|
||||||
|
* onDelete: () => void,
|
||||||
|
* onCopy: () => void,
|
||||||
|
* onNavigateLink: (title: string) => void,
|
||||||
|
* onBack: () => void,
|
||||||
|
* isDailyNote: boolean,
|
||||||
|
* onDailyPrev: () => void,
|
||||||
|
* onDailyNext: () => void,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function NoteReader(props) {
|
||||||
|
const {
|
||||||
|
note, backlinks, onEdit, onDelete, onCopy, onNavigateLink,
|
||||||
|
onBack, isDailyNote, onDailyPrev, onDailyNext,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
const [backlinksOpen, setBacklinksOpen] = useState(true);
|
||||||
|
const [unlinkedMentions, setUnlinkedMentions] = useState([]);
|
||||||
|
|
||||||
|
// ── Render content + wikilinks ──────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentRef.current || !note) return;
|
||||||
|
const { html: rendered } = renderNoteMarkdown(note.content || '');
|
||||||
|
contentRef.current.innerHTML = rendered;
|
||||||
|
|
||||||
|
// Attach wikilink click handlers
|
||||||
|
attachWikilinkHandlers(contentRef.current, onNavigateLink);
|
||||||
|
|
||||||
|
// Resolve transclusions
|
||||||
|
resolveTransclusions(contentRef.current, onNavigateLink);
|
||||||
|
|
||||||
|
// Add IDs to headings for outline scroll-to
|
||||||
|
contentRef.current.querySelectorAll('h1, h2, h3').forEach(el => {
|
||||||
|
const id = el.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
el.id = 'heading-' + id;
|
||||||
|
});
|
||||||
|
}, [note?.id, note?.content, onNavigateLink]);
|
||||||
|
|
||||||
|
// ── Extract headings for outline ────────
|
||||||
|
const headings = note ? extractHeadings(note.content || '') : [];
|
||||||
|
|
||||||
|
// ── Find unlinked mentions ──────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!note?.content) { setUnlinkedMentions([]); return; }
|
||||||
|
// Debounce: fetch note titles and check for unlinked mentions
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const api = window.sw?.api?.notes;
|
||||||
|
if (!api) return;
|
||||||
|
// Get all note titles (limited)
|
||||||
|
const resp = await api.list({ limit: 200, offset: 0 });
|
||||||
|
const allNotes = resp.data || resp || [];
|
||||||
|
const content = note.content.toLowerCase();
|
||||||
|
|
||||||
|
// Find titles mentioned in content but not wikilinked
|
||||||
|
const linked = new Set();
|
||||||
|
const linkRe = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
|
||||||
|
let m;
|
||||||
|
while ((m = linkRe.exec(note.content)) !== null) {
|
||||||
|
linked.add(m[1].trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentions = allNotes
|
||||||
|
.filter(n => n.id !== note.id)
|
||||||
|
.filter(n => {
|
||||||
|
const t = n.title.toLowerCase();
|
||||||
|
return t.length > 2 && content.includes(t) && !linked.has(t);
|
||||||
|
})
|
||||||
|
.map(n => n.title)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
setUnlinkedMentions(mentions);
|
||||||
|
} catch { setUnlinkedMentions([]); }
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [note?.id, note?.content]);
|
||||||
|
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
const folderBadge = note.folder_path && note.folder_path !== '/'
|
||||||
|
? html`<span class="sw-notes-pane__item-folder">${note.folder_path}</span>`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const tagChips = (note.tags || []).map(t =>
|
||||||
|
html`<span class="sw-notes-pane__item-tag" key=${t}>${t}</span>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const dailyDate = isDailyNote ? note.title.replace('Daily \u2014 ', '') : null;
|
||||||
|
|
||||||
|
const scrollToHeading = (id) => {
|
||||||
|
const el = contentRef.current?.querySelector('#heading-' + id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="sw-notes-pane__editor-header">
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onBack}>← Back</button>
|
||||||
|
<span class="sw-notes-pane__editor-header-spacer" />
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onCopy} title="Copy to clipboard">Copy</button>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onEdit}>Edit</button>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn"
|
||||||
|
style="color:var(--danger);border-color:var(--danger)"
|
||||||
|
onClick=${onDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=${'sw-notes-pane__reader' + (headings.length > 2 ? ' sw-notes-pane__reader--with-outline' : '')}>
|
||||||
|
${headings.length > 2 && html`
|
||||||
|
<div class="sw-notes-pane__outline-sidebar">
|
||||||
|
<div class="sw-notes-pane__outline-title">Outline</div>
|
||||||
|
${headings.map(h => html`
|
||||||
|
<a key=${h.id}
|
||||||
|
class=${'sw-notes-pane__outline-item sw-notes-pane__outline-item--h' + h.level}
|
||||||
|
onClick=${() => scrollToHeading(h.id)}>
|
||||||
|
${h.text}
|
||||||
|
</a>`)}
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__reader-scroll">
|
||||||
|
<h1 class="sw-notes-pane__reader-title">${note.title}</h1>
|
||||||
|
|
||||||
|
${(folderBadge || tagChips.length > 0) && html`
|
||||||
|
<div class="sw-notes-pane__reader-meta">${folderBadge}${tagChips}</div>`}
|
||||||
|
|
||||||
|
${isDailyNote && html`
|
||||||
|
<div class="sw-notes-pane__daily-nav">
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyPrev}>‹ Prev</button>
|
||||||
|
<span>${dailyDate}</span>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyNext}
|
||||||
|
disabled=${dailyDate >= today}>Next ›</button>
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__reader-content" ref=${contentRef} />
|
||||||
|
|
||||||
|
${backlinks.length > 0 && html`
|
||||||
|
<div class="sw-notes-pane__backlinks">
|
||||||
|
<div class="sw-notes-pane__backlinks-header"
|
||||||
|
onClick=${() => setBacklinksOpen(p => !p)}>
|
||||||
|
${backlinksOpen ? '▾' : '▸'} Backlinks
|
||||||
|
<span class="sw-notes-pane__backlinks-badge">${backlinks.length}</span>
|
||||||
|
</div>
|
||||||
|
${backlinksOpen && html`
|
||||||
|
<div class="sw-notes-pane__backlinks-list">
|
||||||
|
${backlinks.map(bl => html`
|
||||||
|
<div key=${bl.id} class="sw-notes-pane__backlink-item"
|
||||||
|
onClick=${() => onNavigateLink(bl.title)}>
|
||||||
|
<span>${bl.title}</span>
|
||||||
|
${bl.folder_path && bl.folder_path !== '/' && html`
|
||||||
|
<span class="sw-notes-pane__backlink-folder">${bl.folder_path}</span>`}
|
||||||
|
</div>`)}
|
||||||
|
</div>`}
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
${unlinkedMentions.length > 0 && html`
|
||||||
|
<div class="sw-notes-pane__unlinked">
|
||||||
|
<div class="sw-notes-pane__unlinked-title">Unlinked Mentions</div>
|
||||||
|
${unlinkedMentions.map(title => html`
|
||||||
|
<span key=${title} class="sw-notes-pane__unlinked-item"
|
||||||
|
onClick=${() => onNavigateLink(title)} title="Navigate to ${title}">
|
||||||
|
${title}
|
||||||
|
</span>`)}
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
159
src/js/sw/components/notes-pane/quick-switcher.js
Normal file
159
src/js/sw/components/notes-pane/quick-switcher.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// ==========================================
|
||||||
|
// NotesPane Kit — QuickSwitcher
|
||||||
|
// ==========================================
|
||||||
|
// Cmd+K / Ctrl+K style quick-open overlay.
|
||||||
|
// Fuzzy title search, keyboard nav, recent notes,
|
||||||
|
// create-on-enter. Obsidian's signature UX.
|
||||||
|
// Independently importable.
|
||||||
|
|
||||||
|
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||||
|
const html = window.html;
|
||||||
|
|
||||||
|
const RECENTS_KEY = 'sw_notes_recent';
|
||||||
|
const MAX_RECENTS = 5;
|
||||||
|
|
||||||
|
function _loadRecents() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]').slice(0, MAX_RECENTS);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveRecent(note) {
|
||||||
|
try {
|
||||||
|
const recents = _loadRecents().filter(r => r.id !== note.id);
|
||||||
|
recents.unshift({ id: note.id, title: note.title, folder_path: note.folder_path });
|
||||||
|
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* open: boolean,
|
||||||
|
* onClose: () => void,
|
||||||
|
* onSelect: (id: string) => void,
|
||||||
|
* onCreateNew: (title: string) => void,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function QuickSwitcher(props) {
|
||||||
|
const { open, onClose, onSelect, onCreateNew } = props;
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [recents, setRecents] = useState([]);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const searchTimerRef = useRef(null);
|
||||||
|
|
||||||
|
// ── Load recents on open ────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setActiveIndex(0);
|
||||||
|
setRecents(_loadRecents());
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ── Search ──────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(searchTimerRef.current);
|
||||||
|
if (!query || query.length < 1) {
|
||||||
|
setResults([]);
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await window.sw.api.notes.searchTitles(query, 10);
|
||||||
|
setResults(resp.data || resp || []);
|
||||||
|
setActiveIndex(0);
|
||||||
|
} catch { setResults([]); }
|
||||||
|
}, 150);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// ── Visible items ───────────────────────
|
||||||
|
const items = query ? results : recents;
|
||||||
|
const hasExactMatch = results.some(r => r.title.toLowerCase() === query.toLowerCase());
|
||||||
|
const showCreate = query && !hasExactMatch;
|
||||||
|
|
||||||
|
// ── Keyboard navigation ─────────────────
|
||||||
|
const onKeyDown = useCallback((e) => {
|
||||||
|
const totalItems = items.length + (showCreate ? 1 : 0);
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(i => (i + 1) % Math.max(totalItems, 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(i => (i - 1 + totalItems) % Math.max(totalItems, 1));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex < items.length) {
|
||||||
|
_select(items[activeIndex]);
|
||||||
|
} else if (showCreate) {
|
||||||
|
onCreateNew(query);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [items, activeIndex, showCreate, query, onClose, onCreateNew]);
|
||||||
|
|
||||||
|
const _select = (note) => {
|
||||||
|
_saveRecent(note);
|
||||||
|
onSelect(note.id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="sw-notes-pane__switcher-overlay"
|
||||||
|
onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div class="sw-notes-pane__switcher">
|
||||||
|
<input class="sw-notes-pane__switcher-input"
|
||||||
|
ref=${inputRef}
|
||||||
|
placeholder="Search notes\u2026"
|
||||||
|
value=${query}
|
||||||
|
onInput=${e => setQuery(e.target.value)}
|
||||||
|
onKeyDown=${onKeyDown} />
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__switcher-results">
|
||||||
|
${!query && recents.length > 0 && html`
|
||||||
|
<div class="sw-notes-pane__switcher-section">Recent</div>`}
|
||||||
|
|
||||||
|
${query && results.length === 0 && html`
|
||||||
|
<div style="padding:12px 14px;color:var(--text-3);font-size:13px">
|
||||||
|
No notes found
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
${items.map((note, i) => html`
|
||||||
|
<div key=${note.id}
|
||||||
|
class=${'sw-notes-pane__switcher-item' + (i === activeIndex ? ' sw-notes-pane__switcher-item--active' : '')}
|
||||||
|
onClick=${() => _select(note)}
|
||||||
|
onMouseEnter=${() => setActiveIndex(i)}>
|
||||||
|
<span class="sw-notes-pane__switcher-item-title">${note.title}</span>
|
||||||
|
${note.folder_path && note.folder_path !== '/' && html`
|
||||||
|
<span class="sw-notes-pane__switcher-item-folder">${note.folder_path}</span>`}
|
||||||
|
</div>`)}
|
||||||
|
|
||||||
|
${showCreate && html`
|
||||||
|
<div class=${'sw-notes-pane__switcher-create' + (activeIndex === items.length ? ' sw-notes-pane__switcher-item--active' : '')}
|
||||||
|
onClick=${() => { onCreateNew(query); onClose(); }}
|
||||||
|
onMouseEnter=${() => setActiveIndex(items.length)}>
|
||||||
|
+ Create "${query}"
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__switcher-hint">
|
||||||
|
↑↓ navigate · Enter select · Esc close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for external use (e.g., saving recents from openNote)
|
||||||
|
export { _saveRecent as saveRecentNote };
|
||||||
188
src/js/sw/components/notes-pane/save-to-note.js
Normal file
188
src/js/sw/components/notes-pane/save-to-note.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// ==========================================
|
||||||
|
// NotesPane Kit — SaveToNoteDialog
|
||||||
|
// ==========================================
|
||||||
|
// "Save to note" dialog: create-new or append-to-existing.
|
||||||
|
// Independently importable by any surface (ChatPane, etc.).
|
||||||
|
|
||||||
|
import { renderNoteMarkdown } from './markdown.js';
|
||||||
|
|
||||||
|
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||||
|
const html = window.html;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* open: boolean,
|
||||||
|
* onClose: () => void,
|
||||||
|
* content: string,
|
||||||
|
* sourceChannelId?: string,
|
||||||
|
* sourceMessageId?: string,
|
||||||
|
* defaultTitle?: string,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function SaveToNoteDialog(props) {
|
||||||
|
const { open, onClose, content, sourceChannelId, sourceMessageId, defaultTitle } = props;
|
||||||
|
|
||||||
|
const [mode, setMode] = useState('create'); // 'create' | 'append'
|
||||||
|
const [title, setTitle] = useState(defaultTitle || '');
|
||||||
|
const [folderPath, setFolderPath] = useState('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
const [selectedNote, setSelectedNote] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const searchTimerRef = useRef(null);
|
||||||
|
const titleInputRef = useRef(null);
|
||||||
|
|
||||||
|
// ── Reset on open ───────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMode('create');
|
||||||
|
setTitle(defaultTitle || '');
|
||||||
|
setFolderPath('');
|
||||||
|
setTags('');
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setSelectedNote(null);
|
||||||
|
setTimeout(() => titleInputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [open, defaultTitle]);
|
||||||
|
|
||||||
|
// ── Search for append target ────────────
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(searchTimerRef.current);
|
||||||
|
if (mode !== 'append' || searchQuery.length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await window.sw.api.notes.searchTitles(searchQuery, 8);
|
||||||
|
setSearchResults(resp.data || resp || []);
|
||||||
|
} catch { setSearchResults([]); }
|
||||||
|
}, 250);
|
||||||
|
}, [searchQuery, mode]);
|
||||||
|
|
||||||
|
// ── Save ────────────────────────────────
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
const api = window.sw.api.notes;
|
||||||
|
const toast = window.sw?.toast;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'append') {
|
||||||
|
if (!selectedNote) {
|
||||||
|
toast?.('Select a note to append to', 'warning');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.update(selectedNote.id, {
|
||||||
|
content: '\n\n---\n\n' + content,
|
||||||
|
mode: 'append',
|
||||||
|
});
|
||||||
|
toast?.(`Appended to "${selectedNote.title}"`, 'success');
|
||||||
|
} else {
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast?.('Title is required', 'warning');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tagsArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||||
|
await api.create({
|
||||||
|
title: title.trim(),
|
||||||
|
content,
|
||||||
|
folder_path: folderPath.trim(),
|
||||||
|
tags: tagsArr,
|
||||||
|
source_channel_id: sourceChannelId,
|
||||||
|
source_message_id: sourceMessageId,
|
||||||
|
});
|
||||||
|
toast?.('Note created', 'success');
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
toast?.(e.message, 'error');
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}, [mode, title, folderPath, tags, content, selectedNote, sourceChannelId, sourceMessageId, onClose, saving]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
const previewContent = content ? content.slice(0, 300) + (content.length > 300 ? '\u2026' : '') : '';
|
||||||
|
const { html: previewHtml } = renderNoteMarkdown(previewContent);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="sw-notes-pane__switcher-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div class="sw-notes-pane__switcher" style="width:min(480px,90vw)">
|
||||||
|
<div style="padding:14px">
|
||||||
|
<h3 style="margin:0 0 10px;font-size:15px;color:var(--text)">Save to Note</h3>
|
||||||
|
|
||||||
|
<div class="sw-notes-pane__save-mode">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="saveMode" value="create"
|
||||||
|
checked=${mode === 'create'}
|
||||||
|
onChange=${() => setMode('create')} />
|
||||||
|
Create new
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="saveMode" value="append"
|
||||||
|
checked=${mode === 'append'}
|
||||||
|
onChange=${() => setMode('append')} />
|
||||||
|
Append to existing
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${mode === 'create' && html`
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:10px">
|
||||||
|
<input class="sw-notes-pane__search-input" type="text"
|
||||||
|
ref=${titleInputRef} placeholder="Title"
|
||||||
|
value=${title} onInput=${e => setTitle(e.target.value)} />
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<input class="sw-notes-pane__search-input" type="text"
|
||||||
|
placeholder="Folder (e.g. /work)" style="flex:1"
|
||||||
|
value=${folderPath} onInput=${e => setFolderPath(e.target.value)} />
|
||||||
|
<input class="sw-notes-pane__search-input" type="text"
|
||||||
|
placeholder="Tags (comma-separated)" style="flex:1"
|
||||||
|
value=${tags} onInput=${e => setTags(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
${mode === 'append' && html`
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<input class="sw-notes-pane__search-input" type="text"
|
||||||
|
placeholder="Search notes to append to\u2026"
|
||||||
|
value=${searchQuery}
|
||||||
|
onInput=${e => setSearchQuery(e.target.value)} />
|
||||||
|
${searchResults.length > 0 && html`
|
||||||
|
<div class="sw-notes-pane__save-search-results">
|
||||||
|
${searchResults.map(n => html`
|
||||||
|
<div key=${n.id}
|
||||||
|
class=${'sw-notes-pane__save-search-item' + (selectedNote?.id === n.id ? ' sw-notes-pane__save-search-item--selected' : '')}
|
||||||
|
onClick=${() => { setSelectedNote(n); setSearchQuery(n.title); }}>
|
||||||
|
${n.title}
|
||||||
|
</div>`)}
|
||||||
|
</div>`}
|
||||||
|
</div>`}
|
||||||
|
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div style="font-size:11px;color:var(--text-3);margin-bottom:4px">
|
||||||
|
Preview (${content?.length || 0} chars)
|
||||||
|
</div>
|
||||||
|
<div class="sw-notes-pane__save-preview"
|
||||||
|
dangerouslySetInnerHTML=${{ __html: previewHtml }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:6px">
|
||||||
|
<button class="sw-notes-pane__toolbar-btn" onClick=${onClose}>Cancel</button>
|
||||||
|
<button class="sw-notes-pane__toolbar-btn"
|
||||||
|
style="color:var(--accent);border-color:var(--accent)"
|
||||||
|
onClick=${handleSave} disabled=${saving}>
|
||||||
|
${saving ? 'Saving\u2026' : (mode === 'append' ? 'Append' : 'Create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
475
src/js/sw/components/notes-pane/use-notes.js
Normal file
475
src/js/sw/components/notes-pane/use-notes.js
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
// ==========================================
|
||||||
|
// NotesPane Kit — useNotes Hook
|
||||||
|
// ==========================================
|
||||||
|
// Core state machine: views, CRUD, pagination, search,
|
||||||
|
// folders, sort, selection, backlinks, daily notes,
|
||||||
|
// wikilink navigation, pinning, quick switcher.
|
||||||
|
// Independently importable.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const notes = useNotes({ onNoteChange });
|
||||||
|
// notes.openNote(id);
|
||||||
|
|
||||||
|
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const PINS_KEY = 'sw_notes_pinned';
|
||||||
|
|
||||||
|
function _loadPins() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PINS_KEY);
|
||||||
|
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||||
|
} catch { return new Set(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _savePins(set) {
|
||||||
|
try { localStorage.setItem(PINS_KEY, JSON.stringify([...set])); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* initialFolder?: string,
|
||||||
|
* initialNoteId?: string,
|
||||||
|
* onNoteChange?: (note: object|null) => void,
|
||||||
|
* standalone?: boolean,
|
||||||
|
* enableGraph?: boolean,
|
||||||
|
* enableQuickSwitcher?: boolean,
|
||||||
|
* }} opts
|
||||||
|
*/
|
||||||
|
export function useNotes(opts = {}) {
|
||||||
|
const {
|
||||||
|
initialFolder,
|
||||||
|
initialNoteId,
|
||||||
|
onNoteChange,
|
||||||
|
enableGraph = true,
|
||||||
|
enableQuickSwitcher = true,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// ── View state ──────────────────────────
|
||||||
|
const [view, _setView] = useState(initialNoteId ? 'reader' : 'list');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// ── List state ──────────────────────────
|
||||||
|
const [notes, setNotes] = useState([]);
|
||||||
|
const [folder, setFolder] = useState(initialFolder || '');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sort, setSort] = useState('updated_desc');
|
||||||
|
const [tagFilter, setTagFilter] = useState('');
|
||||||
|
const pageRef = useRef(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [folders, setFolders] = useState([]);
|
||||||
|
|
||||||
|
// ── Selection state ─────────────────────
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
|
|
||||||
|
// ── Editor/Reader state ─────────────────
|
||||||
|
const [currentNote, setCurrentNote] = useState(null);
|
||||||
|
const [editingId, setEditingId] = useState(initialNoteId || null);
|
||||||
|
const [editorMode, setEditorMode] = useState('read'); // 'read' | 'edit' | 'preview'
|
||||||
|
const [backlinks, setBacklinks] = useState([]);
|
||||||
|
|
||||||
|
// ── Graph state ─────────────────────────
|
||||||
|
const [graphData, setGraphData] = useState(null);
|
||||||
|
const graphDirtyRef = useRef(true);
|
||||||
|
|
||||||
|
// ── Quick Switcher ──────────────────────
|
||||||
|
const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
|
||||||
|
|
||||||
|
// ── Pinning ─────────────────────────────
|
||||||
|
const [pinnedIds, setPinnedIds] = useState(_loadPins);
|
||||||
|
|
||||||
|
// ── API shortcut ────────────────────────
|
||||||
|
const api = () => window.sw.api.notes;
|
||||||
|
const toast = (msg, v) => window.sw?.toast?.(msg, v);
|
||||||
|
const confirm = (msg) => window.sw?.confirm?.(msg) ?? Promise.resolve(window.confirm(msg));
|
||||||
|
|
||||||
|
// ── View switching ──────────────────────
|
||||||
|
const setView = useCallback((v) => {
|
||||||
|
_setView(v);
|
||||||
|
if (v === 'list') {
|
||||||
|
setCurrentNote(null);
|
||||||
|
setEditingId(null);
|
||||||
|
setBacklinks([]);
|
||||||
|
if (onNoteChange) onNoteChange(null);
|
||||||
|
}
|
||||||
|
}, [onNoteChange]);
|
||||||
|
|
||||||
|
// ── Load notes list ─────────────────────
|
||||||
|
const _loadList = useCallback(async (append = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let result, isSearch = false;
|
||||||
|
if (searchQuery && searchQuery.length >= 2) {
|
||||||
|
isSearch = true;
|
||||||
|
const data = await api().search(searchQuery);
|
||||||
|
result = data.data || data || [];
|
||||||
|
setHasMore(false);
|
||||||
|
} else {
|
||||||
|
const page = append ? pageRef.current : 1;
|
||||||
|
if (!append) pageRef.current = 1;
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
const data = await api().list({ limit: PAGE_SIZE, offset, folder, tag: tagFilter, sort });
|
||||||
|
result = data.data || data || [];
|
||||||
|
setHasMore(result.length >= PAGE_SIZE);
|
||||||
|
}
|
||||||
|
if (append) {
|
||||||
|
setNotes(prev => [...prev, ...result]);
|
||||||
|
} else {
|
||||||
|
setNotes(result);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [searchQuery, folder, tagFilter, sort]);
|
||||||
|
|
||||||
|
const refreshList = useCallback(() => {
|
||||||
|
pageRef.current = 1;
|
||||||
|
_loadList(false);
|
||||||
|
}, [_loadList]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
pageRef.current += 1;
|
||||||
|
_loadList(true);
|
||||||
|
}, [_loadList]);
|
||||||
|
|
||||||
|
// ── Load folders ────────────────────────
|
||||||
|
const _loadFolders = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api().folders();
|
||||||
|
setFolders(data.folders || data || []);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Initial load ────────────────────────
|
||||||
|
const didMountRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didMountRef.current) {
|
||||||
|
didMountRef.current = true;
|
||||||
|
_loadList();
|
||||||
|
_loadFolders();
|
||||||
|
if (initialNoteId) {
|
||||||
|
_openNoteById(initialNoteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Reload on filter changes ────────────
|
||||||
|
const prevFiltersRef = useRef({ folder, sort, tagFilter, searchQuery });
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevFiltersRef.current;
|
||||||
|
if (prev.folder !== folder || prev.sort !== sort ||
|
||||||
|
prev.tagFilter !== tagFilter || prev.searchQuery !== searchQuery) {
|
||||||
|
prevFiltersRef.current = { folder, sort, tagFilter, searchQuery };
|
||||||
|
if (didMountRef.current) {
|
||||||
|
pageRef.current = 1;
|
||||||
|
_loadList(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [folder, sort, tagFilter, searchQuery, _loadList]);
|
||||||
|
|
||||||
|
// ── Selection ───────────────────────────
|
||||||
|
const enterSelectMode = useCallback(() => {
|
||||||
|
setSelectMode(true);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exitSelectMode = useCallback(() => {
|
||||||
|
setSelectMode(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelectAll = useCallback((checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(notes.map(n => n.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}, [notes]);
|
||||||
|
|
||||||
|
const bulkDelete = useCallback(async () => {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
const count = selectedIds.size;
|
||||||
|
if (!await confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await api().bulkDelete([...selectedIds]);
|
||||||
|
toast(`Deleted ${resp.deleted || count} note${count !== 1 ? 's' : ''}`, 'success');
|
||||||
|
exitSelectMode();
|
||||||
|
graphDirtyRef.current = true;
|
||||||
|
refreshList();
|
||||||
|
_loadFolders();
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
}, [selectedIds, exitSelectMode, refreshList, _loadFolders]);
|
||||||
|
|
||||||
|
// ── Open note ───────────────────────────
|
||||||
|
const _openNoteById = useCallback(async (id) => {
|
||||||
|
try {
|
||||||
|
const note = await api().get(id);
|
||||||
|
setCurrentNote(note);
|
||||||
|
setEditingId(id);
|
||||||
|
setEditorMode('read');
|
||||||
|
_setView('reader');
|
||||||
|
if (onNoteChange) onNoteChange(note);
|
||||||
|
// Load backlinks
|
||||||
|
try {
|
||||||
|
const bl = await api().backlinks(id);
|
||||||
|
setBacklinks(bl.data || bl || []);
|
||||||
|
} catch { setBacklinks([]); }
|
||||||
|
} catch (e) {
|
||||||
|
toast('Failed to load note: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}, [onNoteChange]);
|
||||||
|
|
||||||
|
const openNote = useCallback((id) => _openNoteById(id), [_openNoteById]);
|
||||||
|
|
||||||
|
const newNote = useCallback(() => {
|
||||||
|
setCurrentNote(null);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditorMode('edit');
|
||||||
|
setBacklinks([]);
|
||||||
|
_setView('editor');
|
||||||
|
if (onNoteChange) onNoteChange(null);
|
||||||
|
}, [onNoteChange]);
|
||||||
|
|
||||||
|
// ── Save note ───────────────────────────
|
||||||
|
const saveNote = useCallback(async (fields) => {
|
||||||
|
const { title, content, folder_path, tags } = fields;
|
||||||
|
if (!title) { toast('Title is required', 'warning'); return false; }
|
||||||
|
if (!content) { toast('Content is required', 'warning'); return false; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
const updated = await api().update(editingId, {
|
||||||
|
title, content, folder_path, tags, mode: 'replace',
|
||||||
|
});
|
||||||
|
setCurrentNote(updated);
|
||||||
|
toast('Note updated', 'success');
|
||||||
|
setEditorMode('read');
|
||||||
|
_setView('reader');
|
||||||
|
// Reload backlinks
|
||||||
|
try {
|
||||||
|
const bl = await api().backlinks(editingId);
|
||||||
|
setBacklinks(bl.data || bl || []);
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
const created = await api().create({ title, content, folder_path, tags });
|
||||||
|
setCurrentNote(created);
|
||||||
|
setEditingId(created.id);
|
||||||
|
toast('Note created', 'success');
|
||||||
|
setEditorMode('read');
|
||||||
|
_setView('reader');
|
||||||
|
}
|
||||||
|
graphDirtyRef.current = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [editingId]);
|
||||||
|
|
||||||
|
// ── Delete note ─────────────────────────
|
||||||
|
const deleteNote = useCallback(async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
if (!await confirm('Delete this note?')) return;
|
||||||
|
try {
|
||||||
|
await api().del(editingId);
|
||||||
|
toast('Note deleted', 'success');
|
||||||
|
graphDirtyRef.current = true;
|
||||||
|
setView('list');
|
||||||
|
refreshList();
|
||||||
|
_loadFolders();
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
}, [editingId, setView, refreshList, _loadFolders]);
|
||||||
|
|
||||||
|
// ── Copy note ───────────────────────────
|
||||||
|
const copyNote = useCallback(() => {
|
||||||
|
if (!currentNote) return;
|
||||||
|
const text = `# ${currentNote.title || ''}\n\n${currentNote.content || ''}`;
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(() => toast('Copied to clipboard', 'success'))
|
||||||
|
.catch(() => toast('Copy failed', 'error'));
|
||||||
|
}, [currentNote]);
|
||||||
|
|
||||||
|
// ── Wikilink navigation ─────────────────
|
||||||
|
const navigateToLink = useCallback(async (title) => {
|
||||||
|
try {
|
||||||
|
const resp = await api().searchTitles(title, 1);
|
||||||
|
const match = (resp.data || resp || []).find(
|
||||||
|
n => n.title.toLowerCase() === title.toLowerCase()
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
await _openNoteById(match.id);
|
||||||
|
} else {
|
||||||
|
if (await confirm(`Note "${title}" not found. Create it?`)) {
|
||||||
|
const created = await api().create({
|
||||||
|
title,
|
||||||
|
content: `# ${title}\n\n`,
|
||||||
|
folder_path: '/',
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
await _openNoteById(created.id);
|
||||||
|
graphDirtyRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
|
||||||
|
}, [_openNoteById]);
|
||||||
|
|
||||||
|
// ── Daily Notes ─────────────────────────
|
||||||
|
const _isDailyNote = useCallback((note) => {
|
||||||
|
return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDailyNote = _isDailyNote(currentNote);
|
||||||
|
|
||||||
|
const openDailyNote = useCallback(async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const title = `Daily \u2014 ${today}`;
|
||||||
|
try {
|
||||||
|
const resp = await api().searchTitles(title, 1);
|
||||||
|
const existing = (resp.data || resp || []).find(
|
||||||
|
n => n.title.toLowerCase() === title.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await _openNoteById(existing.id);
|
||||||
|
} else {
|
||||||
|
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||||
|
const created = await api().create({
|
||||||
|
title, content, folder_path: '/daily/', tags: ['daily'],
|
||||||
|
});
|
||||||
|
await _openNoteById(created.id);
|
||||||
|
graphDirtyRef.current = true;
|
||||||
|
}
|
||||||
|
} catch (e) { toast('Failed to open daily note: ' + e.message, 'error'); }
|
||||||
|
}, [_openNoteById]);
|
||||||
|
|
||||||
|
const navigateDaily = useCallback(async (offset) => {
|
||||||
|
if (!currentNote || !_isDailyNote(currentNote)) return;
|
||||||
|
const dateStr = currentNote.title.replace('Daily \u2014 ', '');
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
d.setDate(d.getDate() + offset);
|
||||||
|
const newDate = d.toISOString().slice(0, 10);
|
||||||
|
const title = `Daily \u2014 ${newDate}`;
|
||||||
|
try {
|
||||||
|
const resp = await api().searchTitles(title, 1);
|
||||||
|
const existing = (resp.data || resp || []).find(
|
||||||
|
n => n.title.toLowerCase() === title.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await _openNoteById(existing.id);
|
||||||
|
} else {
|
||||||
|
const content = `# ${newDate}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||||
|
const created = await api().create({
|
||||||
|
title, content, folder_path: '/daily/', tags: ['daily'],
|
||||||
|
});
|
||||||
|
await _openNoteById(created.id);
|
||||||
|
graphDirtyRef.current = true;
|
||||||
|
}
|
||||||
|
} catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
|
||||||
|
}, [currentNote, _isDailyNote, _openNoteById]);
|
||||||
|
|
||||||
|
// ── Graph ───────────────────────────────
|
||||||
|
const loadGraph = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api().graph();
|
||||||
|
setGraphData(data);
|
||||||
|
graphDirtyRef.current = false;
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
toast('Failed to load graph: ' + e.message, 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const invalidateGraph = useCallback(() => {
|
||||||
|
graphDirtyRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Pinning ─────────────────────────────
|
||||||
|
const togglePin = useCallback((id) => {
|
||||||
|
setPinnedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
_savePins(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts ──────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
// Quick switcher: Ctrl/Cmd + K
|
||||||
|
if (enableQuickSwitcher && (e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setQuickSwitcherOpen(prev => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + N: new note
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
||||||
|
// Only prevent when notes pane is focused context
|
||||||
|
if (document.querySelector('.sw-notes-pane')) {
|
||||||
|
e.preventDefault();
|
||||||
|
newNote();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + D: daily note
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
|
||||||
|
if (document.querySelector('.sw-notes-pane')) {
|
||||||
|
e.preventDefault();
|
||||||
|
openDailyNote();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [enableQuickSwitcher, newNote, openDailyNote]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// View
|
||||||
|
view, setView,
|
||||||
|
// List
|
||||||
|
notes, loading, error,
|
||||||
|
folder, setFolder,
|
||||||
|
searchQuery, setSearchQuery,
|
||||||
|
sort, setSort,
|
||||||
|
hasMore, loadMore,
|
||||||
|
folders, refreshList,
|
||||||
|
tagFilter, setTagFilter,
|
||||||
|
// Selection
|
||||||
|
selectMode, selectedIds,
|
||||||
|
enterSelectMode, exitSelectMode,
|
||||||
|
toggleSelect, toggleSelectAll,
|
||||||
|
bulkDelete,
|
||||||
|
// Editor/Reader
|
||||||
|
currentNote, editingId,
|
||||||
|
editorMode, setEditorMode,
|
||||||
|
openNote, newNote,
|
||||||
|
saveNote, deleteNote, copyNote,
|
||||||
|
backlinks, navigateToLink,
|
||||||
|
// Daily
|
||||||
|
openDailyNote, isDailyNote, navigateDaily,
|
||||||
|
// Graph
|
||||||
|
graphData, loadGraph, invalidateGraph,
|
||||||
|
// Quick Switcher
|
||||||
|
quickSwitcherOpen, setQuickSwitcherOpen,
|
||||||
|
// Pinning
|
||||||
|
pinnedIds, togglePin,
|
||||||
|
// Misc
|
||||||
|
clearError: useCallback(() => setError(null), []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -127,8 +127,19 @@ export async function boot() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NotesPane render helper — surfaces call sw.notesPane(container, opts)
|
||||||
|
// Returns Promise<imperative handle>.
|
||||||
|
sw.notesPane = function (container, opts = {}) {
|
||||||
|
return import('../components/notes-pane/index.js').then(({ NotesPane }) => {
|
||||||
|
const handleRef = { current: null };
|
||||||
|
const { render } = preact;
|
||||||
|
render(html`<${NotesPane} handleRef=${handleRef} ...${opts} />`, container);
|
||||||
|
return handleRef.current;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Marker for idempotency
|
// Marker for idempotency
|
||||||
sw._sdk = '0.37.8';
|
sw._sdk = '0.37.9';
|
||||||
|
|
||||||
// 8. Expose globally
|
// 8. Expose globally
|
||||||
window.sw = sw;
|
window.sw = sw;
|
||||||
@@ -150,7 +161,7 @@ export async function boot() {
|
|||||||
// 10. Signal ready
|
// 10. Signal ready
|
||||||
events.emit('sdk.ready', {}, { localOnly: true });
|
events.emit('sdk.ready', {}, { localOnly: true });
|
||||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||||
console.log('[sw] SDK v0.37.8 ready');
|
console.log('[sw] SDK v0.37.9 ready');
|
||||||
|
|
||||||
return sw;
|
return sw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,11 +479,12 @@ const Switchboard = {
|
|||||||
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
|
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
|
||||||
*/
|
*/
|
||||||
sw.notes = function (container, opts) {
|
sw.notes = function (container, opts) {
|
||||||
if (typeof NotePanel === 'undefined') {
|
// v0.37.9: Prefer new Preact NotesPane via sw.notesPane() when SDK is loaded
|
||||||
console.error('[Switchboard] NotePanel not available');
|
if (window.sw?.notesPane) {
|
||||||
return null;
|
return window.sw.notesPane(container, opts || {});
|
||||||
}
|
}
|
||||||
return NotePanel.mount(container, opts || {});
|
console.warn('[Switchboard] sw.notes() — use sw.notesPane() instead (v0.37.9+)');
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1271,7 +1271,7 @@ const UI = {
|
|||||||
const link = document.createElement('button');
|
const link = document.createElement('button');
|
||||||
link.className = 'tool-note-link';
|
link.className = 'tool-note-link';
|
||||||
link.textContent = '📝 View note';
|
link.textContent = '📝 View note';
|
||||||
link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
|
link.onclick = () => { /* v0.37.9: notes navigation moved to Preact NotesPane */ };
|
||||||
el.appendChild(link);
|
el.appendChild(link);
|
||||||
}
|
}
|
||||||
} catch (e) { /* not JSON or no useful summary */ }
|
} catch (e) { /* not JSON or no useful summary */ }
|
||||||
|
|||||||
@@ -604,8 +604,10 @@ function _copyCodeBlock(codeId) {
|
|||||||
if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
|
if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
|
||||||
}
|
}
|
||||||
function _openNoteFromTool(noteId) {
|
function _openNoteFromTool(noteId) {
|
||||||
openNotes();
|
// v0.37.9: old notes.js removed — navigate to notes surface instead
|
||||||
setTimeout(() => openNoteEditor(noteId), 300);
|
if (window.sw?.notesPane) {
|
||||||
|
console.log('[ui-format] Note tool link — navigate to /notes');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Exports ─────────────────────────────────
|
// ── Exports ─────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user