9 directories, 58 files

tinai

Home / tinai
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 ? '&#10003; ' : ''}${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">&#127991;</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) ? '&#10003; ' : ''}${escapeHtml(tag)}</button>
		`).join('');
		const head = bare ? '' : `
				<div class="div-notes-note-head">
					<span class="span-document-gripper div-notes-note-grip" title="Reorder">&#x2016;</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">&#9654;</button>
						<button type="button" class="as-icon" data-action="open-note" data-note="${note.guid}" title="Expand">&#128269;</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">&#127991;</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">&#x2016;</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">&#x1F9F9;</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">&lt;/&gt;</button>
				<button type="button" class="as-icon" data-action="fmt-link" title="Insert Link">&#128279;</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">&times;</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