9 directories, 58 files

tinai

Home / tinai
import { strip_tags } from './util/dom-utils.js';

/**
 * Computes the displayed marker for every item: the explicit index for
 * ordered list steps that carry one, otherwise the running count of the
 * ordered list they belong to.
 * @param {Array<Object>} items - The content items.
 * @returns {Array<number|null>} One marker per item, null for non-list items.
 */
export function compute_list_markers(items) {
	const markers = items.map(() => null);
	let run = 0;
	items.forEach((item, index) => {
		if (item.type === 'list-step' && item.ordered) {
			run = Number.isInteger(item.index) ? item.index : run + 1;
			markers[index] = run;
		} else {
			run = 0;
		}
	});
	return markers;
}

/**
 * Class DocumentModel
 * Pure data model for one document page: a flat array of typed content
 * blocks (text, header, list-step, link). Owns normalization, ordered list
 * numbering, serialization, and every structural mutation so the editor only
 * handles presentation and input. Aside from the DOM-based strip_tags helper
 * it holds no browser dependencies, so it can be exercised headlessly.
 *
 * Future document types (journal, book) hold one model per reply/page; new
 * block types extend the normalization, serialization, and rendering switch
 * points without changing this contract.
 */
class DocumentModel {
	#items = [];

	/**
	 * Initializes the model with the stored document items.
	 * @param {Array} [items=[]] - The stored document items.
	 */
	constructor(items = []) {
		this.load(items);
	}

	/**
	 * 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 type === 'text' || type === 'header' || type === 'list-step';
	}

	/**
	 * Loads the stored document items, normalizing unknown shapes and
	 * ordered list numbering.
	 * @param {Array} [items=[]] - The stored document items.
	 */
	load(items = []) {
		this.#items = (Array.isArray(items) ? items : []).map(item => this.#normalize_item(item));
		if (this.#items.length === 0) {
			this.#items = [this.new_item({ type: 'text', value: '' })];
		}
		this.normalize_ordered_indexes();
	}

	/**
	 * The live items array. Treat as read-only; mutations go through the
	 * model methods so serialization stays consistent.
	 * @returns {Array<Object>} The content block array.
	 */
	get items() {
		return this.#items;
	}

	/**
	 * The number of items in the document.
	 * @returns {number} The item count.
	 */
	get count() {
		return this.#items.length;
	}

	/**
	 * Returns the item at a position.
	 * @param {number} index - The item position.
	 * @returns {Object|null} The item, or null when out of range.
	 */
	item_at(index) {
		return this.#items[index] || null;
	}

	/**
	 * Serializes the model back into conversation content blocks.
	 * @returns {Array<Object>} The content block array.
	 */
	get_content() {
		return this.#serialize();
	}

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

	/**
	 * Creates a new editor item with a guid from the given template.
	 * @param {Object} template - The item fields.
	 * @returns {Object} The item with a guid.
	 */
	new_item(template) {
		return { guid: this.#new_guid(), ...template };
	}

	/**
	 * Updates an item's inline content.
	 * @param {number} index - The item position.
	 * @param {string} html - The item's inline html.
	 */
	set_value(index, html) {
		if (this.#items[index]) this.#items[index].value = html;
	}

	/**
	 * Replaces the item at a position with a new block object.
	 * @param {number} index - The item position.
	 * @param {Object} item - The replacement item.
	 */
	replace_item(index, item) {
		if (this.#items[index]) this.#items[index] = item;
	}

	/**
	 * Inserts an item at a position.
	 * @param {number} at - The insertion index.
	 * @param {Object} item - The item to insert.
	 */
	insert_item(at, item) {
		this.#items.splice(at, 0, item);
	}

	/**
	 * Appends an item to the end.
	 * @param {Object} item - The item to append.
	 */
	append_item(item) {
		this.#items.push(item);
	}

	/**
	 * Removes the item at a position.
	 * @param {number} index - The item position.
	 */
	remove_item(index) {
		this.#items.splice(index, 1);
	}

	/**
	 * Moves an item to the position of an insert point. Dropping on the
	 * item's own leading or trailing position is a no-op.
	 * @param {number} from - The item's current index.
	 * @param {number} to - The insert position index.
	 * @returns {boolean} Whether the items changed.
	 */
	move_item(from, to) {
		if (to < 0) to = 0;
		if (to > this.#items.length) to = this.#items.length;
		if (to === from || to === from + 1) return false;
		const [moved] = this.#items.splice(from, 1);
		this.#items.splice(to > from ? to - 1 : to, 0, moved);
		return true;
	}

	/**
	 * Converts the item at a position to the requested type, preserving its
	 * inline content and guid.
	 * @param {number} index - The item position.
	 * @param {string} type - The target type.
	 * @param {boolean} ordered - Whether a list-step conversion is ordered.
	 * @returns {Object|null} The converted item, or null when out of range.
	 */
	convert_item(index, type, ordered) {
		const item = this.#items[index];
		if (!item) return null;
		if (type === 'link') {
			return this.#items[index] = { guid: item.guid, type: 'link', value: strip_tags(item.value), url: '' };
		}
		if (type === 'list-step') {
			return this.#items[index] = { guid: item.guid, type: 'list-step', value: item.value, ordered: ordered, index: null };
		}
		return this.#items[index] = { guid: item.guid, type: type, value: item.value };
	}

	/**
	 * Resolves an edit anchor guid to an item index, supporting the dynamic
	 * "last" and "first" values.
	 * @param {string} guid - The item guid or "last"/"first".
	 * @returns {number} The item index, or -1 when not found.
	 */
	resolve_index(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);
	}

	/**
	 * Builds an editor item from an edit_document item payload, assigning a
	 * fresh guid. Ordered list steps number themselves implicitly unless the
	 * payload carries an explicit index (a deliberate numbering break).
	 * @param {Object} item - The tool item { type, value, name, ordered, index }.
	 * @returns {Object|null} The editor item, or null when the payload is invalid.
	 */
	tool_item(item) {
		if (!item || typeof item.value !== 'string') return null;
		if (!['text', 'header', 'list-step', 'link'].includes(item.type)) return null;
		const built = this.new_item({ type: item.type, value: item.value });
		if (item.type === 'header') {
			built.name = strip_tags(item.name || item.value).slice(0, 100);
			built.index = (Number.isInteger(item.index) && item.index >= 1 && item.index <= 6) ? item.index : null;
		}
		if (item.type === 'list-step') {
			built.ordered = !!item.ordered;
			built.index = (built.ordered && Number.isInteger(item.index)) ? item.index : null;
		}
		if (item.type === 'link' && typeof item.name === 'string' && item.name) {
			built.anchor = item.name;
		}
		return built;
	}

	/**
	 * Computes the displayed list marker for every item: the explicit index
	 * for ordered steps that carry one, otherwise the running count of the
	 * ordered list they belong to.
	 * @returns {Array<number|null>} One marker per item, null for non-list items.
	 */
	markers() {
		return compute_list_markers(this.#items);
	}

	/**
	 * Clears ordered list indexes that merely restate the implicit numbering,
	 * so renumbering stays automatic on edits and reorders. A number that
	 * breaks the run (a deliberate list restart) is kept and re-anchors the
	 * numbering after itself.
	 * @returns {boolean} Whether any explicit index was removed.
	 */
	normalize_ordered_indexes() {
		let run = 0;
		let changed = false;
		for (const item of this.#items) {
			if (item.type === 'list-step' && item.ordered) {
				if (Number.isInteger(item.index) && item.index !== run + 1) {
					run = item.index;
				} else {
					if (item.index !== null) changed = true;
					item.index = null;
					run += 1;
				}
			} else {
				run = 0;
			}
		}
		return changed;
	}

	/**
	 * Applies a normalized edit_document edit to the items: 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.
	 * @param {Object} edit - The normalized edit { action, guid, target_guid, position, item }.
	 * @returns {Object|null} { guid, flashGuid, contentChanged } for 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';
		if (action === 'add') {
			const item = this.tool_item(edit?.item);
			if (!item) return null;
			const anchor = this.resolve_index(edit?.guid);
			const at = anchor === -1 ? this.#items.length : (position === 'above' ? anchor : anchor + 1);
			this.insert_item(at, item);
			return { guid: item.guid, flashGuid: item.guid, contentChanged: true };
		}
		const index = this.resolve_index(edit?.guid);
		if (index === -1) return null;
		if (action === 'replace') {
			const item = this.tool_item(edit?.item);
			if (!item) return null;
			item.guid = this.#items[index].guid;
			this.replace_item(index, item);
			return { guid: item.guid, flashGuid: item.guid, contentChanged: true };
		}
		if (action === 'remove') {
			const [removed] = this.#items.splice(index, 1);
			// the removed item is gone; settle on the item above it, or the
			// new first item when the top one was removed
			const above = this.#items[index - 1] || this.#items[0];
			return { guid: removed?.guid || edit.guid, flashGuid: above?.guid || null, contentChanged: true };
		}
		if (action === 'move') {
			const target = this.resolve_index(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);
			return { guid: moved.guid, flashGuid: moved.guid, contentChanged: false };
		}
		return null;
	}

	/**
	 * Removes empty inline formatting tags left behind by editing.
	 * @param {string} html - The item's inline html.
	 * @returns {string} The cleaned html.
	 */
	clean_value(html) {
		let cleaned = html || '';
		let previous = null;
		while (cleaned !== previous) {
			previous = cleaned;
			cleaned = cleaned.replace(/<(b|i|u|strong|em|code|sup|sub)[^>]*>\s*<\/\1>/gi, '');
		}
		return cleaned;
	}

	/**
	 * Normalizes a stored block item for editing, assigning a guid when missing.
	 * @param {Object} item - The stored block item.
	 * @returns {Object} The normalized item.
	 */
	#normalize_item(item) {
		const type = (item && DocumentModel.is_convertible(item.type)) ? item.type : (item?.type === 'link' ? 'link' : 'text');
		if (type === 'link') {
			const source = (item?.source && item.source[0]) || {};
			return { type: 'link', value: item?.value || source.title || '', url: source.url || '', anchor: item?.anchor || '', guid: item?.guid || this.#new_guid() };
		}
		return {
			type: type,
			value: item?.value || '',
			name: type === 'header' ? (item?.name || '') : '',
			ordered: type === 'list-step' ? !!item?.ordered : false,
			index: (type === 'list-step' && Number.isInteger(item?.index)) ? item.index
				: (type === 'header' && Number.isInteger(item?.index) && item.index >= 1 && item.index <= 6) ? item.index
				: null,
			guid: item?.guid || this.#new_guid()
		};
	}

	/**
	 * Generates a fresh identifier for a document item.
	 * @returns {string} A new item guid.
	 */
	#new_guid() {
		return crypto.randomUUID();
	}

	/**
	 * Serializes the model back into conversation content blocks.
	 * @returns {Array<Object>} The content block array.
	 */
	#serialize() {
		return this.#items.map(item => {
			if (item.type === 'link') {
				if (item.anchor) return { type: 'link', value: item.value, anchor: item.anchor, guid: item.guid };
				if (item.url) return { type: 'link', value: item.value, source: [{ title: item.value, url: item.url }], guid: item.guid };
				return { type: 'link', value: item.value, guid: item.guid };
			}
			if (item.type === 'list-step') {
				return { type: 'list-step', value: this.clean_value(item.value), ordered: item.ordered, index: item.index, guid: item.guid };
			}
			if (item.type === 'header') {
				const header = { type: 'header', value: this.clean_value(item.value), index: item.index, guid: item.guid };
				if (item.name) header.name = item.name;
				return header;
			}
			return { type: item.type, value: this.clean_value(item.value), guid: item.guid };
		});
	}
}

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