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 } from './util/format-utils.js';
import { cost_block_html, formatCostSummary } from './util/conversation-format-utils.js';
import { replayInteractiveRequests } from './util/interactive-tools.js';
import Api from './api.js';
import ThinkingPanelBase from './util/thinking-panel-base.js';

/**
 * Class ChoicesEditor
 * Plain-text editable item list for the Choices tab of Ranking conversations.
 * Reuses the document editor's item classes and interaction model: insert
 * buttons between items, grip dragging to reorder, Enter to split or append,
 * Shift+Enter for a line break within an item, and appending by clicking
 * below the last item. It has no toolbars and no block types.
 *
 * Like the document editor, it owns a ThinkingPanelBase activity drawer
 * and sends prompts to the rankings endpoint, where the model reads and edits
 * the choice items through the choice tools.
 */
class ChoicesEditor {

	#items = [];
	#root = null;
	#container = null;
	#on_change = null;
	#save_timer = null;
	#active_index = -1;
	#suppress_focusout = false;
	#drag = create_reorder_drag({
		draggable_of: (e) => {
			const grip = e.target.closest('.span-document-gripper');
			return grip?.closest('.div-document-item') || null;
		},
		state_of: (el) => {
			const index = parseInt(el.dataset.index, 10);
			return Number.isInteger(index) ? { index } : null;
		},
		target_of: (x, y) => document.elementFromPoint(x, y)?.closest('.div-document-insert') || null,
		chrome_of: () => [[this.#root, 'dragging-items']],
		scroll_el_of: () => this.#container,
		commit: (drag) => {
			const position = parseInt(drag.target_el?.dataset.position || '', 10);
			if (!Number.isInteger(position)) return;
			const at = Math.max(0, Math.min(position, this.#items.length));
			if (at === drag.index || at === drag.index + 1) return;
			const [moved] = this.#items.splice(drag.index, 1);
			this.#items.splice(at > drag.index ? at - 1 : at, 0, moved);
			this.#rerender();
			this.#save_now();
		}
	});
	#api = new Api();
	#panel = new ThinkingPanelBase({
		on_highlight: (guid) => this.#set_item_highlight(guid),
		replay: (guid) => {
			if (this.#app) {
				replayInteractiveRequests(getEl('div-choices-thinking-content'), guid, this.#app);
			}
		}
	}, {
		drawer_selector: '#div-choices-thinking',
		content_id: 'div-choices-thinking-content',
		clear_id: 'btn-choices-thinking-clear',
		prev_id: 'btn-choices-thinking-prev',
		next_id: 'btn-choices-thinking-next'
	});
	#panel_bound = false;
	#app = null;
	#content_guid = null;
	#asking_guids = new Set();
	#highlighted_item = null;
	#suggesting = false;
	#suggest_button = null;

	/**
	 * Returns whether the editor currently contains the keyboard focus.
	 * @returns {boolean} True when a child of the editor has focus.
	 */
	contains_focus() {
		return contains_focus(this.#root);
	}

	/**
	 * Returns whether the stored items match the editor's current state.
	 * @param {Array} items - The stored choice items.
	 * @returns {boolean} True when both hold the same content.
	 */
	matches_items(items) {
		return JSON.stringify(Array.isArray(items) ? items : []) === JSON.stringify(this.#serialize());
	}

	/**
	 * Renders the editor: one editable text block per choice item.
	 * @param {Array} [items=[]] - The stored choice items.
	 * @returns {string} The editor HTML.
	 */
	render(items = []) {
		this.#items = (Array.isArray(items) ? items : []).map(item => this.#normalize_item(item));
		if (this.#items.length === 0) {
			this.#items = [this.#new_item('')];
		}
		this.#active_index = -1;
		return `
			<div class="div-choices-editor">
				<div class="div-document-items">${this.#items_html()}</div>
			</div>
		`;
	}

	/**
	 * Wires the delegated editing events and the change callback. Listeners
	 * bind once per container; the item root is re-resolved on every call
	 * since rebuilds replace it.
	 * @param {HTMLElement} container - The container the editor renders into.
	 * @param {Function|null} [on_change=null] - Called with the items array after edits.
	 */
	attach(container, on_change = null) {
		this.#on_change = on_change;
		if (!container) return;
		if (container !== this.#container) {
			this.#container = container;
			this.#bind_container_listeners(container);
		}
		this.#root = container.querySelector('.div-document-items');
		if (!this.#panel_bound) {
			const choices_container = getEl('div-choices-container');
			if (choices_container) {
				this.#panel.bind(choices_container);
				this.#panel_bound = true;
			}
			window.addEventListener('beforeunload', () => this.#save_now());
		}
	}

	/**
	 * Switches the thinking panel to the given conversation 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.#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 choice list's structured content to the
	 * rankings endpoint, streaming reasoning into the thinking panel and
	 * appending the final text output at the end. The panel slides open for
	 * the request.
	 * @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 {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.
	 */
	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);
		// 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,
				items: this.get_content(),
				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),
			'rankings',
			(text) => this.#panel.stream_line(target_guid, text, options.app),
			(bytes) => options.app?.response_pipeline?.update_progress(bytes, target_guid),
			target_guid
		);
	}

	/**
	 * 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. Any choice edits the model made flow through the
	 * change callback, which 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);
		if (!this.#panel.finish_run(guid, response)) {
			options.on_error?.();
			return;
		}
		options.on_done?.();
	}

	/**
	 * Finishes a run after a transport failure: ends the streaming block and
	 * adds the error output through the shared panel lifecycle.
	 * @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.#panel.fail_run(guid, error, 'Ranking request failed.');
		options.on_error?.();
	}

	//endregion

	//region Rank Action

	/**
	 * Sends the rank request: the choice list is re-ordered per relevance to
	 * the prompt by the ranking endpoint, streaming reasoning into the
	 * thinking panel. The choices themselves are never modified. The
	 * structured result is delivered through options.on_result.
	 * @param {string} prompt - The user's ranking 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 {boolean} [options.show_suggested=false] - Whether the conversation asks for suggested follow-ups.
	 * @param {boolean} [options.show_related=false] - Whether the conversation asks for related topics.
	 * @param {App|null} [options.app=null] - The application instance, required for progress and streaming.
	 * @param {string} [options.guid=''] - The conversation guid.
	 * @param {Function|null} [options.on_result=null] - Called with the structured ranking response.
	 * @param {Function|null} [options.on_done=null] - Called after the panel is finalized.
	 * @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.
	 */
	rank(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);
		const api = options.app?.api || this.#api;
		api.post(
			{
				prompt: prompt,
				items: this.get_content(),
				model_key: options.model_key || '',
				thinking: options.thinking || '',
				meta_context: options.meta_context || {},
				show_suggested: !!options.show_suggested,
				show_related: !!options.show_related,
				local_thinking: true
			},
			(response) => this.#complete_rank_run(target_guid, response, options),
			(error) => this.#fail_run(target_guid, error, options),
			'rank',
			(text) => this.#panel.stream_line(target_guid, text, options.app),
			(bytes) => options.app?.response_pipeline?.update_progress(bytes, target_guid),
			target_guid
		);
	}

	/**
	 * Finishes a rank run on success: ends the streaming block, appends the
	 * reply html and the cost summary, persists the panel log, refreshes the
	 * conversation title when the choices changed during the run, and
	 * delivers the structured result.
	 * @param {string} guid - The conversation guid the run belongs to.
	 * @param {Object} response - The endpoint response.
	 * @param {object} options - The rank options.
	 */
	#complete_rank_run(guid, response, options) {
		this.#asking_guids.delete(guid);
		this.#panel.end_run(guid);
		if (response && response.error) {
			this.#panel.set_output(guid, 'ERROR: ' + response.error);
			this.#panel.persist(guid);
			options.on_error?.();
			return;
		}
		const reply = String(response?.reply || '').trim();
		if (reply) {
			this.#panel.append_html(guid, '<div class="div-document-thinking-output">' + reply + '</div>');
		} else {
			this.#panel.set_output(guid, 'No ranking returned.');
		}
		this.#panel.append_html(guid, cost_block_html(formatCostSummary(response)));
		this.#panel.persist(guid);
		void options.app?.response_pipeline?.update_ranking_title(guid);
		options.on_result?.(response);
		options.on_done?.();
	}

	//endregion

	//region Tool Edits

	/**
	 * Applies a structured edit_choices edit to the items: add, replace, move,
	 * and remove, referenced by guids with the dynamic "last"/"first" values.
	 * Item values are plain text. Rerenders and saves after the edit.
	 * @param {Object} edit - The edit { action, guid, target_guid, position, item }.
	 * @returns {Object|null} { guid } of the affected item, or null when the edit cannot apply.
	 */
	apply_edit(edit) {
		const action = String(edit?.action || '');
		const position = edit?.position === 'above' ? 'above' : 'below';
		const resolve = (guid) => {
			const key = String(guid || '').trim().toLowerCase();
			if (key === 'last') return this.#items.length - 1;
			if (key === 'first') return 0;
			return this.#items.findIndex(item => item.guid === guid);
		};

		if (action === 'add') {
			const value = edit?.item?.value;
			if (typeof value !== 'string') return null;
			const anchor = resolve(edit?.guid);
			const at = anchor === -1 ? this.#items.length : (position === 'above' ? anchor : anchor + 1);
			this.#items.splice(at, 0, this.#new_item(this.#plain_to_html(value)));
			this.#rerender();
			this.#save_now();
			return { guid: this.#items[at].guid };
		}

		const index = resolve(edit?.guid);
		if (index === -1) return null;

		if (action === 'replace') {
			const value = edit?.item?.value;
			if (typeof value !== 'string') return null;
			this.#items[index].value = this.#plain_to_html(value);
			this.#rerender();
			this.#save_now();
			return { guid: this.#items[index].guid };
		}
		if (action === 'remove') {
			const [removed] = this.#items.splice(index, 1);
			if (this.#items.length === 0) {
				this.#items = [this.#new_item('')];
			}
			this.#rerender(Math.max(0, Math.min(index - 1, this.#items.length - 1)));
			this.#save_now();
			return { guid: removed?.guid || edit.guid };
		}
		if (action === 'move') {
			const target = resolve(edit?.target_guid);
			if (target === -1 || target === index) return null;
			const [moved] = this.#items.splice(index, 1);
			let at = target;
			if (index < target) at -= 1;
			if (position === 'below') at += 1;
			this.#items.splice(at, 0, moved);
			this.#rerender();
			this.#save_now();
			return { guid: moved.guid };
		}
		return null;
	}

	/**
	 * Returns the current choice items.
	 * @returns {Array<Object>} The choice item array.
	 */
	get_content() {
		return this.#serialize();
	}

	/**
	 * Highlights the choice item referenced by a finding card.
	 * @param {string|null} guid - The item guid, or null to clear.
	 */
	#set_item_highlight(guid) {
		if (this.#highlighted_item) {
			this.#highlighted_item.classList.remove('document-item-highlighted');
			this.#highlighted_item = null;
		}
		if (!guid || !this.#root) return;
		const el = this.#root.querySelector(`.div-document-item[data-guid="${guid}"]`) || null;
		if (!el) return;
		el.classList.add('document-item-highlighted');
		this.#highlighted_item = el;
		el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
	}

	//endregion

	//region Editing Surface

	/**
	 * Binds the delegated editing event listeners to the render container.
	 * @param {HTMLElement} container - The container the editor renders into.
	 */
	#bind_container_listeners(container) {
		this.#drag.bind(container);

		container.addEventListener('focusin', (e) => {
			const value_el = e.target.closest('.div-document-value');
			if (!value_el) return;
			this.#active_index = parseInt(value_el.dataset.index, 10);
		});

		container.addEventListener('focusout', (e) => {
			if (this.#suppress_focusout) return;
			if (!container.contains(e.relatedTarget)) {
				this.#save_now();
			}
		});

		container.addEventListener('input', (e) => {
			const value_el = e.target.closest('.div-document-value');
			if (!value_el) return;
			const index = parseInt(value_el.dataset.index, 10);
			if (!this.#items[index]) return;
			this.#items[index].value = value_el.innerHTML;
			this.#sync_suggest_button(index, value_el);
			this.#debounce_save();
		});

		container.addEventListener('keydown', (e) => {
			const value_el = e.target.closest('.div-document-value');
			if (!value_el) return;
			const index = parseInt(value_el.dataset.index, 10);
			if (e.key === 'ArrowUp' && index > 0 && this.#caret_at_start(value_el)) {
				e.preventDefault();
				this.#focus_item(index - 1);
			} else if (e.key === 'ArrowDown' && index < this.#items.length - 1 && this.#caret_at_end(value_el)) {
				e.preventDefault();
				this.#focus_item(index + 1, true);
			} else if (e.key === 'Enter' && !e.shiftKey) {
				e.preventDefault();
				this.#split_or_append(index);
			} else if (e.key === 'Backspace' && this.#is_empty_value(value_el)) {
				e.preventDefault();
				this.#remove_item(index);
			}
		});

		container.addEventListener('click', (e) => {
			// a drag release reports its click on a shared ancestor; swallow it
			if (this.#drag.take_click_guard()) return;
			const button = e.target.closest('button[data-action]');
			if (button) {
				if (button.dataset.action === 'insert-item') {
					this.#insert_at(parseInt(button.dataset.position, 10));
				} else if (button.dataset.action === 'suggest-item') {
					this.suggest(parseInt(button.dataset.index, 10));
				}
				return;
			}
			// tapping empty space appends a new item to the end,
			// unless the last item is already empty; focus it instead
			if (e.target.closest('.div-document-items') && !e.target.closest('.div-document-item')) {
				const last_index = this.#items.length - 1;
				const last_value = last_index >= 0 ? this.#value_el(last_index) : null;
				if (last_value && this.#is_empty_value(last_value)) {
					this.#focus_item(last_index);
				} else {
					this.#append_item();
				}
			}
		});
	}

	/**
	 * Builds the HTML for all editor blocks.
	 * @returns {string} The blocks HTML.
	 */
	#items_html() {
		let html = `<button type="button" class="div-document-insert" data-action="insert-item" data-position="0" title="Insert item"></button>`;
		this.#items.forEach((item, index) => {
			const is_new = this.#is_new_choice(item.value);
			html += `
				<div class="div-document-item" data-index="${index}" data-guid="${item.guid}">
					<div class="div-document-value" contenteditable="true" data-index="${index}">${item.value}</div>
					<button type="button" class="as-icon div-choices-suggest" data-action="suggest-item" data-index="${index}" title="Suggest a choice"${is_new ? '' : ' style="display: none;"'}>&#129668;</button>
					<div class="div-document-item-controls">
						<span class="span-document-gripper" title="Reorder">&#x2016;</span>
					</div>
				</div>
			`;
			html += `<button type="button" class="div-document-insert" data-action="insert-item" data-position="${index + 1}" title="Insert item"></button>`;
		});
		return html;
	}

	//endregion

	//region Item Actions

	/**
	 * Inserts a new empty item at a position and focuses it.
	 * @param {number} position - The insertion index.
	 */
	#insert_at(position) {
		if (!Number.isInteger(position) || position < 0 || position > this.#items.length) return;
		this.#items.splice(position, 0, this.#new_item(''));
		this.#rerender(position);
		this.#save_now();
	}

	/**
	 * Inserts a new item after the focused one, splitting the content at the
	 * caret when content follows it.
	 * @param {number} index - The focused item position.
	 */
	#split_or_append(index) {
		const item = this.#items[index];
		if (!item) return;
		const value_el = this.#value_el(index);
		let after_html = '';
		const selection = window.getSelection();
		if (selection && selection.rangeCount > 0 && value_el && value_el.contains(selection.anchorNode)) {
			const range = selection.getRangeAt(0);
			const after_range = range.cloneRange();
			after_range.selectNodeContents(value_el);
			after_range.setStart(range.endContainer, range.endOffset);
			const holder = document.createElement('div');
			holder.appendChild(after_range.extractContents());
			after_html = holder.innerHTML;
			item.value = value_el.innerHTML;
		}
		this.#items.splice(index + 1, 0, this.#new_item(after_html));
		this.#rerender(index + 1);
		this.#save_now();
	}

	/**
	 * Removes an item and focuses the end of the previous one. The list never
	 * empties: removing the last remaining item leaves one empty item.
	 * @param {number} index - The item position.
	 */
	#remove_item(index) {
		this.#items.splice(index, 1);
		if (this.#items.length === 0) {
			this.#items = [this.#new_item('')];
		}
		this.#rerender(Math.max(0, Math.min(index - 1, this.#items.length - 1)));
		this.#save_now();
	}

	/**
	 * Appends a new empty item to the end and focuses it.
	 */
	#append_item() {
		this.#items.push(this.#new_item(''));
		this.#rerender(this.#items.length - 1);
		this.#save_now();
	}

	//endregion

	//region Choice Suggestions

	/**
	 * Returns whether a choice counts as "new": its plain text is shorter
	 * than two characters, so it is effectively an empty slot.
	 * @param {string} value - The choice's inline html.
	 * @returns {boolean} True when the choice is new.
	 */
	#is_new_choice(value) {
		return strip_tags(String(value || '').replace(/<br\s*\/?>/gi, '')).trim().length < 2;
	}

	/**
	 * Toggles one item's wand button from the item's current text.
	 * @param {number} index - The item position.
	 * @param {HTMLElement} value_el - The item's editable element.
	 */
	#sync_suggest_button(index, value_el) {
		const button = this.#root?.querySelector(`.div-choices-suggest[data-index="${index}"]`);
		if (button) {
			button.style.display = this.#is_new_choice(value_el.innerHTML) ? '' : 'none';
		}
	}

	/**
	 * Suggests a choice for a new item: sends the list and the item's
	 * position to the suggest endpoint and fills the item with the returned
	 * choice. Reasoning streams into the thinking panel.
	 * @param {number} index - The target item position.
	 * @returns {boolean} False when a suggestion is already running or the item is not new.
	 */
	suggest(index) {
		if (this.#suggesting) return false;
		const item = this.#items[index];
		if (!item || !this.#is_new_choice(item.value)) return false;
		const app = this.#app;
		const guid = this.#content_guid || '';
		if (!app) return false;

		this.#suggesting = true;
		this.#suggest_button = this.#root?.querySelector(`.div-choices-suggest[data-index="${index}"]`) || null;
		if (this.#suggest_button) this.#suggest_button.disabled = true;
		this.#panel.begin_run(guid);

		const { model_key, thinking_level } = app.response_pipeline.resolve_conversation_model();
		const meta_context = app.response_pipeline.conversation_search_enabled() ? { search: true } : {};
		const api = app.api || this.#api;
		api.post(
			{
				items: this.get_content(),
				position: index,
				model_key: model_key,
				thinking: thinking_level,
				meta_context: meta_context,
				local_thinking: true
			},
			(response) => this.#complete_suggest_run(index, guid, response),
			(error) => this.#fail_suggest_run(guid, error),
			'suggest',
			(text) => this.#panel.stream_line(guid, text, app),
			(bytes) => app.response_pipeline?.update_progress(bytes, guid),
			guid
		);
		return true;
	}

	/**
	 * Finishes a suggestion on success: fills the target item with the
	 * returned choice, saves, and finalizes the panel through the shared
	 * cost block and persist.
	 * @param {number} index - The target item position.
	 * @param {string} guid - The conversation guid.
	 * @param {Object} response - The endpoint response.
	 */
	#complete_suggest_run(index, guid, response) {
		this.#suggesting = false;
		this.#panel.end_run(guid);
		if (this.#suggest_button) this.#suggest_button.disabled = false;
		if (response && response.error) {
			this.#panel.set_output(guid, 'ERROR: ' + response.error);
			this.#panel.persist(guid);
			return;
		}
		const choice = String(response?.choice || '').trim();
		if (!choice || !this.#items[index]) {
			this.#panel.set_output(guid, 'No suggestion returned.');
			this.#panel.persist(guid);
			return;
		}
		this.#items[index].value = this.#plain_to_html(choice);
		this.#rerender(index);
		this.#save_now();
		this.#panel.append_html(guid, cost_block_html(formatCostSummary(response)));
		this.#panel.persist(guid);
	}

	/**
	 * Finishes a suggestion after a transport failure: ends the streaming
	 * block, adds the error output, persists the panel log.
	 * @param {string} guid - The conversation guid.
	 * @param {Object} error - The transport error.
	 */
	#fail_suggest_run(guid, error) {
		this.#suggesting = false;
		this.#panel.end_run(guid);
		if (this.#suggest_button) this.#suggest_button.disabled = false;
		this.#panel.set_output(guid, 'ERROR: ' + (error?.error || error?.message || 'Suggestion request failed.'));
		this.#panel.persist(guid);
	}

	//endregion

	//region Helpers

	/**
	 * Normalizes a stored choice item, assigning a guid when missing.
	 * @param {Object} item - The stored item.
	 * @returns {Object} The normalized item.
	 */
	#normalize_item(item) {
		return { value: item?.value || '', guid: item?.guid || crypto.randomUUID() };
	}

	/**
	 * Creates a new choice item with a fresh guid.
	 * @param {string} value - The item's inline html.
	 * @returns {Object} The item.
	 */
	#new_item(value) {
		return { value: value, guid: crypto.randomUUID() };
	}

	/**
	 * Serializes the items back into conversation storage.
	 * @returns {Array<Object>} The item array.
	 */
	#serialize() {
		return this.#items.map(item => ({ value: item.value, guid: item.guid }));
	}

	/**
	 * 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 the editable element of an item.
	 * @param {number} index - The item position.
	 * @returns {HTMLElement|null} The editable element.
	 */
	#value_el(index) {
		return this.#root ? this.#root.querySelector(`.div-document-item[data-index="${index}"] .div-document-value`) : null;
	}

	/**
	 * Rebuilds the item list and restores focus.
	 * @param {number} [focus_index=-1] - The item to focus after the rebuild.
	 */
	#rerender(focus_index = -1) {
		if (!this.#root) return;
		this.#suppress_focusout = true;
		this.#root.innerHTML = this.#items_html();
		this.#suppress_focusout = false;
		if (focus_index >= 0) {
			this.#focus_item(focus_index);
		}
	}

	/**
	 * Focuses an item's editable element and places the caret.
	 * @param {number} index - The item position.
	 * @param {boolean} [at_start=false] - Whether to place the caret at the start.
	 */
	#focus_item(index, at_start = false) {
		const el = this.#value_el(index);
		if (!el) return;
		el.focus();
		const range = document.createRange();
		range.selectNodeContents(el);
		range.collapse(at_start);
		const selection = window.getSelection();
		selection.removeAllRanges();
		selection.addRange(range);
	}

	/**
	 * Returns whether an item's content is empty.
	 * @param {HTMLElement} value_el - The editable element.
	 * @returns {boolean} True when the item holds no content.
	 */
	#is_empty_value(value_el) {
		return value_el.innerHTML.replace(/<br\s*\/?>/gi, '').trim() === '';
	}

	/**
	 * Returns whether the caret sits at the very start of an item.
	 * @param {HTMLElement} value_el - The editable element.
	 * @returns {boolean} True when the caret is at the start.
	 */
	#caret_at_start(value_el) {
		const selection = window.getSelection();
		if (!selection || selection.rangeCount === 0) return false;
		const range = selection.getRangeAt(0);
		if (!range.collapsed) return false;
		const probe = document.createRange();
		probe.selectNodeContents(value_el);
		probe.setEnd(range.startContainer, range.startOffset);
		return probe.toString() === '';
	}

	/**
	 * Returns whether the caret sits at the very end of an item.
	 * @param {HTMLElement} value_el - The editable element.
	 * @returns {boolean} True when the caret is at the end.
	 */
	#caret_at_end(value_el) {
		const selection = window.getSelection();
		if (!selection || selection.rangeCount === 0) return false;
		const range = selection.getRangeAt(0);
		if (!range.collapsed) return false;
		const probe = document.createRange();
		probe.selectNodeContents(value_el);
		probe.setStart(range.endContainer, range.endOffset);
		return probe.toString() === '';
	}

	/**
	 * Schedules a debounced save of the choice items.
	 */
	#debounce_save() {
		clearTimeout(this.#save_timer);
		this.#save_timer = setTimeout(() => this.#save_now(), 400);
	}

	/**
	 * Saves the choice items immediately through the change callback.
	 */
	#save_now() {
		clearTimeout(this.#save_timer);
		this.#save_timer = null;
		if (this.#on_change) {
			this.#on_change(this.#serialize());
		}
	}

	//endregion

}

export default ChoicesEditor;
🌐
choices-editor.js ×
Type: Web, text/x-c++
28.11 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File