9 directories, 58 files

tinai

Home / tinai
import { getEl, strip_tags, contains_focus, watch_compact_select_label, watch_compact_button_label } from './util/dom-utils.js';
import { copyToClipboard } from './util/async-utils.js';
import { escapeHtml, headerLevel, build_export_html } from './util/format-utils.js';
import { openPluginDialog } from './util/plugin-dialog.js';
import { customConfirm, customAlert } from './util/confirm-dialog.js';
import { export_content } from './util/export-utils.js';
import { exportDocumentPdf } from './util/pdf-export.js';
import Api from './api.js';
import { replayInteractiveRequests } from './util/interactive-tools.js';
import { scroller_of } from './util/scrollbars.js';
import { create_reorder_drag } from './util/drag-reorder.js';
import { apply_inline_format, sync_inline_format_states } from './util/inline-format.js';
import DocumentModel from './document-model.js';
import ThinkingPanelBase from './util/thinking-panel-base.js';

/**
 * Class Document
 * Block editor for document conversations. Authors the same typed content
 * blocks that AI conversations render (text, header, list-step, link), stored
 * as an array on the conversation's DOCUMENT field.
 *
 * The editor orchestrates: the block data lives in the DocumentModel, the
 * thinking/activity panel on the shared ThinkingPanelBase, and this class binds
 * them to the editing surface, toolbars, dialogs, drag reordering, and the
 * request lifecycle.
 */
class Document {
	#model = new DocumentModel();
	#on_change = null;
	#container = null;
	#root = null;
	#format_toolbar = null;
	#type_toolbar = null;
	#save_timer = null;
	#active_index = -1;
	#dialog_index = -1;
	#dialog_handle = null;
	#format_show_timer = null;
	#format_hide_timer = null;
	#suppress_focusout = false;
	#api = new Api();
	#panel = new ThinkingPanelBase({
		on_highlight: (guid) => this.#set_item_highlight(guid),
		replay: (guid) => {
			if (this.#app) {
				replayInteractiveRequests(getEl('div-document-thinking-content'), guid, this.#app);
			}
		},
	});
	#highlighted_item = null;
	#flash_item_el = null;
	#flash_guid = null;
	#flash_timer = null;
	#preview_mode = false;
	#content_guid = null;
	#app = null;
	#asking_guids = new Set();
	#drag = create_reorder_drag({
		draggable_of: (e) => {
			if (this.#preview_mode) return null;
			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: () => getEl('div-chat-document-scroll'),
		commit: (drag) => {
			const position = parseInt(drag.target_el?.dataset.position || '', 10);
			if (Number.isInteger(position) && this.#model.move_item(drag.index, position)) {
				this.#rerender();
				this.#save_now(false);
			}
		}
	});

	/**
	 * Determines whether a block type supports the type-conversion toolbar.
	 * @param {string} type - The block type.
	 * @returns {boolean} True when the type supports direct conversion.
	 */
	static is_convertible(type) {
		return DocumentModel.is_convertible(type);
	}

	/**
	 * 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 document items.
	 * @returns {boolean} True when both hold the same content.
	 */
	matches_items(items) {
		return this.#model.equals(items);
	}

	/**
	 * Renders the editor: anchored toolbars plus one editable block per item.
	 * @param {Array} [items=[]] - The stored document items.
	 * @returns {string} The editor HTML.
	 */
	render(items = []) {
		this.#model.load(items);
		this.#active_index = -1;
		return `
			<div class="div-document-editor">
				<div class="div-document-items">${this.#items_html()}</div>
			</div>
		`;
	}

	/**
	 * Wires the delegated editing events and the change callback. Event
	 * listeners bind once per container; the item root and toolbars are
	 * re-resolved on every call since rebuilds replace them.
	 * @param {Function|null} [on_change=null] - Called with the items array after edits.
	 */
	attachEventListeners(on_change = null) {
		this.#on_change = on_change;
		const container = getEl('div-document-container');
		if (!container) return;
		if (container !== this.#container) {
			this.#container = container;
			this.#bind_container_listeners(container);
			this.#panel.bind(container);
			this.#handle_copy_and_export();
			this.#bind_document_preview();
			this.#bind_document_close();
			// flush a pending debounced save before the page goes away
			window.addEventListener('beforeunload', () => this.#save_now());
		}
		this.#root = container.querySelector('.div-document-items');
		this.#format_toolbar = container.querySelector('.div-document-format-toolbar');
		this.#type_toolbar = container.querySelector('.div-document-toolbar');
		this.#apply_preview_state();
	}

	/**
	 * Binds the document Copy button (clipboard with formatting) and the
	 * Export dropdown (minimal HTML5 file download).
	 */
	#handle_copy_and_export() {
		const copy_btn = getEl('btn-document-copy');
		if (copy_btn) {
			copy_btn.addEventListener('click', async () => {
				const body = document.querySelector('.div-document-items');
				if (body) {
					await copyToClipboard(body, copy_btn);
				}
			});
		}
		const export_select = getEl('id-select-document-export');
		if (export_select) {
			watch_compact_select_label(export_select, 'Export', 'XPT.');
			export_select.addEventListener('change', () => {
				const format = export_select.value;
				export_select.value = '';
				if (!format) return;
				export_content(format, this.#document_title(), {
					pdf: () => exportDocumentPdf(this.#model.items, this.#model.markers(), this.#document_title()),
					html: (mobile) => this.#build_export_html(mobile),
					markdown: () => this.#build_export_markdown()
				}).catch(err => customAlert('Export Failed', String(err?.message || err)));
			});
		}
	}

	/**
	 * Binds the document Preview button: toggles the read-only preview mode.
	 */
	#bind_document_preview() {
		const preview_btn = getEl('id-btn-document-preview');
		if (preview_btn) {
			watch_compact_button_label(preview_btn, 'Preview', 'Pvw.');
			preview_btn.addEventListener('click', () => this.#set_preview_mode(!this.#preview_mode));
		}
	}

	/**
	 * Binds the document Close button: returns to the conversation view.
	 */
	#bind_document_close() {
		const close_btn = getEl('btn-document-close');
		if (close_btn) {
			close_btn.addEventListener('click', () => this.#app?.close_conversation?.());
		}
	}

	/**
	 * Resolves the document title from the conversation storage.
	 * @returns {string} The document title, or a fallback.
	 */
	#document_title() {
		const guid = this.#content_guid;
		const title = guid && this.#app ? this.#app.storage.get_conversation(guid)?.[this.#app.storage.KEY_CONVERSATION_TITLE] : null;
		return title || 'document';
	}

	/**
	 * Builds the export HTML for a single document item: plain structural
	 * markup only (h1-h6 for headers by level, p for text, li for list steps,
	 * a for links).
	 * @param {Object} item - The document item.
	 * @returns {string} The item's HTML fragment.
	 */
	#export_item_html(item) {
		const value = item.value || '';
		if (item.type === 'header') {
			return `<h${headerLevel(item)}>${escapeHtml(strip_tags(value))}</h${headerLevel(item)}>`;
		}
		if (item.type === 'list-step') {
			return `<li>${escapeHtml(strip_tags(value))}</li>`;
		}
		if (item.type === 'link') {
			return `<p><a href="${escapeHtml(item.url || '')}">${escapeHtml(strip_tags(value))}</a></p>`;
		}
		return `<p>${this.#model.clean_value(value)}</p>`;
	}

	/**
	 * Builds the export markdown for a single document item: headings by
	 * level, list markers for list steps, links as markdown links.
	 * @param {Object} item - The document item.
	 * @param {number|null} marker - The displayed list number for ordered items.
	 * @returns {string} The item's markdown fragment.
	 */
	#export_item_markdown(item, marker) {
		const value = strip_tags(item.value || '').trim();
		if (item.type === 'header') {
			return '#'.repeat(headerLevel(item)) + ' ' + value;
		}
		if (item.type === 'list-step') {
			return (item.ordered ? (marker || 1) + '.' : '-') + ' ' + value;
		}
		if (item.type === 'link') {
			return `[${value}](${item.url || ''})`;
		}
		return value;
	}

	/**
	 * Builds the full export HTML for the document: a minimal HTML5 template
	 * with the document blocks converted to plain structural markup. Contiguous
	 * list-step blocks are wrapped in a single ul.
	 * @param {boolean} [mobile=false] - Whether to emit the mobile variant.
	 * @returns {string} The complete HTML5 document.
	 */
	#build_export_html(mobile = false) {
		let body = '';
		let list_open = false;
		for (const item of this.#model.items) {
			const is_list = item.type === 'list-step';
			if (is_list && !list_open) {
				body += '<ul>';
				list_open = true;
			} else if (!is_list && list_open) {
				body += '</ul>';
				list_open = false;
			}
			body += this.#export_item_html(item);
		}
		if (list_open) {
			body += '</ul>';
		}
		return build_export_html(this.#document_title(), body, mobile);
	}

	/**
	 * Builds the full export markdown for the document: contiguous list-step
	 * blocks form one list, other blocks are separated by blank lines.
	 * @returns {string} The markdown document.
	 */
	#build_export_markdown() {
		const markers = this.#model.markers();
		const parts = [];
		let list = [];
		const flush_list = () => {
			if (list.length) parts.push(list.join('\n'));
			list = [];
		};
		this.#model.items.forEach((item, i) => {
			if (item.type === 'list-step') {
				list.push(this.#export_item_markdown(item, markers[i]));
				return;
			}
			flush_list();
			parts.push(this.#export_item_markdown(item, markers[i]));
		});
		flush_list();
		return parts.filter(part => part.trim() !== '').join('\n\n') + '\n';
	}

	/**
	 * Toggles preview mode: hides the toolbars, strips item borders, edit
	 * controls, and grippers, and makes the content read-only. The editor
	 * stays in the selected mode across re-renders.
	 * @param {boolean} preview - True for preview mode, false for editing.
	 */
	#set_preview_mode(preview) {
		this.#preview_mode = preview;
		const container = getEl('div-document-container');
		if (container) {
			container.classList.toggle('preview-mode', preview);
		}
		const preview_btn = getEl('id-btn-document-preview');
		if (preview_btn) {
			preview_btn.textContent = preview ? 'Edit' : 'Preview';
		}
		for (const value_el of (container?.querySelectorAll('.div-document-value') || [])) {
			value_el.setAttribute('contenteditable', preview ? 'false' : 'true');
		}
	}

	/**
	 * Re-applies the current preview mode to freshly rendered content.
	 */
	#apply_preview_state() {
		const container = getEl('div-document-container');
		if (!container) return;
		container.classList.toggle('preview-mode', this.#preview_mode);
		for (const value_el of container.querySelectorAll('.div-document-value')) {
			value_el.setAttribute('contenteditable', this.#preview_mode ? 'false' : 'true');
		}
	}

	/**
	 * Highlights a document item element as the anchor of a selected finding,
	 * and scrolls it into view. A null guid clears the highlight and any
	 * active edit flash.
	 * @param {string|null} guid - The item guid, or null to clear.
	 */
	#set_item_highlight(guid) {
		this.#clear_item_highlight();
		this.#clear_flash();
		if (!guid) return;
		const el = this.#root?.querySelector(`.div-document-item[data-guid="${guid}"]`) || null;
		if (!el) return;
		this.#highlighted_item = el;
		el.classList.add('document-item-highlighted');
		this.#scroll_item_into_view(el);
	}

	/**
	 * Removes the background highlight from the previously selected item.
	 */
	#clear_item_highlight() {
		if (this.#highlighted_item) {
			this.#highlighted_item.classList.remove('document-item-highlighted');
			this.#highlighted_item = null;
		}
	}

	/**
	 * Removes the model-edit flash highlight from the previously flashed item.
	 */
	#clear_flash() {
		clearTimeout(this.#flash_timer);
		this.#flash_timer = null;
		if (this.#flash_item_el) {
			this.#flash_item_el.classList.remove('document-item-flash');
			this.#flash_item_el = null;
		}
	}

	/**
	 * Scrolls the document so an item is fully visible: an item coming in
	 * from below is anchored at its bottom edge, one coming from above at its
	 * top edge, and an item taller than the view is anchored at its bottom
	 * edge. A fully visible item does not scroll. The jump is instant so
	 * rapid edit batches cannot interrupt a smooth animation halfway.
	 * @param {HTMLElement} el - The item element.
	 */
	#scroll_item_into_view(el) {
		const scroll = scroller_of(getEl('div-chat-document-scroll'));
		if (!scroll) {
			el.scrollIntoView({ block: 'end' });
			return;
		}
		const view = scroll.getBoundingClientRect();
		const rect = el.getBoundingClientRect();
		let top = null;
		if (rect.height > view.height || rect.bottom > view.bottom) {
			top = rect.bottom - view.bottom + scroll.scrollTop;
		} else if (rect.top < view.top) {
			top = rect.top - view.top + scroll.scrollTop;
		}
		if (top === null) return;
		scroll.scrollTo({ top, behavior: 'auto' });
	}

	/**
	 * Highlights a document item by guid and scrolls it into view, used as
	 * feedback while tool edits apply. The highlight fades out over three
	 * seconds through the document-item-flash animation, then the class is
	 * cleared.
	 * @param {string} guid - The item guid.
	 */
	#flash_item(guid) {
		this.#clear_flash();
		const el = this.#root?.querySelector(`.div-document-item[data-guid="${guid}"]`) || null;
		if (!el) return;
		this.#flash_item_el = el;
		this.#flash_guid = guid;
		// restart the animation when the same item flashes again
		void el.offsetWidth;
		el.classList.add('document-item-flash');
		this.#scroll_item_into_view(el);
		this.#flash_timer = setTimeout(() => this.#clear_flash(), 3100);
	}

	/**
	 * Switches the panel and editor to the given conversation.
	 * @param {string} guid - The conversation guid to display.
	 * @param {App|null} [app=null] - The application instance.
	 */
	set_active_guid(guid, app = null) {
		if (app) this.#app = app;
		this.#panel.set_app(app);
		guid = guid || '';
		if (this.#content_guid === guid) return;
		if (this.#save_timer) this.#save_now(true);
		if (!this.#panel.set_active_guid(guid)) return;
		this.#content_guid = guid;
	}

	/**
	 * Sends the prompt with the document's structured content to the documents
	 * 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);
		// during the run the editor becomes read-only so tool edits do not
		// overlap with user edits; the original mode returns when the last
		// running prompt finishes
		const was_preview = this.#preview_mode;
		if (!was_preview) {
			this.#set_preview_mode(true);
		}
		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.#model.get_content(),
				model_key: options.model_key || '',
				thinking: options.thinking || '',
				meta_context: options.meta_context || {},
				local_thinking: true
			},
			(response) => this.#complete_run(target_guid, was_preview, response, options),
			(error) => this.#fail_run(target_guid, was_preview, error, options),
			'documents',
			(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, adds the findings
	 * or the text output plus the cost summary through the shared panel
	 * lifecycle, persists the panel log, and restores the editor mode.
	 * @param {string} guid - The conversation guid the run belongs to.
	 * @param {boolean} was_preview - The editor mode before the run.
	 * @param {Object} response - The endpoint response.
	 * @param {object} options - The ask options.
	 */
	#complete_run(guid, was_preview, response, options) {
		this.#finish_run(guid, was_preview);
		const ok = this.#panel.finish_run(guid, response);
		if (!ok) {
			options.on_error?.();
			return;
		}
		// the run's final output carries a title for the whole document; the
		// summary utility only runs when the response did not provide one
		const pipeline = options.app?.response_pipeline;
		if (pipeline) {
			const title = String(response?.document_title || '').trim();
			if (title) {
				pipeline.apply_document_title(guid, title);
			} else {
				void pipeline.update_document_title(guid);
			}
		}
		options.on_done?.();
	}

	/**
	 * Finishes a run after a transport failure: ends the streaming block,
	 * adds the error output through the shared panel lifecycle, persists the
	 * panel log, and restores the editor mode.
	 * @param {string} guid - The conversation guid the run belongs to.
	 * @param {boolean} was_preview - The editor mode before the run.
	 * @param {Object} error - The transport error.
	 * @param {object} options - The ask options.
	 */
	#fail_run(guid, was_preview, error, options) {
		this.#finish_run(guid, was_preview);
		this.#panel.fail_run(guid, error, 'Document request failed.');
		options.on_error?.();
	}

	/**
	 * Ends the last running request: drops redundant ordered list indexes and
	 * restores the editor mode when no other run is active.
	 * @param {string} guid - The conversation guid the run belongs to.
	 * @param {boolean} was_preview - The editor mode before the run.
	 */
	#finish_run(guid, was_preview) {
		this.#asking_guids.delete(guid);
		if (this.#asking_guids.size === 0) {
			this.#cleanup_ordered_indexes();
			this.#set_preview_mode(was_preview);
			// the mode switch changes item heights: bring the last flashed
			// item back so it stays fully in view
			if (this.#flash_guid) {
				const el = this.#root?.querySelector(`.div-document-item[data-guid="${this.#flash_guid}"]`) || null;
				if (el) {
					this.#scroll_item_into_view(el);
				}
			}
		}
	}

	/**
	 * Runs when the last running request finishes and before the editor
	 * becomes editable again: drops ordered list indexes that only restate
	 * the implicit numbering (models tend to send the number along) and saves
	 * when something was removed.
	 */
	#cleanup_ordered_indexes() {
		if (this.#model.normalize_ordered_indexes()) {
			this.#save_now(false);
		}
	}

	/**
	 * Returns the current document content blocks.
	 * @returns {Array<Object>} The content block array.
	 */
	get_content() {
		return this.#model.get_content();
	}

	/**
	 * Applies a normalized edit_document edit to the open document: add (with
	 * an optional guid anchor, "last"/"first" dynamic anchors, appended at the
	 * end when absent), replace, move (with a target_guid anchor) and remove.
	 * The editor rerenders, saves, and flashes the affected item.
	 * @param {Object} edit - The normalized 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 result = this.#model.apply_edit(edit);
		if (!result) return null;
		this.#rerender();
		this.#save_now(result.contentChanged);
		this.#apply_preview_state();
		if (result.flashGuid) {
			this.#flash_item(result.flashGuid);
		}
		return { guid: result.guid };
	}

	/**
	 * Binds the delegated editing event listeners to the scroll container.
	 * @param {HTMLElement} container - The document scroll container.
	 */
	#bind_container_listeners(container) {
		// keep the text selection alive when using the toolbars
		container.addEventListener('mousedown', (e) => {
			if (e.target.closest('.div-document-toolbar')) e.preventDefault();
		});

		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);
			this.#sync_format_toolbar();
			this.#sync_type_selection();
		});

		container.addEventListener('focusout', (e) => {
			// rebuilds drop focus momentarily; ignore that focusout so the
			// toolbar does not hide and re-show
			if (this.#suppress_focusout) return;
			if (!container.contains(e.relatedTarget)) {
				clearTimeout(this.#format_show_timer);
				this.#format_show_timer = null;
				clearTimeout(this.#format_hide_timer);
				this.#format_hide_timer = null;
				this.#set_format_toolbar(false);
				this.#sync_type_selection();
				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.#model.item_at(index)) return;
			this.#model.set_value(index, value_el.innerHTML);
			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 === 'Tab' && !e.shiftKey && this.#model.item_at(index)?.type === 'text') {
				e.preventDefault();
				this.#model.convert_item(index, 'list-step', false);
				this.#rerender(index);
				this.#save_now();
			} else if (e.key === 'Tab' && e.shiftKey && this.#model.item_at(index)?.type === 'list-step') {
				e.preventDefault();
				this.#model.convert_item(index, 'text', false);
				this.#rerender(index);
				this.#save_now();
			} else 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.#model.count - 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) {
				// tapping empty space appends a new item to the end,
				// unless the last item is already empty; focus it instead
				if (!this.#preview_mode && e.target.closest('.div-document-items') && !e.target.closest('.div-document-item')) {
					const last_index = this.#model.count - 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();
					}
				}
				return;
			}
			this.#handle_action(button.dataset.action, button);
		});

		document.addEventListener('selectionchange', () => {
			this.#sync_format_states();
		});
	}

	/**
	 * Builds the hover controls for an item: the edit pencil (opens the item
	 * dialog, which also carries Delete) and the reorder gripper.
	 * @param {Object} item - The normalized item.
	 * @returns {string} The controls HTML.
	 */
	#controls_html(item) {
		return `
			<div class="div-document-item-controls">
				<button type="button" class="as-icon div-document-item-edit" data-action="open-item-dialog" title="Edit">&#9998;</button>
				<span class="span-document-gripper" title="Reorder">&#x2016;</span>
			</div>
		`;
	}

	/**
	 * Builds the HTML for all editor blocks, using the model's computed list
	 * markers for ordered items.
	 * @returns {string} The blocks HTML.
	 */
	#items_html() {
		const markers = this.#model.markers();
		let html = `<button type="button" class="div-document-insert" data-action="insert-item" data-position="0" title="Insert item"></button>`;
		this.#model.items.forEach((item, index) => {
			html += this.#item_html(item, index, markers[index]);
			html += `<button type="button" class="div-document-insert" data-action="insert-item" data-position="${index + 1}" title="Insert item"></button>`;
		});
		return html;
	}

	/**
	 * Builds the HTML for one editor block.
	 * @param {Object} item - The normalized item.
	 * @param {number} index - The item position.
	 * @param {number|null} marker_number - The displayed list number for ordered items.
	 * @returns {string} The block HTML.
	 */
	#item_html(item, index, marker_number = null) {
		if (item.type === 'link') {
			const linked = (item.url || item.anchor) ? ' div-document-value-link' : '';
			return `
				<div class="div-document-item div-document-item-link" data-index="${index}" data-guid="${item.guid}">
					<div class="div-document-value${linked}" contenteditable="true" data-index="${index}">${item.value}</div>
					${this.#controls_html(item)}
				</div>
			`;
		}
		const header_id = (item.type === 'header' && item.name) ? ` id="${escapeHtml(item.name)}"` : '';
		const marker = item.type === 'list-step'
			? `<span class="span-document-list-marker">${item.ordered ? (marker_number || 1) + '.' : '&bull;'}</span>`
			: '';
		const value_tag = item.type === 'header' ? `h${headerLevel(item)}` : 'div';
		const value_class = item.type === 'list-step' ? ' div-document-value-list' : '';
		return `
			<div class="div-document-item div-document-item-${item.type}"${header_id} data-index="${index}" data-guid="${item.guid}">
				${marker}<${value_tag} class="div-document-value${value_class}" contenteditable="true" data-index="${index}">${item.value}</${value_tag}>
				${this.#controls_html(item)}
			</div>
		`;
	}

	/**
	 * Highlights the formatting buttons that are active at the caret: bold,
	 * italic, underline, superscript, subscript, inline code, and links.
	 */
	#sync_format_states() {
		sync_inline_format_states(this.#format_toolbar, this.#root);
	}

	/**
	 * Dispatches a toolbar or config button action.
	 * @param {string} action - The action identifier.
	 * @param {HTMLElement} button - The clicked button.
	 */
	#handle_action(action, button) {
		switch (action) {
			case 'type-text':
			case 'type-header':
			case 'type-unordered':
			case 'type-ordered': {
				const type = action === 'type-text' ? 'text' : action === 'type-header' ? 'header' : 'list-step';
				let index = this.#target_index();
				if (index >= 0 && !DocumentModel.is_convertible(this.#model.item_at(index).type)) return;
				if (index < 0) {
					this.#model.append_item(this.#model.new_item({ type: 'text', value: '' }));
					index = this.#model.count - 1;
				}
				this.#model.convert_item(index, type, action === 'type-ordered');
				this.#rerender(index);
				this.#save_now();
				break;
			}
			case 'make-link': {
				let index = (this.#active_index >= 0 && this.#model.item_at(this.#active_index)) ? this.#active_index : this.#target_index();
				if (index < 0) {
					this.#model.append_item(this.#model.new_item({ type: 'link', value: '', url: '', anchor: '' }));
					index = this.#model.count - 1;
				} else if (this.#model.item_at(index).type !== 'link') {
					const item = this.#model.item_at(index);
					this.#model.replace_item(index, { guid: item.guid, type: 'link', value: strip_tags(item.value), url: '', anchor: '' });
				}
				this.#rerender(index);
				this.#save_now();
				this.#open_item_dialog(index);
				break;
			}
			case 'open-item-dialog':
				this.#open_item_dialog(parseInt(button.closest('.div-document-item').dataset.index, 10));
				break;
			case 'insert-item': {
				const position = parseInt(button.dataset.position, 10);
				const prev = this.#model.item_at(position - 1);
				let new_item = this.#model.new_item({ type: 'text', value: '' });
				if (prev && prev.type === 'list-step') {
					// inserting inside a list continues it, matching the list item before
					new_item = this.#model.new_item({ type: 'list-step', value: '', ordered: prev.ordered, index: null });
				}
				this.#model.insert_item(position, new_item);
				this.#rerender(position);
				this.#save_now();
				break;
			}
			case 'delete-item': {
				const index = parseInt(button.closest('.div-document-item').dataset.index, 10);
				void this.#delete_item_with_confirm(index);
				break;
			}
			case 'fmt-bold':
			case 'fmt-italic':
			case 'fmt-underline':
			case 'fmt-strike':
			case 'fmt-superscript':
			case 'fmt-subscript':
			case 'fmt-clear':
			case 'fmt-code':
			case 'fmt-link':
				apply_inline_format(action, this.#root, () => {
					this.#sync_active_value();
					this.#sync_format_states();
					this.#debounce_save();
				});
				break;
		}
	}

	/**
	 * Resolves which item a type action applies to: the focused item, or the
	 * trailing empty text item, or -1 to append a new item.
	 * @returns {number} The item index, or -1 to append.
	 */
	#target_index() {
		if (this.#active_index >= 0 && this.#model.item_at(this.#active_index)) {
			return this.#active_index;
		}
		const last = this.#model.count - 1;
		if (last >= 0 && this.#model.item_at(last).type === 'text' && this.#model.item_at(last).value === '') {
			return last;
		}
		return -1;
	}

	/**
	 * Inserts a new item after the focused one, splitting the content at the
	 * caret when content follows it. List items continue their list.
	 * @param {number} index - The focused item position.
	 */
	#split_or_append(index) {
		const item = this.#model.item_at(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;
		}
		const new_item = item.type === 'list-step'
			? this.#model.new_item({ type: 'list-step', value: after_html, ordered: item.ordered, index: null })
			: this.#model.new_item({ type: 'text', value: after_html });
		this.#model.insert_item(index + 1, new_item);
		this.#rerender(index + 1, true);
		this.#save_now();
	}

	/**
	 * Removes an item and focuses the end of the previous one.
	 * @param {number} index - The item position.
	 */
	#remove_item(index) {
		this.#model.remove_item(index);
		if (this.#model.count === 0) {
			this.#model.append_item(this.#model.new_item({ type: 'text', value: '' }));
		}
		this.#rerender(Math.max(0, Math.min(index - 1, this.#model.count - 1)));
		this.#save_now();
	}

	/**
	 * Confirms with the user, then removes the item.
	 * @param {number} index - The item position.
	 */
	async #delete_item_with_confirm(index) {
		const item = this.#model.item_at(index);
		if (!item) return;
		const preview = strip_tags(item.value).slice(0, 50) || 'this item';
		if (await customConfirm('Delete Item', `Are you sure you want to delete "${preview}"?`)) {
			this.#remove_item(index);
		}
	}

	/**
	 * Appends a new empty text item to the end and focuses it.
	 */
	#append_item() {
		this.#model.append_item(this.#model.new_item({ type: 'text', value: '' }));
		this.#rerender(this.#model.count - 1);
		this.#save_now();
	}

	/**
	 * Opens the item edit dialog with fields for the item's type:
	 * link (URL / internal anchor target), header (anchor name),
	 * ordered list (item number).
	 * @param {number} index - The item position.
	 */
	#open_item_dialog(index) {
		const item = this.#model.item_at(index);
		if (!item) return;
		this.#dialog_index = index;

		const content = document.createElement('div');
		content.className = 'div-plugin-dialog-body';
		let title = 'Edit Item';

		if (item.type === 'link') {
			title = 'Edit Link';
			const targets = this.#link_target_options(item.anchor);
			content.innerHTML = `
				<p><label for="id-document-dialog-url">URL</label></p>
				<input type="text" id="id-document-dialog-url" class="prompt-style-input" placeholder="https://..." value="${escapeHtml(item.url)}">
				<p><label for="id-document-dialog-target">Target</label></p>
				<select id="id-document-dialog-target" class="prompt-style-input"${targets.disabled ? ' disabled' : ''}>
					<option value=""${item.anchor ? '' : ' selected'}>None</option>
					${targets.options}
				</select>
			`;
		} else if (item.type === 'header') {
			title = 'Edit Header';
			content.innerHTML = `
				<p><label for="id-document-dialog-name">Name (anchor)</label></p>
				<input type="text" id="id-document-dialog-name" class="prompt-style-input" placeholder="section-name" value="${escapeHtml(item.name)}">
				<p><label for="id-document-dialog-level">Level</label></p>
				<input type="number" id="id-document-dialog-level" class="prompt-style-input" min="1" max="6" value="${item.index ?? ''}" placeholder="1">
				<button type="button" class="as-icon" data-dialog-action="clear-level">Clear</button>
			`;
		} else if (item.type === 'list-step' && item.ordered) {
			title = 'Edit List Item';
			content.innerHTML = `
				<p><label for="id-document-dialog-number">Item Number</label></p>
				<input type="number" id="id-document-dialog-number" class="prompt-style-input" min="1" value="${item.index ?? ''}" placeholder="Auto">
				<button type="button" class="as-icon" data-dialog-action="clear-number">Clear</button>
			`;
		} else {
			// text and unordered list items: a plain content box for the raw value
			title = 'Edit Item';
			content.innerHTML = `
				<p><label for="id-document-dialog-content">Content</label></p>
				<textarea id="id-document-dialog-content" class="prompt-style-input" rows="4">${escapeHtml(item.value)}</textarea>
			`;
		}

		const actions = document.createElement('div');
		actions.className = 'div-document-item-dialog-actions';
		const delete_btn = document.createElement('button');
		delete_btn.type = 'button';
		delete_btn.className = 'as-icon';
		delete_btn.textContent = 'Delete Item';
		delete_btn.onclick = async () => {
			const index = this.#dialog_index;
			const item = this.#model.item_at(index);
			const preview = strip_tags(item?.value).slice(0, 50) || 'this item';
			// close the edit dialog first so the confirmation sits on top
			this.#dialog_handle?.close();
			if (await customConfirm('Delete Item', `Are you sure you want to delete "${preview}"?`)) {
				this.#remove_item(index);
			} else {
				this.#open_item_dialog(index);
			}
		};
		const apply_btn = document.createElement('button');
		apply_btn.type = 'button';
		apply_btn.className = 'as-icon';
		apply_btn.textContent = 'Apply';
		apply_btn.onclick = () => this.#apply_item_dialog();
		actions.appendChild(delete_btn);
		actions.appendChild(apply_btn);
		content.appendChild(actions);

		this.#dialog_handle = openPluginDialog(title, content, () => {
			this.#dialog_handle = null;
			this.#dialog_index = -1;
		});

		if (item.type === 'link') {
			const url_input = content.querySelector('#id-document-dialog-url');
			const target_select = content.querySelector('#id-document-dialog-target');
			url_input.addEventListener('input', () => {
				if (url_input.value !== '') target_select.value = '';
			});
			target_select.addEventListener('change', () => {
				if (target_select.value !== '') url_input.value = '';
			});
		}
		if (item.type === 'list-step' && item.ordered) {
			const clear_btn = content.querySelector('button[data-dialog-action="clear-number"]');
			const number_input = content.querySelector('#id-document-dialog-number');
			clear_btn.addEventListener('click', () => {
				number_input.value = '';
				this.#apply_item_dialog();
			});
		}
		if (item.type === 'header') {
			const clear_btn = content.querySelector('button[data-dialog-action="clear-level"]');
			const level_input = content.querySelector('#id-document-dialog-level');
			clear_btn.addEventListener('click', () => {
				level_input.value = '';
				this.#apply_item_dialog();
			});
		}
		content.querySelectorAll('input, select').forEach((input) => {
			input.addEventListener('keydown', (e) => {
				if (e.key === 'Enter') {
					e.preventDefault();
					this.#apply_item_dialog();
				}
			});
		});
		const first = content.querySelector('input, select');
		if (first) first.focus();
	}

	/**
	 * Builds the target dropdown options from headers that have a name.
	 * @param {string} current_anchor - The link's current anchor, if any.
	 * @returns {{disabled: boolean, options: string}} The select state and option HTML.
	 */
	#link_target_options(current_anchor) {
		const named = this.#model.items.filter(i => i.type === 'header' && i.name);
		let options = '';
		if (current_anchor && !named.some(h => h.name === current_anchor)) {
			options += `<option value="${escapeHtml(current_anchor)}" selected>${escapeHtml(current_anchor)} (missing)</option>`;
		}
		options += named.map(h => `<option value="${escapeHtml(h.name)}"${h.name === current_anchor ? ' selected' : ''}>${escapeHtml(h.value || h.name)} (#${escapeHtml(h.name)})</option>`).join('');
		return { disabled: named.length === 0 && !current_anchor, options: options };
	}

	/**
	 * Reads the dialog fields into the item, saves, and closes the dialog.
	 */
	#apply_item_dialog() {
		const index = this.#dialog_index;
		const item = this.#model.item_at(index);
		const content = this.#dialog_handle?.content;
		if (!item || !content) return;
		if (item.type === 'link') {
			const target = content.querySelector('#id-document-dialog-target')?.value || '';
			const url = (content.querySelector('#id-document-dialog-url')?.value || '').trim();
			if (target) {
				// selecting a named header makes this an internal anchor and clears the URL
				item.anchor = target;
				item.url = '';
			} else {
				item.url = url;
				item.anchor = '';
			}
		} else if (item.type === 'header') {
			item.name = (content.querySelector('#id-document-dialog-name')?.value || '').trim();
			const raw_level = (content.querySelector('#id-document-dialog-level')?.value || '').trim();
			const level = parseInt(raw_level, 10);
			// an empty level is the default heading level 1
			item.index = Number.isInteger(level) && level >= 1 && level <= 6 ? level : null;
		} else if (item.type === 'list-step') {
			const raw_number = (content.querySelector('#id-document-dialog-number')?.value || '').trim();
			const number = parseInt(raw_number, 10);
			// an empty number is "Default": the list continues numbering on its own
			item.index = raw_number !== '' && Number.isInteger(number) && number > 0 ? number : (raw_number === '' ? null : item.index);
		} else {
			item.value = content.querySelector('#id-document-dialog-content')?.value || '';
		}
		this.#dialog_handle.close();
		this.#rerender(index);
		this.#save_now();
	}

	/**
	 * Highlights the type button matching the focused item.
	 */
	#sync_type_selection() {
		if (!this.#type_toolbar) return;
		const item = this.#model.item_at(this.#active_index);
		let action = null;
		if (item) {
			if (item.type === 'link') action = 'make-link';
			else if (item.type === 'header') action = 'type-header';
			else if (item.type === 'list-step') action = item.ordered ? 'type-ordered' : 'type-unordered';
			else action = 'type-text';
		}
		this.#type_toolbar.querySelectorAll('button[data-action]').forEach(b => b.classList.toggle('selected', b.dataset.action === action));
	}

	/**
	 * Shows or hides the inline formatting toolbar for the focused item.
	 */
	#sync_format_toolbar() {
		if (!this.#format_toolbar) return;
		const item = this.#model.item_at(this.#active_index);
		const convertible = item && DocumentModel.is_convertible(item.type);
		clearTimeout(this.#format_show_timer);
		this.#format_show_timer = null;
		clearTimeout(this.#format_hide_timer);
		this.#format_hide_timer = null;
		const shown = this.#format_toolbar.style.display !== 'none';
		if (convertible) {
			// delay the first open after a tap so the layout shift cannot
			// displace a quick follow-up tap
			if (!shown) {
				this.#format_show_timer = setTimeout(() => {
					this.#format_show_timer = null;
					this.#set_format_toolbar(true);
				}, 500);
			}
			return;
		}
		// delay hiding by the same amount so moving to a new item does not
		// hide and immediately re-show the toolbar
		if (shown) {
			this.#format_hide_timer = setTimeout(() => {
				this.#format_hide_timer = null;
				this.#set_format_toolbar(false);
				this.#sync_type_selection();
			}, 500);
		}
	}

	/**
	 * Shows or hides the formatting toolbar, compensating the scroll position
	 * so the bottom-anchored items do not shift when the bar resizes.
	 * @param {boolean} shown - Whether the toolbar should be visible.
	 */
	#set_format_toolbar(shown) {
		if (!this.#format_toolbar) return;
		const scroll = scroller_of(getEl('div-chat-document-scroll'));
		const before = scroll ? scroll.scrollHeight : 0;
		this.#format_toolbar.style.display = shown ? '' : 'none';
		if (scroll) {
			const delta = scroll.scrollHeight - before;
			if (delta !== 0) scroll.scrollTop += delta;
		}
	}

	/**
	 * Copies the focused item's rendered content back into the model.
	 */
	#sync_active_value() {
		const value_el = this.#value_el(this.#active_index);
		if (value_el && this.#model.item_at(this.#active_index)) {
			this.#model.set_value(this.#active_index, value_el.innerHTML);
		}
	}

	/**
	 * 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 from the model and restores focus.
	 * @param {number} [focus_index=-1] - The item to focus after the rebuild.
	 * @param {boolean} [at_start=false] - Whether to place the caret at the start.
	 */
	#rerender(focus_index = -1, at_start = false) {
		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, at_start);
		}
	}

	/**
	 * 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 document items.
	 */
	#debounce_save() {
		clearTimeout(this.#save_timer);
		this.#save_timer = setTimeout(() => this.#save_now(), 400);
	}

	/**
	 * Saves the document items immediately through the change callback.
	 * @param {boolean} content_changed - Whether document content changed; moves don't change it.
	 */
	#save_now(content_changed = true) {
		clearTimeout(this.#save_timer);
		this.#save_timer = null;
		if (this.#on_change) {
			this.#on_change(this.#model.get_content(), content_changed);
		}
	}
}

export default Document;
🌐
document.js ×
Type: Web, text/plain
44.96 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File