-
ā .gitignoreUnknown, 37 Bā¬
-
ā README.mdUnknown, 2.94 kBā¬
-
š admin_panel.phpWeb, 1.54 kBā¬
-
š api.jsWeb, 22.21 kBā¬
-
š api.phpWeb, 9.62 kBā¬
-
š app.jsWeb, 61.42 kBā¬
-
š attachments.jsWeb, 7.30 kBā¬
-
š base.cssWeb, 2.23 kBā¬
-
š choices-editor.jsWeb, 28.11 kBā¬
-
š choices.cssWeb, 1.14 kBā¬
-
š content.jsWeb, 31.08 kBā¬
-
š context.jsWeb, 9.26 kBā¬
-
š conversation-index.jsWeb, 10.74 kBā¬
-
š conversation.cssWeb, 16.33 kBā¬
-
š conversation.jsWeb, 1.11 kBā¬
-
š dialogs.cssWeb, 11.43 kBā¬
-
š diction.jsWeb, 948 Bā¬
-
š document-index.jsWeb, 1.04 kBā¬
-
š document-model.jsWeb, 12.88 kBā¬
-
š document.cssWeb, 6.74 kBā¬
-
š document.jsWeb, 44.96 kBā¬
-
š dynamic.phpWeb, 533 Bā¬
-
š· favicon-lg.pngImage, 9.11 kBā¬
-
š· favicon.pngImage, 3.12 kBā¬
-
š index-lists.cssWeb, 4.77 kBā¬
-
š index.phpWeb, 49.72 kBā¬
-
š indexjs.jsWeb, 377 Bā¬
-
š instant-answer.cssWeb, 5.17 kBā¬
-
š layout.cssWeb, 11.37 kBā¬
-
š login.phpWeb, 997 Bā¬
-
ā manifest.webmanifestUnknown, 262 Bā¬
-
š memory.jsWeb, 407 Bā¬
-
š mermaid.cssWeb, 6.16 kBā¬
-
š model-presets.jsonWeb, 5.51 kBā¬
-
š models.jsonWeb, 8.05 kBā¬
-
š notes.cssWeb, 11.97 kBā¬
-
š overlays.cssWeb, 13.97 kBā¬
-
ā ļø php.iniOS File, 424 Bā¬
-
š rankings-view.jsWeb, 5.67 kBā¬
-
š rankings.cssWeb, 3.35 kBā¬
-
š scratchpad-conversation.jsWeb, 4.87 kBā¬
-
š scratchpad-index.jsWeb, 15.55 kBā¬
-
š scratchpad-model.jsWeb, 13.29 kBā¬
-
š scratchpad.cssWeb, 3.12 kBā¬
-
š settings.cssWeb, 4.65 kBā¬
-
š shared-view.cssWeb, 3.48 kBā¬
-
š shared.cssWeb, 16.26 kBā¬
-
š shared.jsWeb, 14.63 kBā¬
-
š shared.phpWeb, 3.28 kBā¬
-
š sticky-notes-editor.jsWeb, 64.84 kBā¬
-
š storage.jsWeb, 36.73 kBā¬
-
š style.cssWeb, 12.06 kBā¬
-
š sw.jsWeb, 1.69 kBā¬
-
š sync.jsWeb, 19.02 kBā¬
-
š tools-index.jsWeb, 15.00 kBā¬
-
š users.jsWeb, 49.86 kBā¬
-
š utility-models.jsonWeb, 1.04 kBā¬
-
š voice-models.jsonWeb, 4.81 kBā¬
import { getEl, strip_tags, contains_focus } from './util/dom-utils.js';
import { create_reorder_drag } from './util/drag-reorder.js';
import { escapeHtml, formatThinkingLineHtml, finding_card_html } from './util/format-utils.js';
import { cost_block_html, formatCostSummary } from './util/conversation-format-utils.js';
import { customConfirm } from './util/confirm-dialog.js';
import { replayInteractiveRequests } from './util/interactive-tools.js';
import { showOverlay, hideOverlay } from './util/overlay-utils.js';
import { append_following_bottom, scroller_of, content_of } from './util/scrollbars.js';
import { apply_inline_format, sync_inline_format_states } from './util/inline-format.js';
import { speak_text, stop_playback } from './util/read-aloud.js';
import Api from './api.js';
import ThinkingPanelBase from './util/thinking-panel-base.js';
/**
* Class StickyNotesEditor
* Note board for STICKY_NOTES conversations. Each note carries a title,
* formatted text, optional checklist items, and tags from the conversation's
* tag vocabulary; notes are archived rather than deleted. The board is
* filtered by tag and search term and sorted by title or board order. Note
* text supports basic inline formatting through the shared inline-format
* toolbar, and each note can be read aloud or edited in a full-screen
* overlay with its own prompt box scoped to the open note. Checking a note's
* header checkbox scopes the board prompt to the checked notes.
*
* Checklist items reuse the choices editor's interaction model: editable
* text with insert buttons between items, grip dragging to reorder, Enter to
* insert, and Backspace on an empty item to remove it.
*
* Like the choices editor, it owns a ThinkingPanelBase activity
* drawer and sends prompts to the stickynotes endpoint, where the model reads
* and edits the notes through the notes tools. The tag vocabulary is managed
* on the conversation's Tags tab (render_tags / attach_tags).
*/
class StickyNotesEditor {
static TEXT_MAX = 5000;
static TITLE_MAX = 100;
static ITEMS_MAX = 20;
static TAG_WORDS_MAX = 3;
#notes = [];
#tags = [];
#filter_tag = null;
#show_archived = false;
#search_term = '';
#sort = 'order';
#selected = new Set();
#tag_menu_open = false;
#note_tag_menu_open = null;
#root = null;
#tags_root = null;
#overlay_body = null;
#overlay_guid = null;
#edit_scope_guids = null;
#on_change = null;
#save_timer = null;
#focus_text = null;
#reading_guid = null;
#api = new Api();
#panel = new ThinkingPanelBase({
on_highlight: (guid) => this.#set_note_highlight(guid),
replay: (guid) => {
if (this.#app) {
replayInteractiveRequests(getEl('div-notes-thinking-content'), guid, this.#app);
}
}
}, {
drawer_selector: '#div-notes-thinking',
content_id: 'div-notes-thinking-content',
clear_id: 'btn-notes-thinking-clear',
prev_id: 'btn-notes-thinking-prev',
next_id: 'btn-notes-thinking-next'
});
#panel_bound = false;
#app = null;
#content_guid = null;
#asking_guids = new Set();
#highlighted_note = null;
#drag = create_reorder_drag({
draggable_of: (e) => {
const grip = e.target.closest('.span-document-gripper');
if (!grip) return null;
const item_el = grip.closest('.div-notes-item');
if (item_el) return item_el;
return grip.closest('.div-notes-note-grip') ? grip.closest('.div-notes-note') : null;
},
state_of: (el) => el.classList.contains('div-notes-item')
? { kind: 'item', note_guid: el.dataset.note }
: { kind: 'note' },
target_of: (x, y, drag) => {
const over = document.elementFromPoint(x, y) || null;
if (drag.kind === 'note') return over?.closest('.div-notes-insert-note') || null;
const hit = over?.closest('.div-notes-items .div-document-insert, .div-notes-items-done') || null;
return hit?.dataset.note === drag.note_guid ? hit : null;
},
chrome_of: (el) => el.classList.contains('div-notes-item')
? [[el.closest('.div-notes-items'), 'dragging-items']]
: [[this.#root?.querySelector('.div-notes-board'), 'dragging-notes']],
scroll_el_of: (el) => el,
commit: (drag) => drag.kind === 'note' ? this.#move_note(drag) : this.#commit_item_drop(drag)
});
#doc_close_bound = false;
/**
* Returns whether the editor currently contains the keyboard focus.
* @returns {boolean} True when a child of the board, tags manager, or overlay has focus.
*/
contains_focus() {
return contains_focus(this.#root, this.#tags_root, getEl('id-notes-overlay'));
}
/**
* Returns whether the stored notes and tags match the editor's state.
* @param {Array} notes - The stored notes.
* @param {Array} tags - The stored tag vocabulary.
* @returns {boolean} True when both hold the same content.
*/
matches_content(notes, tags) {
return this.matches_notes(notes) && this.matches_tags(tags);
}
/**
* Returns whether the stored notes match the editor's state.
* @param {Array} notes - The stored notes.
* @returns {boolean} True when both hold the same content.
*/
matches_notes(notes) {
return JSON.stringify(Array.isArray(notes) ? notes : []) === JSON.stringify(this.#serialize());
}
/**
* Returns whether the stored tag vocabulary matches the editor's state.
* @param {Array} tags - The stored tags.
* @returns {boolean} True when both hold the same content.
*/
matches_tags(tags) {
return JSON.stringify(Array.isArray(tags) ? tags : []) === JSON.stringify(this.#tags);
}
/**
* Renders the note board: the sticky control toolbar (new note, archive
* toggle, search, sort, tag filter), the sticky format toolbar, and the
* visible note cards.
* @param {Array} [notes=[]] - The stored notes.
* @param {Array} [tags=[]] - The stored tag vocabulary.
* @returns {string} The board HTML.
*/
render(notes = [], tags = []) {
this.#notes = (Array.isArray(notes) ? notes : []).map(note => this.#normalize_note(note));
this.#tags = this.#clean_tags(tags);
return `
<div class="div-notes-editor">
<div class="div-notes-sticky">
${this.#toolbar_html()}
${this.#format_toolbar_html()}
</div>
<div class="div-notes-board">${this.#cards_html()}</div>
</div>
`;
}
/**
* Renders the tag manager for the conversation's Tags tab: the add-tag
* row and the vocabulary with per-tag note counts.
* @param {Array} [tags=[]] - The stored tag vocabulary.
* @param {Array|null} [notes=null] - The stored notes, for the counts.
* @returns {string} The tag manager HTML.
*/
render_tags(tags = [], notes = null) {
this.#tags = this.#clean_tags(tags);
if (Array.isArray(notes)) {
this.#notes = notes.map(note => this.#normalize_note(note));
}
return this.#tags_manager_html();
}
/**
* Wires the delegated editing events and the change callback. Listeners
* bind once per container; the board root is re-resolved on every call
* since rebuilds replace it.
* @param {HTMLElement} container - The container the board renders into.
* @param {Function|null} [on_change=null] - Called with (notes, tags) after edits.
*/
attach(container, on_change = null) {
this.#on_change = on_change;
if (container && container !== this.#root) {
this.#root = container;
this.#bind_surface(container);
this.#bind_document_close();
}
if (!this.#panel_bound) {
const notes_container = getEl('div-notes-container');
if (notes_container) {
this.#panel.bind(notes_container);
this.#panel_bound = true;
}
this.#overlay_body = getEl('div-notes-overlay-body');
if (this.#overlay_body) {
this.#bind_surface(this.#overlay_body);
this.#bind_overlay(getEl('id-notes-overlay'));
}
window.addEventListener('beforeunload', () => this.#save_now());
}
}
/**
* Wires the tag manager surface for the Tags tab.
* @param {HTMLElement} container - The container the tag manager renders into.
* @param {Function|null} [on_change=null] - Called with (notes, tags) after edits.
*/
attach_tags(container, on_change = null) {
this.#on_change = on_change;
if (container && container !== this.#tags_root) {
this.#tags_root = container;
this.#bind_surface(container);
}
}
/**
* Switches the thinking panel to the given conversation, resets the board
* filter and selection, and remembers the app instance for the panel's
* storage and interactive callbacks.
* @param {string} guid - The conversation guid.
* @param {object|null} [app=null] - The application instance.
* @returns {boolean} Whether the panel switched.
*/
set_active_guid(guid, app = null) {
if (app) this.#app = app;
this.#panel.set_app(app);
if (guid !== this.#content_guid) {
if (this.#save_timer) this.#save_now();
this.#filter_tag = null;
this.#show_archived = false;
this.#search_term = '';
this.#sort = 'order';
this.#selected.clear();
this.#tag_menu_open = false;
this.#note_tag_menu_open = null;
this.close_overlay();
}
this.#content_guid = guid;
return this.#panel.set_active_guid(guid);
}
/**
* Shows or hides the thinking drawer for the current surface.
* @param {boolean} visible - Whether the drawer is available.
*/
set_drawer_visible(visible) {
this.#panel.set_visible(visible);
}
//region Request Lifecycle
/**
* Sends the prompt with the board's structured content to the stickynotes
* endpoint, streaming reasoning into the thinking panel and appending the
* final text output at the end. The panel slides open for the request.
* The payload is scoped when a scope note is given (the overlay prompt)
* or when notes are checked in their headers: only the scoped notes are
* sent and a scope instruction tells the model to stay within them.
* @param {string} prompt - The user's prompt.
* @param {object} [options=null] - Optional request options.
* @param {string} [options.model_key=''] - The conversation's selected model key.
* @param {object} [options.meta_context=null] - Optional meta context (e.g. { search: true }).
* @param {App|null} [options.app=null] - The application instance, required for interactive request handling.
* @param {string} [options.guid=''] - The conversation guid, used for interactive request tracking.
* @param {string} [options.scope_note_guid=''] - Scopes the request to one note (the overlay prompt).
* @param {Function|null} [options.on_done=null] - Called after the final output is appended.
* @param {Function|null} [options.on_error=null] - Called when the request failed.
* @returns {boolean} False when a request for this conversation is already in flight.
*/
ask(prompt, options = {}) {
const target_guid = options.guid || '';
if (this.#asking_guids.has(target_guid)) return false;
this.#asking_guids.add(target_guid);
this.#panel.begin_run(target_guid);
let scope_instruction = '';
let edit_scope = null;
if (options.scope_note_guid) {
edit_scope = new Set([options.scope_note_guid]);
scope_instruction = `You may read all notes on the board for context, but you may only edit the note with guid ${options.scope_note_guid}. Do not modify, archive, or delete any other note in any way, and do not create new notes.`;
} else if (this.#selected.size > 0) {
edit_scope = new Set(this.#notes.filter(note => this.#selected.has(note.guid)).map(note => note.guid));
const guids = [...edit_scope].join(', ');
scope_instruction = edit_scope.size === 1
? `You may read all notes on the board for context, but you may only edit the note with guid ${guids}. Do not modify, archive, or delete any other note in any way, and do not create new notes.`
: `You may read all notes on the board for context, but you may only edit the notes with guids ${guids}. Do not modify, archive, or delete any other note in any way, and do not create new notes.`;
}
this.#edit_scope_guids = edit_scope;
// the app's shared api instance keeps the request visible to the
// progress UI, the stop button and the loading indicators
const api = options.app?.api || this.#api;
api.post(
{
prompt: prompt,
notes: this.#serialize(),
tags: this.get_tags(),
scope_instruction: scope_instruction,
model_key: options.model_key || '',
thinking: options.thinking || '',
meta_context: options.meta_context || {},
local_thinking: true
},
(response) => this.#complete_run(target_guid, response, options),
(error) => this.#fail_run(target_guid, error, options),
'stickynotes',
(text) => this.#stream_line(target_guid, text, options),
(bytes) => options.app?.response_pipeline?.update_progress(bytes, target_guid),
target_guid
);
return true;
}
/**
* Handles one streamed thinking line through the shared panel lifecycle,
* mirroring the line to the open overlay's thinking column when the run
* was started from there.
* @param {string} guid - The conversation guid the run belongs to.
* @param {string} text - The raw streamed line.
* @param {object} options - The ask options.
*/
#stream_line(guid, text, options) {
this.#panel.stream_line(guid, text, options.app);
options.on_stream?.(text);
}
/**
* Finishes a run on success: ends the streaming block and adds the
* findings or the text output plus the cost summary through the shared
* panel lifecycle, then schedules the conversation title update.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Object} response - The endpoint response.
* @param {object} options - The ask options.
*/
#complete_run(guid, response, options) {
this.#asking_guids.delete(guid);
this.#edit_scope_guids = null;
const ok = this.#panel.finish_run(guid, response);
if (!ok) {
options.on_error?.();
return;
}
void options.app?.response_pipeline?.schedule_sticky_title_update(guid);
options.on_done?.(response);
}
/**
* Finishes a run after a transport failure: ends the streaming block,
* adds the error output, persists the panel log.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Object} error - The transport error.
* @param {object} options - The ask options.
*/
#fail_run(guid, error, options) {
this.#asking_guids.delete(guid);
this.#edit_scope_guids = null;
this.#panel.fail_run(guid, error, 'Sticky notes request failed.');
options.on_error?.();
}
//endregion
//region Tool Edits
/**
* Applies a structured edit_notes edit to the board. Note and item texts
* arrive as plain text and are converted to the editor's inline html.
* Sizes are enforced: text at most 5000 characters, titles at most 100,
* notes at most 20 checklist items, tags at most 3 words and always taken
* from the vocabulary. Rerenders and saves after the edit.
* @param {Object} edit - The edit { action, guid, item_guid, checked,
* archived, note, text, tags }.
* @returns {Object|null} { guid } of the affected note, or null when the edit cannot apply.
*/
apply_edit(edit) {
const action = String(edit?.action || '');
const result = this.#apply_action(action, edit);
if (result) {
this.#commit();
}
return result;
}
/**
* Runs one edit action against the board state without saving. While an
* overlay-scoped request is in flight, edits may only touch the open
* note: no new notes, no vocabulary removal, no other notes.
* @param {string} action - The edit action.
* @param {Object} edit - The edit payload.
* @returns {Object|null} { guid } of the affected note, or null when the edit cannot apply.
*/
#apply_action(action, edit) {
const scoped = this.#edit_scope_guids !== null;
if (scoped && (action === 'add_note' || action === 'remove_tag')) {
return null;
}
const note_of = (guid) => this.#notes.find(note => note.guid === guid) || null;
if (action === 'add_note') {
const title = this.#plain_text(edit?.note?.title);
const text = this.#plain_text(edit?.note?.text);
if (text.length > StickyNotesEditor.TEXT_MAX || title.length > StickyNotesEditor.TITLE_MAX) return null;
const note = this.#new_note();
note.title = title.slice(0, StickyNotesEditor.TITLE_MAX);
note.text = this.#plain_to_html(text);
note.tags = this.#valid_tags(edit?.note?.tags);
this.#notes.push(note);
return { guid: note.guid };
}
if (action === 'add_tag') {
const tag = this.#plain_text(Array.isArray(edit?.tags) ? edit.tags[0] : '');
if (!tag || !this.#tag_words_ok(tag) || this.#tags.includes(tag)) return null;
this.#tags.push(tag);
return { guid: (this.#notes[0] || {}).guid || null };
}
if (action === 'remove_tag') {
const tag = this.#plain_text(Array.isArray(edit?.tags) ? edit.tags[0] : '');
if (!tag || !this.#tags.includes(tag)) return null;
this.#tags = this.#tags.filter(existing => existing !== tag);
this.#notes.forEach(note => {
note.tags = note.tags.filter(existing => existing !== tag);
});
if (this.#filter_tag === tag) this.#filter_tag = null;
return { guid: (this.#notes[0] || {}).guid || null };
}
const note = note_of(String(edit?.guid || ''));
if (!note) return null;
if (scoped && !this.#edit_scope_guids.has(note.guid)) {
return null;
}
if (action === 'replace_note') {
const title = edit?.note?.title !== undefined ? this.#plain_text(edit.note.title) : null;
const text = edit?.note?.text !== undefined ? this.#plain_text(edit.note.text) : null;
if (title !== null && title.length > StickyNotesEditor.TITLE_MAX) return null;
if (text !== null && text.length > StickyNotesEditor.TEXT_MAX) return null;
if (title !== null) note.title = title;
if (text !== null) note.text = this.#plain_to_html(text);
this.#touch(note);
return { guid: note.guid };
}
if (action === 'archive_note') {
note.archived = !!edit?.archived;
this.#touch(note);
return { guid: note.guid };
}
if (action === 'set_tags') {
note.tags = this.#valid_tags(edit?.tags);
this.#touch(note);
return { guid: note.guid };
}
if (action === 'add_item') {
if (note.items.length >= StickyNotesEditor.ITEMS_MAX) return null;
const text = this.#plain_text(edit?.text);
if (!text) return null;
note.items.push({ guid: crypto.randomUUID(), text: text, checked: false });
this.#touch(note);
return { guid: note.guid };
}
const item = note.items.find(entry => entry.guid === String(edit?.item_guid || ''));
if (!item) return null;
if (action === 'toggle_item') {
item.checked = !!edit?.checked;
this.#touch(note);
return { guid: note.guid };
}
if (action === 'remove_item') {
note.items = note.items.filter(entry => entry.guid !== item.guid);
this.#touch(note);
return { guid: note.guid };
}
return null;
}
/**
* Returns the current notes.
* @returns {Array<Object>} The note array.
*/
get_content() {
return this.#serialize();
}
/**
* Returns the tag vocabulary.
* @returns {Array<string>} The tags.
*/
get_tags() {
return [...this.#tags];
}
//endregion
//region Rendering
/**
* Builds the sticky control toolbar: note actions on the left (new note,
* archive toggle for the selection, delete) and the search, sort, and
* tag filter menu on the right. Archived notes are toggled by the
* board's Show Archives button instead of a toolbar control.
* @returns {string} The toolbar HTML.
*/
#toolbar_html() {
const tag_items = [''].concat(this.#tags).map(tag => {
const selected = (this.#filter_tag || '') === tag;
const label = tag === '' ? 'All tags' : tag;
return `
<button type="button" class="${selected ? 'selected' : ''}" data-action="filter-tag" data-tag="${escapeHtml(tag)}">${selected ? '✓ ' : ''}${escapeHtml(label)}</button>
`;
}).join('');
const count = this.#selected.size;
return `
<div class="div-notes-toolbar">
<div class="div-notes-toolbar-side">
<button type="button" class="btn-compact" data-action="add-note">New Note</button>
<button type="button" class="btn-compact" data-action="archive-selection" ${count === 0 ? 'disabled' : ''}>Archive${count > 0 ? ` (${count})` : ''}</button>
<button type="button" class="btn-compact" data-action="delete-selection" ${count === 0 ? 'disabled' : ''}>Delete${count > 0 ? ` (${count})` : ''}</button>
</div>
<div class="div-notes-toolbar-side">
<input type="text" class="div-notes-search-input prompt-style-input" placeholder="Search notes" value="${escapeHtml(this.#search_term)}"/>
<select class="div-notes-sort prompt-style-input" title="Sort">
<option value="order"${this.#sort === 'order' ? ' selected' : ''}>Sort by...</option>
<option value="title"${this.#sort === 'title' ? ' selected' : ''}>Title AāZ</option>
<option value="title-desc"${this.#sort === 'title-desc' ? ' selected' : ''}>Title ZāA</option>
<option value="newest"${this.#sort === 'newest' ? ' selected' : ''}>Newest</option>
<option value="oldest"${this.#sort === 'oldest' ? ' selected' : ''}>Oldest</option>
<option value="updated"${this.#sort === 'updated' ? ' selected' : ''}>Updated</option>
</select>
<div class="div-notes-tag-menu">
<button type="button" class="as-icon div-notes-tag-button ${this.#filter_tag ? 'selected' : ''}" data-action="toggle-tag-menu" title="Filter by tag">🏷</button>
<div class="div-notes-tag-dropdown ${this.#tag_menu_open ? 'open' : ''}">${tag_items}</div>
</div>
</div>
</div>
`;
}
/**
* Builds the visible note cards: the unarchived notes, then ā when
* archives exist or are being shown ā a full-width subdued button that
* shows or hides the archived notes after it. Both groups are filtered
* by the selected tag and the search term, then sorted. Every
* unarchived card carries drop targets above and below it for drag
* reordering.
* @returns {string} The cards HTML.
*/
#cards_html() {
const visible = this.#visible_notes(false);
const archived = this.#show_archived ? this.#visible_notes(true) : [];
if (visible.length === 0 && archived.length === 0 && !this.#has_archived()) {
return '<p class="div-notes-empty">No notes yet.</p>';
}
let html = visible.map((note, index) => this.#note_html(note, false, visible[index + 1]?.guid || '')).join('');
if (this.#show_archived || this.#has_archived()) {
html += `<button type="button" class="div-notes-archive-toggle" data-action="toggle-archived">${this.#show_archived ? 'Hide Archives' : 'Show Archives'}</button>`;
}
if (archived.length > 0) {
html += archived.map(note => this.#note_html(note)).join('');
} else if (this.#show_archived) {
html += '<p class="div-notes-empty">No archived notes.</p>';
}
return html;
}
/**
* Builds one invisible drop target for note reordering, absolutely
* positioned against the card it belongs to so it never disturbs the
* layout. Dropping places the dragged note before the given note (an
* empty guid appends after the last visible note).
* @param {string} before_guid - The guid of the note that follows, or empty for the end.
* @param {string} edge - 'above' or 'below', the card edge to attach to.
* @returns {string} The button HTML.
*/
#note_insert_html(before_guid, edge) {
return `<button type="button" class="div-document-insert div-notes-insert-note ${edge}" data-before="${escapeHtml(before_guid)}" title="Insert note" tabindex="-1"></button>`;
}
/**
* Returns whether any note is archived.
* @returns {boolean} True when the archive holds at least one note.
*/
#has_archived() {
return this.#notes.some(note => note.archived);
}
/**
* Returns the archived or unarchived notes matching the tag filter and
* search term, in the selected sort order.
* @param {boolean} archived - Which group to return.
* @returns {Array<Object>} The visible notes.
*/
#visible_notes(archived) {
let list = this.#notes.filter(note => !!note.archived === archived)
.filter(note => !this.#filter_tag || note.tags.includes(this.#filter_tag));
const term = this.#search_term.trim().toLowerCase();
if (term) {
list = list.filter(note =>
note.title.toLowerCase().includes(term)
|| strip_tags(this.#sanitize_html(note.text)).toLowerCase().includes(term)
|| note.items.some(item => item.text.toLowerCase().includes(term)));
}
if (this.#sort === 'title') {
list = [...list].sort((a, b) => a.title.localeCompare(b.title));
} else if (this.#sort === 'title-desc') {
list = [...list].sort((a, b) => b.title.localeCompare(a.title));
} else if (this.#sort === 'newest') {
list = [...list].sort((a, b) => b.created - a.created);
} else if (this.#sort === 'oldest') {
list = [...list].sort((a, b) => a.created - b.created);
} else if (this.#sort === 'updated') {
list = [...list].sort((a, b) => b.updated - a.updated);
}
return list;
}
/**
* Builds one note card: reorder grip, select checkbox, title, actions,
* formatted text, choices-style checklist items, and tag pills. Visible
* board cards carry absolutely positioned drop targets above and below
* the card for drag reordering. The bare variant (overlay) omits the
* header ā the overlay provides title, read-aloud, and close.
* @param {Object} note - The note.
* @param {boolean} [bare=false] - Whether to omit the note header.
* @param {string|null} [after_guid=null] - Guid of the note that follows
* in board order (empty for the board end); null renders no targets.
* @returns {string} The card HTML.
*/
#note_html(note, bare = false, after_guid = null) {
const pills = note.tags.map(tag => `
<span class="span-option-chip div-notes-tag-pill">${escapeHtml(tag)}</span>
`).join('');
const tag_items = this.#tags.map(tag => `
<button type="button" class="${note.tags.includes(tag) ? 'selected' : ''}" data-action="assign-note-tag" data-note="${note.guid}" data-tag="${escapeHtml(tag)}">${note.tags.includes(tag) ? '✓ ' : ''}${escapeHtml(tag)}</button>
`).join('');
const head = bare ? '' : `
<div class="div-notes-note-head">
<span class="span-document-gripper div-notes-note-grip" title="Reorder">‖</span>
<input type="checkbox" class="div-notes-select" data-note="${note.guid}" title="Select" ${this.#selected.has(note.guid) ? 'checked' : ''}>
<div class="div-notes-note-title" contenteditable="true" data-placeholder="Title" data-field="title" data-note="${note.guid}">${escapeHtml(note.title)}</div>
<div class="div-notes-note-actions">
<button type="button" class="as-icon btn-note-read" data-action="read-note" data-note="${note.guid}" title="Read aloud">▶</button>
<button type="button" class="as-icon" data-action="open-note" data-note="${note.guid}" title="Expand">🔍</button>
</div>
</div>
`;
return `
<div class="div-notes-note ${note.archived ? 'archived' : ''} ${bare ? 'bare' : ''}" data-note="${note.guid}">
${head}
<div class="div-notes-note-text" contenteditable="true" data-placeholder="My Note Content" data-field="text" data-note="${note.guid}">${this.#sanitize_html(note.text)}</div>
<div class="div-notes-items">${this.#items_html(note)}</div>
<div class="div-notes-note-tags">
${pills}
<div class="div-notes-tag-menu">
<button type="button" class="as-icon div-notes-tag-button" data-action="toggle-note-tag-menu" data-note="${note.guid}" title="Tag note">🏷</button>
<div class="div-notes-tag-dropdown div-notes-tag-dropdown-note ${this.#note_tag_menu_open === note.guid ? 'open' : ''}">${tag_items || '<span class="div-notes-tag-empty">No tags yet</span>'}</div>
</div>
</div>
${after_guid !== null ? this.#note_insert_html(note.guid, 'above') + this.#note_insert_html(after_guid, 'below') : ''}
</div>
`;
}
/**
* Builds one note's checklist: an insert button before and after every
* pending item, each item an editable text row with a checkbox and a
* gripper. Checked items move into a completed section below the list,
* which is also the drop zone that checks a dragged item off; while a
* drag is underway an empty section appears as its drop target. An empty
* note shows a full-width subdued button that starts the list.
* @param {Object} note - The note.
* @returns {string} The items HTML.
*/
#items_html(note) {
if (note.items.length === 0) {
return `<button type="button" class="div-notes-items-empty" data-action="insert-item" data-note="${note.guid}" data-position="0">Add Items</button>`;
}
let html = '';
let last_pending = -1;
note.items.forEach((item, index) => {
if (item.checked) return;
html += this.#insert_html(note, index);
html += this.#item_html(note, item);
last_pending = index;
});
// the trailing button stays available even when every item is
// completed, so a completed item can always be dragged back out
html += this.#insert_html(note, last_pending === -1 ? 0 : last_pending + 1);
const done = note.items.filter(item => item.checked);
html += `
<div class="div-notes-items-done ${done.length === 0 ? 'empty' : ''}" data-note="${note.guid}">
<span class="div-notes-items-done-label">Completed</span>
${done.map(item => this.#item_html(note, item)).join('')}
</div>
`;
return html;
}
/**
* Builds one checklist item row: editable text with a checkbox and a
* gripper. Checked items render dimmed.
* @param {Object} note - The note owning the item.
* @param {Object} item - The checklist item.
* @returns {string} The item row HTML.
*/
#item_html(note, item) {
return `
<div class="div-notes-item ${item.checked ? 'done' : ''}" data-item="${item.guid}" data-note="${note.guid}">
<input type="checkbox" data-item="${item.guid}" ${item.checked ? 'checked' : ''} title="Done">
<span class="div-notes-item-text" contenteditable="true" data-item="${item.guid}">${escapeHtml(item.text)}</span>
<div class="div-document-item-controls">
<span class="span-document-gripper" title="Reorder">‖</span>
</div>
</div>
`;
}
/**
* Builds one insert button for a note's item list, hidden when the list
* is at its item limit.
* @param {Object} note - The note.
* @param {number} position - The insertion index.
* @returns {string} The button HTML.
*/
#insert_html(note, position) {
if (note.items.length >= StickyNotesEditor.ITEMS_MAX) return '';
return `<button type="button" class="div-document-insert" data-action="insert-item" data-note="${note.guid}" data-position="${position}" title="Insert item"></button>`;
}
/**
* Builds the shared inline-format toolbar for note text.
* @returns {string} The toolbar HTML.
*/
#format_toolbar_html() {
return `
<div class="div-notes-format-toolbar">
<button type="button" class="as-icon" data-action="fmt-clear" title="Clear Formatting">🧹</button>
<button type="button" class="as-icon" data-action="fmt-bold" title="Bold"><b>B</b></button>
<button type="button" class="as-icon" data-action="fmt-italic" title="Italic"><i>I</i></button>
<button type="button" class="as-icon" data-action="fmt-underline" title="Underline"><u>U</u></button>
<button type="button" class="as-icon" data-action="fmt-strike" title="Strikethrough"><s>S</s></button>
<button type="button" class="as-icon" data-action="fmt-code" title="Inline Code"></></button>
<button type="button" class="as-icon" data-action="fmt-link" title="Insert Link">🔗</button>
</div>
`;
}
/**
* Builds the tag manager for the Tags tab: the add-tag row and one row
* per tag with its note count and removal.
* @returns {string} The tag manager HTML.
*/
#tags_manager_html() {
const rows = this.#tags.map(tag => {
const count = this.#notes.filter(note => note.tags.includes(tag)).length;
return `
<div class="div-tags-row">
<span class="div-tags-name">${escapeHtml(tag)}</span>
<span class="div-tags-count">${count} ${count === 1 ? 'note' : 'notes'}</span>
<button type="button" class="as-icon div-tags-remove" data-action="remove-tag" data-tag="${escapeHtml(tag)}" title="Remove tag">×</button>
</div>
`;
}).join('');
return `
<div class="div-tags-manager">
<div class="div-tags-add">
<input type="text" class="div-notes-tag-input prompt-style-input" placeholder="Add tag" maxlength="30"/>
<button type="button" class="btn-compact" data-action="add-tag">Add Tag</button>
</div>
<div class="div-tags-list">${rows || '<p class="div-notes-empty">No tags yet.</p>'}</div>
</div>
`;
}
//endregion
//region Overlay
/**
* Binds the static overlay chrome: the close button, Escape, the header
* read-aloud and thinking toggle, and the note-scoped prompt box.
* @param {HTMLElement|null} overlay - The overlay root element.
*/
#bind_overlay(overlay) {
if (!overlay) return;
const overlay_title = getEl('div-notes-overlay-title');
overlay_title?.addEventListener('input', () => {
if (this.#overlay_guid === null) return;
const note = this.#notes.find(entry => entry.guid === this.#overlay_guid);
if (note) {
note.title = overlay_title.textContent.trim();
this.#touch(note);
this.#debounce_save();
}
});
overlay_title?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
}
});
overlay_title?.addEventListener('focusout', () => {
this.#save_now();
});
getEl('btn-notes-overlay-close')?.addEventListener('click', () => this.close_overlay());
getEl('btn-notes-overlay-read')?.addEventListener('click', () => {
if (this.#overlay_guid !== null) this.#read_note(this.#overlay_guid);
});
getEl('btn-notes-overlay-thinking')?.addEventListener('click', () => this.#set_overlay_thinking());
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
this.close_overlay();
}
});
const input = getEl('id-notes-overlay-prompt-input');
input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.#send_overlay_prompt();
}
});
input?.addEventListener('input', () => this.#sync_overlay_send());
getEl('btn-notes-overlay-send')?.addEventListener('click', () => this.#send_overlay_prompt());
}
/**
* Syncs the overlay Send button: enabled only when the prompt has text,
* disabled while a request runs (mirrors the main prompt box).
*/
#sync_overlay_send() {
const input = getEl('id-notes-overlay-prompt-input');
const send_btn = getEl('btn-notes-overlay-send');
if (!input || !send_btn || this.#overlay_guid === null) return;
const running = this.#asking_guids.has(this.#content_guid || '');
send_btn.disabled = running || input.value.trim() === '';
send_btn.textContent = running ? '...' : 'Send';
}
/**
* Toggles the overlay's thinking column, mirroring the code analysis
* overlay's side column toggle.
* @param {boolean} [visible] - Force a state instead of toggling.
*/
#set_overlay_thinking(visible) {
const overlay = getEl('id-notes-overlay');
const btn = getEl('btn-notes-overlay-thinking');
if (!overlay || !btn) return;
const show = visible !== undefined ? visible : !overlay.classList.contains('column-open');
overlay.classList.toggle('column-open', show);
btn.textContent = show ? 'Hide Thinking' : 'Thinking';
btn.title = show ? 'Hide Thinking' : 'Open Thinking';
}
/**
* Sends the overlay prompt: the request is scoped to the note open in
* the overlay. Reasoning streams into the overlay's thinking panel.
*/
#send_overlay_prompt() {
const input = getEl('id-notes-overlay-prompt-input');
const prompt = (input?.value || '').trim();
if (!prompt || !this.#app || this.#overlay_guid === null) return;
const send_btn = getEl('btn-notes-overlay-send');
const state = getEl('span-notes-overlay-state');
const thinking = getEl('div-notes-overlay-thinking');
const restore = () => {
if (state) state.textContent = '';
if (input) input.value = '';
this.#sync_overlay_send();
};
if (thinking) thinking.innerHTML = '';
this.#set_overlay_thinking(true);
const asked = this.ask(prompt, {
app: this.#app,
guid: this.#content_guid || '',
scope_note_guid: this.#overlay_guid,
on_stream: (text) => {
if (!thinking || !text) return;
// the overlay column follows the stream like the panels do: the
// anchor breaks on user scroll-up and re-engages at the bottom
append_following_bottom(scroller_of(thinking), () => thinking.insertAdjacentHTML('beforeend', formatThinkingLineHtml(text)));
},
on_done: (response) => {
restore();
if (!thinking || !response) return;
const items = Array.isArray(response.items) ? response.items : [];
if (items.length > 0) {
thinking.insertAdjacentHTML('beforeend', items.map(item => finding_card_html(item)).join(''));
} else {
const output = (response.explanation || response.value || 'No findings returned.');
thinking.insertAdjacentHTML('beforeend', '<div class="div-document-thinking-output">' + escapeHtml(String(output)) + '</div>');
}
thinking.insertAdjacentHTML('beforeend', cost_block_html(formatCostSummary(response)));
thinking.scrollTop = thinking.scrollHeight;
},
on_error: () => {
restore();
if (state) state.textContent = 'Request failed.';
}
});
if (asked === false) {
if (state) state.textContent = 'A request is already running.';
this.#sync_overlay_send();
return;
}
this.#sync_overlay_send();
if (state) state.textContent = 'Working...';
}
/**
* Opens the full-screen overlay editor for a note: the note's title in
* the header with read-aloud beside it, the note body without its header,
* and the prompt box at the bottom.
* @param {string} guid - The note guid.
*/
#open_overlay(guid) {
const overlay = getEl('id-notes-overlay');
if (!overlay || !this.#overlay_body || !this.#notes.some(note => note.guid === guid)) return;
this.#overlay_guid = guid;
const read_btn = getEl('btn-notes-overlay-read');
if (read_btn) read_btn.dataset.note = guid;
this.#render_overlay();
showOverlay(overlay, 'flex');
const state = getEl('span-notes-overlay-state');
if (state) state.textContent = '';
this.#overlay_body.querySelector('.div-notes-note-text')?.focus();
}
/**
* Rebuilds the overlay body for the open note (format toolbar and the
* bare note card) and syncs the header title.
*/
#render_overlay() {
const note = this.#notes.find(entry => entry.guid === this.#overlay_guid);
if (!this.#overlay_body) return;
content_of(this.#overlay_body).innerHTML = note
? this.#format_toolbar_html() + this.#note_html(note, true)
: '';
const title_el = getEl('div-notes-overlay-title');
if (title_el && document.activeElement !== title_el) {
title_el.textContent = note?.title || '';
}
}
/**
* Closes the full-screen overlay editor and refreshes the board.
*/
close_overlay() {
const overlay = getEl('id-notes-overlay');
if (!overlay || this.#overlay_guid === null) return;
this.#overlay_guid = null;
this.#save_now();
hideOverlay(overlay, () => {
if (this.#overlay_body) content_of(this.#overlay_body).innerHTML = '';
this.#rerender();
});
}
//endregion
//region Editing Surface
/**
* Binds the delegated editing event listeners to a surface: the board
* container, the overlay body, or the tag manager.
* @param {HTMLElement} container - The surface container.
*/
#bind_surface(container) {
container.addEventListener('mousedown', (e) => {
// keep the text selection alive when using the format toolbar
if (e.target.closest('.div-notes-format-toolbar')) e.preventDefault();
});
this.#drag.bind(container);
container.addEventListener('focusin', (e) => {
const text = e.target.closest('.div-notes-note-text');
if (text) {
this.#focus_text = text;
sync_inline_format_states(this.#toolbar_of(text), text);
}
});
container.addEventListener('focusout', (e) => {
if (container.contains(e.relatedTarget)) return;
this.#save_now();
});
container.addEventListener('input', (e) => {
const search = e.target.closest('.div-notes-search-input');
if (search) {
this.#search_term = search.value;
this.#refresh_board();
return;
}
const title = e.target.closest('.div-notes-note-title');
if (title) {
const note = this.#note_of_el(title);
if (note) {
note.title = title.textContent.trim();
this.#touch(note);
this.#debounce_save();
}
return;
}
const text = e.target.closest('.div-notes-note-text');
if (text) {
const note = this.#note_of_el(text);
if (note) {
note.text = text.innerHTML;
this.#touch(note);
sync_inline_format_states(this.#toolbar_of(text), text);
this.#debounce_save();
}
return;
}
const item = e.target.closest('.div-notes-item-text');
if (item) {
const note = this.#note_of_el(item);
const item_data = this.#item_of_el(item);
if (note && item_data) {
item_data.text = item.textContent;
this.#touch(note);
this.#debounce_save();
}
}
});
container.addEventListener('change', (e) => {
const select = e.target.closest('.div-notes-select');
if (select) {
if (select.checked) this.#selected.add(select.dataset.note);
else this.#selected.delete(select.dataset.note);
this.#refresh_toolbar();
return;
}
const sort = e.target.closest('.div-notes-sort');
if (sort) {
this.#sort = sort.value;
this.#refresh_board();
return;
}
const checkbox = e.target.closest('input[type="checkbox"][data-item]:not(.div-notes-select)');
if (checkbox) {
const note = this.#note_of_el(checkbox);
const item = this.#item_of_el(checkbox);
if (item) {
item.checked = checkbox.checked;
this.#touch(note);
this.#commit();
}
}
});
container.addEventListener('keydown', (e) => {
const title = e.target.closest('.div-notes-note-title');
if (title && e.key === 'Enter') {
e.preventDefault();
return;
}
const tag_input = e.target.closest('.div-notes-tag-input');
if (tag_input && e.key === 'Enter') {
e.preventDefault();
tag_input.parentElement.querySelector('button[data-action="add-tag"]')?.click();
return;
}
const item_text = e.target.closest('.div-notes-item-text');
if (item_text && e.key === 'Enter') {
e.preventDefault();
this.#split_item(item_text);
return;
}
if (e.key === 'Escape' && (this.#tag_menu_open || this.#note_tag_menu_open !== null)) {
this.#tag_menu_open = false;
this.#note_tag_menu_open = null;
this.#refresh_toolbar();
this.#refresh_notes();
return;
}
if (item_text && e.key === 'Backspace' && item_text.textContent.trim() === '') {
e.preventDefault();
this.#remove_item_element(item_text);
}
});
container.addEventListener('click', (e) => {
// a drag release reports its click on a shared ancestor; swallow it
if (this.#drag.take_click_guard()) return;
// moving the caret with the mouse refreshes the format states
const clicked_text = e.target.closest('.div-notes-note-text');
if (clicked_text) {
sync_inline_format_states(this.#toolbar_of(clicked_text), clicked_text);
}
const fmt_button = e.target.closest('.div-notes-format-toolbar button[data-action]');
if (fmt_button) {
this.#handle_format_action(fmt_button.dataset.action);
return;
}
const button = e.target.closest('button[data-action]');
if (button) {
void this.#handle_action(button.dataset.action, button);
}
});
container.addEventListener('keyup', (e) => {
const text = e.target.closest('.div-notes-note-text');
if (text) {
sync_inline_format_states(this.#toolbar_of(text), text);
}
});
}
/**
* Returns the format toolbar of the surface a note text belongs to.
* @param {HTMLElement} text - The note text element.
* @returns {HTMLElement|null} The toolbar.
*/
#toolbar_of(text) {
return text.closest('.div-notes-editor, .div-notes-overlay-body')?.querySelector('.div-notes-format-toolbar') || null;
}
/**
* Runs an inline-format action on the last focused note text.
* @param {string} action - The fmt-* action identifier.
*/
#handle_format_action(action) {
const text = (this.#focus_text?.isConnected ? this.#focus_text : null)
|| document.activeElement?.closest?.('.div-notes-note-text')
|| null;
if (!text) return;
text.focus();
apply_inline_format(action, text, () => {
const note = this.#note_of_el(text);
note.text = text.innerHTML;
this.#touch(note);
sync_inline_format_states(this.#toolbar_of(text), text);
this.#debounce_save();
});
}
/**
* Dispatches a data-action click on a surface.
* @param {string} action - The action identifier.
* @param {HTMLElement} button - The clicked element.
*/
async #handle_action(action, button) {
const guid = button.dataset.note || '';
switch (action) {
case 'add-note': {
const note = this.#new_note();
this.#notes.push(note);
this.#commit();
this.#focus_title(note.guid);
break;
}
case 'toggle-archived':
this.#show_archived = !this.#show_archived;
this.#commit();
break;
case 'toggle-tag-menu':
this.#tag_menu_open = !this.#tag_menu_open;
this.#refresh_toolbar();
break;
case 'filter-tag': {
const tag = button.dataset.tag || null;
this.#filter_tag = this.#filter_tag === tag ? null : tag;
this.#tag_menu_open = false;
this.#commit();
break;
}
case 'archive-selection': {
// toggles each selected note's own archive state, so a mixed
// selection flips in place instead of being forced one way
const selected = this.#notes.filter(note => this.#selected.has(note.guid));
selected.forEach(note => {
note.archived = !note.archived;
this.#touch(note);
});
this.#selected.clear();
this.#commit();
break;
}
case 'delete-selection': {
const selected = this.#notes.filter(note => this.#selected.has(note.guid));
if (selected.length === 0) break;
const confirmed = await customConfirm(
'Delete Notes',
`Are you sure you want to delete ${selected.length === 1 ? 'the selected note' : `${selected.length} selected notes`}? This cannot be undone.`
);
if (!confirmed) break;
this.#notes = this.#notes.filter(note => !this.#selected.has(note.guid));
this.#selected.clear();
if (this.#overlay_guid !== null && !this.#notes.some(note => note.guid === this.#overlay_guid)) {
this.#overlay_guid = null;
}
this.#commit();
break;
}
case 'add-tag': {
const input = button.parentElement.querySelector('.div-notes-tag-input');
const tag = (input?.value || '').trim().replace(/\s+/g, ' ');
if (input) input.value = '';
if (tag && this.#tag_words_ok(tag) && !this.#tags.includes(tag)) {
this.#tags.push(tag);
this.#commit();
}
break;
}
case 'remove-tag': {
const tag = button.dataset.tag;
const count = this.#notes.filter(note => note.tags.includes(tag)).length;
if (count > 1) {
const confirmed = await customConfirm(
'Remove Tag',
`The tag "${tag}" is used by ${count} notes. Remove it from the vocabulary and from all of them?`
);
if (!confirmed) break;
}
this.#tags = this.#tags.filter(existing => existing !== tag);
this.#notes.forEach(note => {
note.tags = note.tags.filter(existing => existing !== tag);
});
if (this.#filter_tag === tag) this.#filter_tag = null;
this.#commit();
break;
}
case 'open-note':
this.#open_overlay(guid);
break;
case 'read-note':
this.#read_note(guid);
break;
case 'toggle-note-tag-menu':
this.#note_tag_menu_open = this.#note_tag_menu_open === guid ? null : guid;
this.#refresh_notes();
break;
case 'assign-note-tag': {
const note = this.#notes.find(entry => entry.guid === guid);
const tag = button.dataset.tag;
if (note && this.#tags.includes(tag)) {
note.tags = note.tags.includes(tag) ? note.tags.filter(existing => existing !== tag) : [...note.tags, tag];
this.#touch(note);
this.#commit();
}
break;
}
case 'insert-item': {
const position = parseInt(button.dataset.position, 10);
this.#insert_item_at(guid, position);
break;
}
}
}
/**
* Inserts an empty checklist item at a position and focuses it.
* @param {string} note_guid - The note guid.
* @param {number} position - The insertion index.
*/
#insert_item_at(note_guid, position) {
const note = this.#notes.find(entry => entry.guid === note_guid);
if (!note || !Number.isInteger(position) || position < 0 || position > note.items.length) return;
if (note.items.length >= StickyNotesEditor.ITEMS_MAX) return;
const item = { guid: crypto.randomUUID(), text: '', checked: false };
note.items.splice(position, 0, item);
this.#touch(note);
this.#commit();
this.#focus_item(item.guid);
}
/**
* Inserts a new checklist item after the focused one, splitting the
* focused text at the caret, and focuses the new item.
* @param {HTMLElement} item_el - The focused item text element.
*/
#split_item(item_el) {
const note = this.#note_of_el(item_el);
const item = this.#item_of_el(item_el);
if (!note || !item || note.items.length >= StickyNotesEditor.ITEMS_MAX) return;
let before = item.text;
let after = '';
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && item_el.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
const probe = document.createRange();
probe.selectNodeContents(item_el);
probe.setEnd(range.startContainer, range.startOffset);
before = probe.toString();
probe.selectNodeContents(item_el);
probe.setStart(range.endContainer, range.endOffset);
after = probe.toString();
}
item.text = before;
const fresh = { guid: crypto.randomUUID(), text: after, checked: false };
note.items.splice(note.items.findIndex(entry => entry.guid === item.guid) + 1, 0, fresh);
this.#touch(note);
this.#commit();
this.#focus_item(fresh.guid);
}
/**
* Removes the checklist item owning an editing element and focuses the
* previous item's text.
* @param {HTMLElement} item_el - The item text element.
*/
#remove_item_element(item_el) {
const note = this.#note_of_el(item_el);
if (!note) return;
const at = note.items.findIndex(entry => entry.guid === item_el.dataset.item);
if (at === -1) return;
const previous = at > 0 ? note.items[at - 1] : null;
note.items.splice(at, 1);
this.#touch(note);
this.#commit();
if (previous) this.#focus_item(previous.guid);
}
//endregion
//region Item Drag Reorder
/**
* Applies a committed checklist item drop: dropping on the completed
* section checks the item off; dropping on an insert button unchecks it
* and moves it to that position. Drops that change nothing are ignored.
* @param {Object} drag - The finished item drag (el, target_el, note_guid).
*/
#commit_item_drop(drag) {
const note = this.#notes.find(entry => entry.guid === drag.note_guid);
const item = note?.items.find(entry => entry.guid === drag.el.dataset.item);
if (!note || !item) return;
if (drag.target_el?.classList.contains('div-notes-items-done')) {
if (!item.checked) {
item.checked = true;
this.#touch(note);
this.#commit();
}
return;
}
const position = parseInt(drag.target_el?.dataset.position || '', 10);
if (!Number.isInteger(position)) return;
const index = note.items.findIndex(entry => entry.guid === item.guid);
if (index === -1) return;
const at = Math.max(0, Math.min(position, note.items.length));
const moved = at !== index && at !== index + 1;
const was_checked = item.checked;
if (moved) {
const [entry] = note.items.splice(index, 1);
note.items.splice(at > index ? at - 1 : at, 0, entry);
}
if (was_checked) item.checked = false;
if (moved || was_checked) {
this.#touch(note);
this.#commit();
}
}
/**
* Moves a dropped note card in the board order: before the note named by
* the drop target's data-before guid, or after the last visible note for
* the trailing target. Drops that do not change the order are ignored.
* @param {Object} drag - The finished note drag (el, target_el).
*/
#move_note(drag) {
const note = this.#notes.find(entry => entry.guid === drag.el.dataset.note);
if (!note) return;
const from = this.#notes.indexOf(note);
const before_guid = drag.target_el?.dataset.before;
let at;
if (before_guid) {
const target = this.#notes.find(entry => entry.guid === before_guid);
if (!target) return;
at = this.#notes.indexOf(target);
} else {
const visible = this.#visible_notes(false);
const last = visible[visible.length - 1];
at = last ? this.#notes.indexOf(last) + 1 : this.#notes.length;
}
this.#notes.splice(from, 1);
const to = at > from ? at - 1 : at;
this.#notes.splice(to, 0, note);
if (to !== from) {
this.#touch(note);
this.#commit();
}
}
//endregion
//region Read Aloud
/**
* Reads one note aloud: title, text, and checklist items (marked done or
* to do). Toggles playback when the same note is already being read.
* @param {string} guid - The note guid.
*/
#read_note(guid) {
if (this.#reading_guid === guid) {
stop_playback();
this.#reading_guid = null;
this.#sync_read_buttons();
return;
}
const note = this.#notes.find(entry => entry.guid === guid);
if (!note || !this.#app) return;
stop_playback();
const parts = [];
if (note.title) parts.push(note.title + '.');
const text = strip_tags(this.#sanitize_html(note.text)).trim();
if (text) parts.push(text);
if (note.items.length) {
parts.push('Checklist: ' + note.items.map(item => (item.checked ? 'Done: ' : 'To do: ') + item.text).join('. '));
}
const spoken = parts.join(' ').trim();
if (!spoken) return;
this.#reading_guid = guid;
this.#sync_read_buttons();
const config = this.#app.storage.get_app_config();
speak_text(spoken, {
tts_model: config[this.#app.storage.KEY_CONFIG_TTS_MODEL] || '',
voice: config[this.#app.storage.KEY_CONFIG_TTS_VOICE] || ''
}, () => this.#sync_read_buttons(), () => {
this.#reading_guid = null;
this.#sync_read_buttons();
});
}
/**
* Refreshes every read button's glyph: pause while its note is being read,
* play otherwise.
*/
#sync_read_buttons() {
document.querySelectorAll('.btn-note-read').forEach(btn => {
btn.textContent = this.#reading_guid === btn.dataset.note ? '\u23F8' : '\u25B6';
});
}
//endregion
//region Helpers
/**
* Focuses a note's title and selects its content.
* @param {string} note_guid - The note guid.
*/
#focus_title(note_guid) {
this.#focus_element(document.querySelector(`.div-notes-note[data-note="${note_guid}"] .div-notes-note-title`));
}
/**
* Focuses a checklist item's text and places the caret.
* @param {string} item_guid - The item guid.
*/
#focus_item(item_guid) {
this.#focus_element(document.querySelector(`.div-notes-item-text[data-item="${item_guid}"]`));
}
/**
* Focuses an element and places the caret at the start.
* @param {HTMLElement|null} el - The element.
*/
#focus_element(el) {
if (!el) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Returns the note owning an editing element.
* @param {HTMLElement} el - The element inside a note card.
* @returns {Object|null} The note.
*/
#note_of_el(el) {
return this.#notes.find(note => note.guid === el.closest('.div-notes-note')?.dataset.note) || null;
}
/**
* Returns the checklist item owning an editing element.
* @param {HTMLElement} el - The element inside an item row.
* @returns {Object|null} The item.
*/
#item_of_el(el) {
const note = this.#note_of_el(el);
const item_guid = el.dataset?.item || el.closest?.('[data-item]')?.dataset?.item;
return note?.items.find(entry => entry.guid === item_guid) || null;
}
/**
* Normalizes a stored note, assigning missing guids and timestamps.
* @param {Object} note - The stored note.
* @returns {Object} The normalized note.
*/
#normalize_note(note) {
const created = Number(note?.created) || Date.now();
return {
guid: note?.guid || crypto.randomUUID(),
title: String(note?.title || ''),
text: String(note?.text || ''),
items: (Array.isArray(note?.items) ? note.items : []).map(item => ({
guid: item?.guid || crypto.randomUUID(),
text: String(item?.text || ''),
checked: !!item?.checked
})),
tags: this.#clean_tags(note?.tags),
archived: !!note?.archived,
created: created,
updated: Number(note?.updated) || created
};
}
/**
* Creates a new empty note.
* @returns {Object} The note.
*/
#new_note() {
const now = Date.now();
return { guid: crypto.randomUUID(), title: '', text: '', items: [], tags: [], archived: false, created: now, updated: now };
}
/**
* Stamps a note's last-edited time.
* @param {Object} note - The note.
*/
#touch(note) {
if (note) note.updated = Date.now();
}
/**
* Serializes the notes back into conversation storage.
* @returns {Array<Object>} The note array.
*/
#serialize() {
return this.#notes.map(note => this.#serialize_note(note));
}
/**
* Serializes one note back into conversation storage.
* @param {Object} note - The note.
* @returns {Object} The stored note object.
*/
#serialize_note(note) {
return {
guid: note.guid,
title: note.title.slice(0, StickyNotesEditor.TITLE_MAX),
text: this.#sanitize_html(note.text),
items: note.items.map(item => ({ guid: item.guid, text: item.text.slice(0, 200), checked: item.checked })),
tags: [...note.tags],
archived: note.archived,
created: note.created,
updated: note.updated
};
}
/**
* Cleans a tag list: plain strings, trimmed, non-empty.
* @param {Array|null} tags - The raw tags.
* @returns {Array<string>} The clean tags.
*/
#clean_tags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map(tag => String(tag || '').trim()).filter(Boolean);
}
/**
* Reduces a value to plain text: strips tags, decodes entities, and
* collapses whitespace.
* @param {string} value - The raw value.
* @returns {string} The plain text.
*/
#plain_text(value) {
const holder = document.createElement('div');
holder.innerHTML = String(value ?? '');
return holder.textContent.replace(/\s+/g, ' ').trim();
}
/**
* Converts a plain-text value into the editor's inline html: escaped text
* with newlines as line breaks.
* @param {string} value - The plain text value.
* @returns {string} The inline html.
*/
#plain_to_html(value) {
return escapeHtml(String(value || '')).replace(/\n/g, '<br>');
}
/**
* Returns whether a tag is within the word limit.
* @param {string} tag - The tag.
* @returns {boolean} True when the tag has 1 to 3 words.
*/
#tag_words_ok(tag) {
const words = String(tag || '').trim().split(/\s+/);
return words.length >= 1 && words.length <= StickyNotesEditor.TAG_WORDS_MAX;
}
/**
* Filters a tag list down to valid tags that exist in the vocabulary.
* @param {Array|null} tags - The raw tags.
* @returns {Array<string>} The valid tags.
*/
#valid_tags(tags) {
if (!Array.isArray(tags)) return [];
const seen = new Set();
return this.#clean_tags(tags)
.filter(tag => this.#tags.includes(tag) && !seen.has(tag) && seen.add(tag));
}
/**
* Sanitizes note text to the editor's allowlisted inline html: b, i, u, s,
* sup, sub, code, a (http(s) links only), and br. Every other element is
* unwrapped, block containers become line breaks, and text is escaped.
* @param {string} html - The raw html.
* @returns {string} The sanitized html.
*/
#sanitize_html(html) {
const holder = document.createElement('div');
holder.innerHTML = String(html || '');
return this.#clean_children(holder).replace(/(?:<br>)+$/, '');
}
/**
* Builds sanitized inline html from a DOM subtree.
* @param {HTMLElement} parent - The subtree root.
* @returns {string} The sanitized html.
*/
#clean_children(parent) {
let out = '';
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType === 3) {
out += escapeHtml(node.textContent);
continue;
}
if (node.nodeType !== 1) continue;
const inner = this.#clean_children(node);
const tag = node.tagName;
const wrap = (open, close) => inner ? open + inner + close : '';
if (tag === 'BR') {
out += '<br>';
} else if (tag === 'B' || tag === 'STRONG') {
out += wrap('<b>', '</b>');
} else if (tag === 'I' || tag === 'EM') {
out += wrap('<i>', '</i>');
} else if (tag === 'U') {
out += wrap('<u>', '</u>');
} else if (tag === 'S' || tag === 'STRIKE' || tag === 'DEL') {
out += wrap('<s>', '</s>');
} else if (tag === 'SUP') {
out += wrap('<sup>', '</sup>');
} else if (tag === 'SUB') {
out += wrap('<sub>', '</sub>');
} else if (tag === 'CODE') {
out += wrap('<code>', '</code>');
} else if (tag === 'A') {
const href = node.getAttribute('href') || '';
out += /^https?:\/\//i.test(href) ? `<a href="${escapeHtml(href)}">${inner}</a>` : inner;
} else if (tag === 'DIV' || tag === 'P') {
if (inner && out && !out.endsWith('<br>')) out += '<br>';
out += inner;
} else {
out += inner;
}
}
return out;
}
/**
* Highlights the note referenced by a findings card.
* @param {string|null} guid - The note guid, or null to clear.
*/
#set_note_highlight(guid) {
if (this.#highlighted_note) {
this.#highlighted_note.classList.remove('document-item-highlighted');
this.#highlighted_note = null;
}
if (!guid || !this.#root) return;
const el = this.#root.querySelector(`.div-notes-note[data-note="${guid}"]`) || null;
if (!el) return;
el.classList.add('document-item-highlighted');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Rebuilds the visible surfaces and saves.
*/
#commit() {
this.#rerender();
this.#save_now();
}
/**
* Rebuilds the board, the tag manager, and the open overlay.
*/
#rerender() {
const editor = this.#root?.querySelector('.div-notes-editor');
if (editor) {
editor.outerHTML = `
<div class="div-notes-editor">
<div class="div-notes-sticky">
${this.#toolbar_html()}
${this.#format_toolbar_html()}
</div>
<div class="div-notes-board">${this.#cards_html()}</div>
</div>
`;
}
if (this.#tags_root) {
this.#tags_root.innerHTML = this.#tags_manager_html();
}
if (this.#overlay_guid !== null) {
this.#render_overlay();
}
this.#position_note_menu();
}
/**
* Rebuilds only the board cards (search and sort typing stay intact).
*/
#refresh_board() {
const board = this.#root?.querySelector('.div-notes-board');
if (board) {
board.innerHTML = this.#cards_html();
}
this.#position_note_menu();
}
/**
* Rebuilds only the control toolbar (tag menu open state included).
*/
#refresh_toolbar() {
const toolbar = this.#root?.querySelector('.div-notes-toolbar');
if (toolbar) {
toolbar.outerHTML = this.#toolbar_html();
}
}
/**
* Rebuilds the note cards without saving (per-note tag menu toggling).
*/
#refresh_notes() {
this.#refresh_board();
if (this.#overlay_guid !== null) {
this.#render_overlay();
}
this.#position_note_menu();
}
/**
* Anchors every open per-note tag menu to its own tag button with fixed
* positioning, so the menus escape the note cards' clipping and scrolling:
* below the button when there is room, above it otherwise, clamped to
* the viewport. Board and overlay cards may both be open for the same
* note; each is anchored to its own button.
*/
#position_note_menu() {
if (this.#note_tag_menu_open === null) return;
// rects are in visual (zoomed) pixels; inline left/top multiply by the
// page zoom again, so convert back before assigning
const zoom = parseFloat(document.documentElement.style.zoom) || 1;
for (const dropdown of document.querySelectorAll('.div-notes-tag-dropdown-note.open')) {
const button = dropdown.parentElement.querySelector('.div-notes-tag-button');
if (!button) continue;
const rect = button.getBoundingClientRect();
dropdown.style.left = '0px';
dropdown.style.top = '0px';
const menu = dropdown.getBoundingClientRect();
let top = rect.bottom + 4;
if (top + menu.height > window.innerHeight - 8) {
top = rect.top - menu.height - 4;
}
let left = rect.left;
if (left + menu.width > window.innerWidth - 8) {
left = window.innerWidth - menu.width - 8;
}
dropdown.style.top = `${Math.max(8, top / zoom)}px`;
dropdown.style.left = `${Math.max(8, left / zoom)}px`;
}
}
/**
* Closes an open tag menu when a click lands outside it. Bound once.
*/
#bind_document_close() {
if (this.#doc_close_bound) return;
this.#doc_close_bound = true;
document.addEventListener('click', (e) => {
if (e.target.closest('.div-notes-tag-menu')) return;
let closed = false;
if (this.#tag_menu_open) {
this.#tag_menu_open = false;
closed = true;
}
if (this.#note_tag_menu_open !== null) {
this.#note_tag_menu_open = null;
closed = true;
}
if (closed) {
this.#refresh_toolbar();
this.#refresh_notes();
}
});
// the fixed note menu must follow its anchor when the board scrolls
document.addEventListener('scroll', () => {
if (this.#note_tag_menu_open !== null) this.#position_note_menu();
}, true);
window.addEventListener('resize', () => {
if (this.#note_tag_menu_open !== null) this.#position_note_menu();
});
}
/**
* Schedules a debounced save of the notes and tags.
*/
#debounce_save() {
clearTimeout(this.#save_timer);
this.#save_timer = setTimeout(() => this.#save_now(), 400);
}
/**
* Saves the notes and tags immediately through the change callback.
*/
#save_now() {
clearTimeout(this.#save_timer);
this.#save_timer = null;
if (this.#on_change) {
this.#on_change(this.#serialize(), [...this.#tags]);
}
}
//endregion
}
export default StickyNotesEditor;
sticky-notes-editor.js
×
Type: Web, text/plain
64.84 Kilobytes
Last Modified 2026-11-04 09:48:28
⬠Download File
Type: Web, text/plain
64.84 Kilobytes
Last Modified 2026-11-04 09:48:28
⬠Download File