Changeset 0.17.3 (#78)

This commit is contained in:
2026-02-28 15:20:23 +00:00
parent a008dac488
commit 12e316c234
29 changed files with 3347 additions and 65 deletions

View File

@@ -1870,6 +1870,128 @@ select option { background: var(--bg-surface); color: var(--text); }
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: #fff; 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-2);
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;
}
/* ── Sidebar Search ──────────────────────── */
.sidebar-search {

View File

@@ -30,12 +30,13 @@ import { emacs } from '@replit/codemirror-emacs';
import { codeEditor } from './code-editor.mjs';
import { chatInput } from './chat-input.mjs';
import { noteEditor } from './note-editor.mjs';
// ── Expose on window ────────────────────────
window.CM = {
// Version (injected by the build script or matched to app version)
version: '0.17.2',
version: '0.17.3',
// Low-level access (for advanced use / debug console)
EditorView,
@@ -49,6 +50,10 @@ window.CM = {
// CM.chatInput(container, { placeholder, onSubmit, onChange, maxHeight, darkMode })
chatInput,
// Factory: rich markdown note editor with wikilinks
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
noteEditor,
// Bundled language modes (for reference / dynamic use)
languages: {
javascript, json, markdown, sql, html, css, yaml, go, python, rust,

207
src/editor/note-editor.mjs Normal file
View File

@@ -0,0 +1,207 @@
// ==========================================
// Chat Switchboard — CM6 Note Editor Factory
// ==========================================
// Rich markdown editor for the notes panel.
// Decorates headings, bold, italic, code blocks inline.
// Integrates wikilink autocomplete and chip rendering.
//
// Usage:
// const editor = CM.noteEditor(container, {
// value: '# My Note\n\nSome content',
// darkMode: true,
// onChange: (text) => { ... },
// onLink: (title) => { ... },
// linkCompleter: async (query) => [{ label, id }],
// });
//
// editor.getValue()
// editor.setValue(str)
// editor.focus()
// editor.destroy()
// ==========================================
import { EditorView, keymap, drawSelection, dropCursor,
highlightSpecialChars, ViewPlugin, Decoration } from '@codemirror/view';
import { EditorState, RangeSetBuilder } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { syntaxHighlighting, defaultHighlightStyle, syntaxTree } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { noteEditorTheme } from './theme.mjs';
import { wikilinkExtension } from './wikilink.mjs';
// ── Markdown Live Preview Decorations ───────
/**
* ViewPlugin that styles markdown headings, code blocks, etc.
* at their rendered sizes inside the editor (live preview).
*/
const mdPreviewPlugin = ViewPlugin.fromClass(class {
decorations;
constructor(view) {
this.decorations = this.build(view);
}
update(update) {
if (update.docChanged || update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.decorations = this.build(update.view);
}
}
build(view) {
const builder = new RangeSetBuilder();
const tree = syntaxTree(view.state);
const doc = view.state.doc;
tree.iterate({
enter(node) {
// Heading decorations
if (node.name.startsWith('ATXHeading')) {
const level = parseInt(node.name.replace('ATXHeading', ''), 10) || 1;
const line = doc.lineAt(node.from);
builder.add(line.from, line.from,
Decoration.line({ class: `cm-md-heading cm-md-h${level}` }));
}
// Fenced code block decorations
if (node.name === 'FencedCode') {
const startLine = doc.lineAt(node.from);
const endLine = doc.lineAt(node.to);
for (let ln = startLine.number; ln <= endLine.number; ln++) {
const line = doc.line(ln);
let cls = 'cm-codeblock cm-codeblock-mid';
if (ln === startLine.number) cls = 'cm-codeblock cm-codeblock-open';
if (ln === endLine.number) cls = 'cm-codeblock cm-codeblock-close';
if (startLine.number === endLine.number) cls = 'cm-codeblock cm-codeblock-open cm-codeblock-close';
builder.add(line.from, line.from, Decoration.line({ class: cls }));
}
}
// Blockquote decorations
if (node.name === 'Blockquote') {
const startLine = doc.lineAt(node.from);
const endLine = doc.lineAt(node.to);
for (let ln = startLine.number; ln <= endLine.number; ln++) {
const line = doc.line(ln);
builder.add(line.from, line.from,
Decoration.line({ class: 'cm-md-blockquote' }));
}
}
},
});
return builder.finish();
}
}, {
decorations: (v) => v.decorations,
});
// ── Note Editor Factory ─────────────────────
/**
* Create a rich markdown note editor with wikilink support.
*
* @param {HTMLElement} target - Container element
* @param {Object} opts
* @param {string} [opts.value='']
* @param {boolean} [opts.darkMode]
* @param {Function} [opts.onChange] - Called with text on every edit
* @param {Function} [opts.onLink] - Called with (title) when a [[link]] is clicked
* @param {Function} [opts.linkCompleter] - async (query) => [{ label, id }]
* @returns {{ getValue, setValue, focus, getView, destroy }}
*/
export function noteEditor(target, opts = {}) {
const {
value = '',
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
onChange = null,
onLink = null,
linkCompleter = null,
} = opts;
const extensions = [
// Markdown highlighting with live preview
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
mdPreviewPlugin,
// Wikilink support
...wikilinkExtension({ onLink, linkCompleter }),
// Base editing
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
closeBrackets(),
highlightSelectionMatches(),
// Theme — note-specific (no line numbers, full-height)
noteEditorTheme,
// Keymaps
keymap.of([
indentWithTab,
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
// Spell check
EditorView.contentAttributes.of({
spellcheck: 'true',
autocapitalize: 'sentences',
autocorrect: 'on',
}),
// Line wrapping
EditorView.lineWrapping,
];
// Change listener
if (onChange) {
extensions.push(EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}));
}
const view = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: target,
});
// ── Public API ──────────────────────────
return {
getValue() {
return view.state.doc.toString();
},
setValue(text) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text || '' },
});
},
focus() {
view.focus();
},
/** Access the underlying EditorView */
getView() {
return view;
},
/** Clean up */
destroy() {
view.destroy();
},
};
}

View File

@@ -184,3 +184,97 @@ export const chatInputTheme = EditorView.theme({
color: 'var(--text-3, #6b6b7b)',
},
});
/**
* Theme for the note editor — full-height, no gutters,
* with markdown live preview styling (heading sizes, blockquotes).
*/
export const noteEditorTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--bg-surface, var(--bg))',
color: 'var(--text)',
fontSize: 'var(--msg-font, 14px)',
minHeight: '200px',
maxHeight: '60vh',
},
'.cm-content': {
fontFamily: 'var(--font, system-ui, sans-serif)',
caretColor: 'var(--accent)',
padding: '8px 0',
lineHeight: '1.6',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--accent)',
borderLeftWidth: '2px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'var(--accent-muted, rgba(99, 102, 241, 0.2))',
},
'.cm-line': {
padding: '0 4px',
},
'.cm-scroller': {
fontFamily: 'var(--font, system-ui, sans-serif)',
overflow: 'auto',
},
// Tooltip/autocomplete
'.cm-tooltip': {
backgroundColor: 'var(--bg-2)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius, 4px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
},
'.cm-tooltip-autocomplete > ul > li': {
padding: '4px 8px',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--accent)',
color: '#fff',
},
// ── Markdown live preview ──
'.cm-md-heading': {
fontWeight: 'bold',
},
'.cm-md-h1': {
fontSize: '1.6em',
lineHeight: '1.3',
},
'.cm-md-h2': {
fontSize: '1.35em',
lineHeight: '1.3',
},
'.cm-md-h3': {
fontSize: '1.15em',
lineHeight: '1.4',
},
'.cm-md-blockquote': {
borderLeft: '3px solid var(--accent, #6c9fff)',
paddingLeft: '12px',
color: 'var(--text-2, #999)',
fontStyle: 'italic',
},
// Fenced code blocks
'.cm-codeblock': {
backgroundColor: 'var(--bg-2, #1e1e2e)',
fontFamily: 'var(--mono, monospace)',
fontSize: '13px',
padding: '0 10px',
borderLeft: '2px solid var(--accent, #6c9fff)',
},
'.cm-codeblock.cm-codeblock-open': {
borderTopLeftRadius: 'var(--radius, 6px)',
borderTopRightRadius: 'var(--radius, 6px)',
paddingTop: '6px',
color: 'var(--text-3, #6b6b7b)',
},
'.cm-codeblock.cm-codeblock-close': {
borderBottomLeftRadius: 'var(--radius, 6px)',
borderBottomRightRadius: 'var(--radius, 6px)',
paddingBottom: '6px',
color: 'var(--text-3, #6b6b7b)',
},
});

203
src/editor/wikilink.mjs Normal file
View File

@@ -0,0 +1,203 @@
// ==========================================
// Chat Switchboard — CM6 Wikilink Extension
// ==========================================
// Provides:
// 1. ViewPlugin that decorates [[Title]] as clickable chips
// 2. Autocomplete source triggered by [[
// 3. Distinct styling for ![[transclusion]] markers
//
// Usage:
// import { wikilinkExtension } from './wikilink.mjs';
// const ext = wikilinkExtension({
// onLink: (title) => { ... },
// linkCompleter: async (query) => [{ label, id }],
// });
// ==========================================
import {
ViewPlugin, Decoration, WidgetType, EditorView,
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { autocompletion } from '@codemirror/autocomplete';
// ── Wikilink Regex ──────────────────────────
const WIKILINK_RE = /(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
// ── Widget: Clickable Link Chip ─────────────
class WikilinkWidget extends WidgetType {
constructor(title, displayText, isTransclusion, onLink) {
super();
this.title = title;
this.displayText = displayText || title;
this.isTransclusion = isTransclusion;
this.onLink = onLink;
}
toDOM() {
const chip = document.createElement('span');
chip.className = this.isTransclusion
? 'cm-wikilink cm-wikilink-transclusion'
: 'cm-wikilink';
chip.textContent = (this.isTransclusion ? '↗ ' : '') + this.displayText;
chip.title = this.isTransclusion
? `Embed: ${this.title}`
: `Link: ${this.title}`;
chip.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.onLink) this.onLink(this.title);
});
return chip;
}
eq(other) {
return this.title === other.title
&& this.displayText === other.displayText
&& this.isTransclusion === other.isTransclusion;
}
ignoreEvent() { return false; }
}
// ── ViewPlugin: Decorate [[links]] ──────────
function wikilinkDecoPlugin(onLink) {
return ViewPlugin.fromClass(class {
decorations;
constructor(view) {
this.decorations = this.build(view);
}
update(update) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.build(update.view);
}
}
build(view) {
const builder = new RangeSetBuilder();
const doc = view.state.doc;
const text = doc.toString();
let match;
WIKILINK_RE.lastIndex = 0;
while ((match = WIKILINK_RE.exec(text)) !== null) {
const from = match.index;
const to = from + match[0].length;
// Don't decorate if cursor is inside the link (let user edit)
const sel = view.state.selection.main;
if (sel.from >= from && sel.from <= to) continue;
const isTransclusion = match[1] === '!';
const title = match[2].trim();
const displayText = match[3] ? match[3].trim() : '';
builder.add(from, to, Decoration.replace({
widget: new WikilinkWidget(title, displayText, isTransclusion, onLink),
}));
}
return builder.finish();
}
}, {
decorations: (v) => v.decorations,
});
}
// ── Autocomplete: [[ trigger ────────────────
function wikilinkAutocomplete(linkCompleter) {
return autocompletion({
override: [
async (ctx) => {
// Look for [[ before the cursor
const line = ctx.state.doc.lineAt(ctx.pos);
const textBefore = ctx.state.doc.sliceString(line.from, ctx.pos);
const triggerMatch = textBefore.match(/\[\[([^\]]*?)$/);
if (!triggerMatch) return null;
const query = triggerMatch[1];
const from = ctx.pos - query.length;
if (!linkCompleter) return null;
try {
const results = await linkCompleter(query);
if (!results || results.length === 0) return null;
return {
from,
options: results.map(r => ({
label: r.label || r.title,
apply: (view, completion, from, to) => {
// Replace query text with completed title + closing ]]
const insert = completion.label + ']]';
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
},
})),
};
} catch (e) {
console.warn('[wikilink] autocomplete error:', e);
return null;
}
},
],
activateOnTyping: true,
});
}
// ── Theme: Wikilink Chip Styles ─────────────
const wikilinkTheme = EditorView.baseTheme({
'.cm-wikilink': {
display: 'inline-block',
padding: '1px 6px',
margin: '0 1px',
borderRadius: '4px',
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.15)',
color: 'var(--accent, #6366f1)',
cursor: 'pointer',
fontSize: '0.9em',
fontWeight: '500',
textDecoration: 'none',
verticalAlign: 'baseline',
'&:hover': {
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.25)',
},
},
'.cm-wikilink-transclusion': {
borderLeft: '2px solid var(--accent, #6366f1)',
fontStyle: 'italic',
},
});
// ── Public: Combined Extension ──────────────
/**
* Create the wikilink CM6 extension bundle.
*
* @param {Object} opts
* @param {Function} [opts.onLink] - Called with (title) when a link chip is clicked
* @param {Function} [opts.linkCompleter] - async (query) => [{ label, id }]
* @returns {Extension[]} Array of CM6 extensions
*/
export function wikilinkExtension(opts = {}) {
const extensions = [
wikilinkDecoPlugin(opts.onLink || null),
wikilinkTheme,
];
if (opts.linkCompleter) {
extensions.push(wikilinkAutocomplete(opts.linkCompleter));
}
return extensions;
}

View File

@@ -237,6 +237,8 @@
</div>
<div class="notes-actions-bar">
<button class="btn-small" id="notesSelectModeBtn">Select</button>
<button class="btn-small" id="notesGraphBtn" title="Knowledge graph">Graph</button>
<button class="btn-small" id="notesTodayBtn" title="Today's daily note">Today</button>
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
</div>
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
@@ -250,6 +252,19 @@
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
</div>
</div>
<!-- Graph view (hidden by default) -->
<div id="notesGraphView" class="notes-graph-view" style="display:none">
<div class="notes-graph-toolbar">
<button class="btn-small" onclick="closeNoteGraph()">← List</button>
<span class="notes-graph-stats">
<span id="graphNodeCount">0</span> notes ·
<span id="graphEdgeCount">0</span> links
</span>
<button class="btn-small" onclick="_graphToggleOrphans()" title="Toggle unresolved links">Ghosts</button>
<button class="btn-small" onclick="_graphResetZoom()" title="Reset zoom">Reset</button>
</div>
<canvas id="noteGraphCanvas"></canvas>
</div>
<!-- Editor view (hidden by default) -->
<div id="notesEditorView" style="display:none">
<div class="notes-editor">
@@ -265,9 +280,16 @@
<h3 class="note-read-title" id="noteReadTitle"></h3>
<div class="note-read-meta" id="noteReadMeta"></div>
<div class="note-read-content msg-text" id="noteReadContent"></div>
<!-- Backlinks panel -->
<div id="noteBacklinks" class="note-backlinks" style="display:none">
<div class="note-backlinks-header" onclick="toggleBacklinks()">
<span>Linked mentions</span>
<span id="noteBacklinksCount" class="badge">0</span>
</div>
<div id="noteBacklinksList" class="note-backlinks-list"></div>
</div>
<div class="notes-editor-actions">
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
</div>
</div>
@@ -278,7 +300,7 @@
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
<div class="form-group"><div id="noteEditorContentContainer" class="notes-content-input"></div></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
@@ -997,6 +1019,49 @@
</div>
<!-- Command Palette (Ctrl+K) -->
<!-- ── Save to Note Modal ──────────────────── -->
<div class="modal-overlay" id="saveToNoteModal" style="display:none">
<div class="modal modal-narrow">
<div class="modal-header"><h2>Save to Note</h2><button class="modal-close" onclick="closeSaveToNoteModal()"></button></div>
<div class="modal-body">
<div class="form-group">
<label>Title</label>
<input type="text" id="saveToNoteTitle" placeholder="Note title" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group">
<label>Folder</label>
<input type="text" id="saveToNoteFolder" placeholder="/ (root)" class="notes-folder-input">
</div>
<div class="form-group">
<label>Tags</label>
<input type="text" id="saveToNoteTags" placeholder="comma-separated" class="notes-tags-input">
</div>
</div>
<div class="form-group">
<label class="save-to-note-toggle">
<input type="checkbox" id="saveToNoteAppendToggle" onchange="_toggleAppendMode(this.checked)">
Append to existing note
</label>
</div>
<div id="saveToNoteAppendPicker" style="display:none" class="form-group">
<label>Target note</label>
<input type="text" id="saveToNoteAppendSearch" placeholder="Search notes..." class="notes-title-input" oninput="_searchAppendTarget(this.value)">
<div id="saveToNoteAppendResults" class="save-to-note-results"></div>
</div>
<div class="form-group">
<label>Preview</label>
<div id="saveToNotePreview" class="save-to-note-preview msg-text"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-small" onclick="closeSaveToNoteModal()">Cancel</button>
<button class="btn-small btn-primary" id="saveToNoteConfirmBtn" onclick="_confirmSaveToNote()">Create Note</button>
</div>
</div>
</div>
<div class="cmd-palette-overlay" id="cmdPalette">
<div class="cmd-palette">
<div class="cmd-input-wrap">
@@ -1031,6 +1096,7 @@
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
<script src="js/note-graph.js?v=%%APP_VERSION%%"></script>
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
<script src="js/tools-toggle.js?v=%%APP_VERSION%%"></script>
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>

View File

@@ -489,10 +489,12 @@ const API = {
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags) {
createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
if (sourceChannelId) body.source_channel_id = sourceChannelId;
if (sourceMessageId) body.source_message_id = sourceMessageId;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
@@ -500,7 +502,10 @@ const API = {
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
searchNoteTitles(query, limit = 10) { return this._get(`/api/v1/notes/search-titles?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); },
getNoteGraph() { return this._get('/api/v1/notes/graph'); },
// ── Attachments ─────────────────────────

490
src/js/note-graph.js Normal file
View File

@@ -0,0 +1,490 @@
// ==========================================
// Chat Switchboard — Note Graph (Canvas)
// ==========================================
// Force-directed graph visualization of note
// connections. Lazy-loaded — initializes only
// when the graph view is opened.
// ==========================================
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 = {};
}

View File

@@ -7,6 +7,7 @@ var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
var _noteEditor = null; // CM6 noteEditor instance
// ── Notes ─────────────────────────────────────
@@ -172,7 +173,13 @@ async function loadNoteFolders() {
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
const graphView = document.getElementById('notesGraphView');
if (graphView) graphView.style.display = 'none';
_editingNoteId = null;
_destroyNoteEditor();
// Reset list to loading state to prevent stale content flash
const list = document.getElementById('notesList');
if (list) list.innerHTML = '<div class="notes-loading">Loading…</div>';
}
var _currentNote = null; // cached note for read mode
@@ -211,14 +218,83 @@ function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
document.getElementById('noteEditorContent').value = note.content || '';
_setNoteEditorContent(note.content || '');
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
document.getElementById('noteEditorContent').value = '';
_setNoteEditorContent('');
}
/** Get content from CM6 editor or fallback textarea */
function _getNoteEditorContent() {
if (_noteEditor) return _noteEditor.getValue();
const ta = document.querySelector('#noteEditorContentContainer textarea');
return ta ? ta.value : '';
}
/** Set content in CM6 editor or fallback textarea */
function _setNoteEditorContent(text) {
if (_noteEditor) {
_noteEditor.setValue(text);
return;
}
// Lazy-init CM6 note editor
const container = document.getElementById('noteEditorContentContainer');
if (!container) return;
if (window.CM?.noteEditor) {
container.innerHTML = ''; // clear any previous
_noteEditor = CM.noteEditor(container, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: null, // could wire auto-save later
onLink: (title) => {
_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 {
// Fallback: plain textarea
if (!container.querySelector('textarea')) {
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown..."></textarea>';
}
const ta = container.querySelector('textarea');
if (ta) ta.value = text;
}
}
/** Navigate to a note by title (from wikilink click) */
async function _navigateToLinkedNote(title) {
try {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
await openNoteEditor(match.id);
} else {
// Offer to create
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
await openNoteEditor(created.id);
}
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
/** Destroy CM6 editor on panel close */
function _destroyNoteEditor() {
if (_noteEditor) {
_noteEditor.destroy();
_noteEditor = null;
}
}
function _showNoteReadMode() {
@@ -239,6 +315,9 @@ function _showNoteReadMode() {
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
// Render [[wikilinks]] as clickable chips in read mode
_renderWikilinksInReadMode(noteReadEl);
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
@@ -247,6 +326,12 @@ function _showNoteReadMode() {
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Load backlinks
if (_editingNoteId) _loadBacklinks(_editingNoteId);
// Show daily note navigation if applicable
_updateDailyNav(_currentNote);
}
function _showNoteEditMode() {
@@ -259,9 +344,9 @@ function _showNoteEditMode() {
}
function _showNotePreview() {
// Live preview from textarea without saving
// Live preview from editor without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const content = _getNoteEditorContent();
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -273,6 +358,7 @@ function _showNotePreview() {
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
@@ -284,7 +370,7 @@ function _showNotePreview() {
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const content = _getNoteEditorContent();
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -305,6 +391,8 @@ async function saveNote() {
UI.toast('Note created', 'success');
_showNoteReadMode();
}
// Invalidate graph cache
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -314,17 +402,356 @@ async function deleteNote() {
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Backlinks ───────────────────
async function _loadBacklinks(noteId) {
const container = document.getElementById('noteBacklinks');
const listEl = document.getElementById('noteBacklinksList');
const countEl = document.getElementById('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" onclick="openNoteEditor('${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';
}
}
function toggleBacklinks() {
const list = document.getElementById('noteBacklinksList');
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
}
// ── Wikilink Read-Mode Rendering ──
function _renderWikilinksInReadMode(containerEl) {
if (!containerEl) return;
const html = containerEl.innerHTML;
let transclusionId = 0;
// Replace [[Title|display]] and [[Title]] with clickable spans
// For ![[transclusion]], render a placeholder that gets filled async
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" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div>
<div class="transclusion-content"><span class="text-muted">Loading…</span></div>
</div>`;
}
return `<span class="wikilink-chip" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
}
);
containerEl.innerHTML = rendered;
// Async: resolve and embed transclusion content
_resolveTransclusions(containerEl);
}
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
async function _resolveTransclusions(containerEl) {
const embeds = containerEl.querySelectorAll('.transclusion-embed');
if (!embeds.length) return;
// Cache to avoid duplicate fetches in same note
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;
}
// Render embedded content (no recursive transclusion — strip ![[]] )
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 function openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily — ${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 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 openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
}
/** Detect if current note is a daily note */
function _isDailyNote(note) {
return note && /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
}
/** Show prev/next nav for daily notes */
function _updateDailyNav(note) {
let nav = document.getElementById('noteDailyNav');
if (!_isDailyNote(note)) {
if (nav) nav.style.display = 'none';
return;
}
// Ensure nav element exists
if (!nav) {
const toolbar = document.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"> Prev</button>
<span class="note-daily-date" id="dailyDateLabel"></span>
<button class="btn-small" id="dailyNextBtn" title="Next day">Next </button>
`;
toolbar.after(nav);
document.getElementById('dailyPrevBtn')?.addEventListener('click', () => _navigateDaily(-1));
document.getElementById('dailyNextBtn')?.addEventListener('click', () => _navigateDaily(1));
}
nav.style.display = '';
const dateStr = note.title.replace('Daily — ', '');
document.getElementById('dailyDateLabel').textContent = dateStr;
// Disable "Next" if it's today or future
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dailyNextBtn').disabled = dateStr >= today;
}
/** Navigate to prev/next daily note */
async function _navigateDaily(offset) {
if (!_currentNote || !_isDailyNote(_currentNote)) return;
const dateStr = _currentNote.title.replace('Daily — ', '');
const d = new Date(dateStr + 'T12:00:00'); // noon to avoid DST edge
d.setDate(d.getDate() + offset);
const newDateStr = d.toISOString().slice(0, 10);
const title = `Daily — ${newDateStr}`;
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
// Create the daily note for that day
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, '/daily/', ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
// ── Save-to-Note (from chat messages) ──
async function saveMessageToNote(msgIndex) {
const chat = App.chats?.find(c => c.id === App.currentChatId);
const msg = chat?.messages?.[msgIndex];
if (!msg) return;
// Check for text selection within the message element
const sel = window.getSelection();
let content = msg.content;
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;
}
// Extract title from first line
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
// Show save-to-note modal
_showSaveToNoteModal({
content,
sourceChannelId: App.currentChatId || '',
sourceMessageId: msg.id || '',
defaultTitle,
});
}
/** Modal for save-to-note with create/append options */
function _showSaveToNoteModal(opts) {
// Remove existing modal if any
document.getElementById('saveToNoteModal')?.remove();
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content 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 ? '…' : ''}</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;
// Toggle create vs append
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 = '';
});
});
// Append search with debounce
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);
});
// Cancel
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
// Confirm
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'); }
});
// Focus title
document.getElementById('saveNoteTitle')?.focus();
document.getElementById('saveNoteTitle')?.select();
}
// ── Notes Listeners (extracted from initListeners) ──
function _initNotesListeners() {
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
document.getElementById('notesGraphBtn')?.addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
document.getElementById('notesTodayBtn')?.addEventListener('click', openDailyNote);
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
@@ -334,7 +761,6 @@ function _initNotesListeners() {
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }

View File

@@ -418,6 +418,7 @@ const UI = {
${editBtn}
${regenBtn}
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
<button class="msg-action-btn" onclick="saveMessageToNote(${index})" title="Save to note">Note</button>
</div>
</div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}