-
❓ .gitignoreUnknown, 37 B⬇
-
❓ README.mdUnknown, 2.94 kB⬇
-
🌐 admin_panel.phpWeb, 1.54 kB⬇
-
🌐 api.jsWeb, 22.21 kB⬇
-
🌐 api.phpWeb, 9.62 kB⬇
-
🌐 app.jsWeb, 61.42 kB⬇
-
🌐 attachments.jsWeb, 7.30 kB⬇
-
🌐 base.cssWeb, 2.23 kB⬇
-
🌐 choices-editor.jsWeb, 28.11 kB⬇
-
🌐 choices.cssWeb, 1.14 kB⬇
-
🌐 content.jsWeb, 31.08 kB⬇
-
🌐 context.jsWeb, 9.26 kB⬇
-
🌐 conversation-index.jsWeb, 10.74 kB⬇
-
🌐 conversation.cssWeb, 16.33 kB⬇
-
🌐 conversation.jsWeb, 1.11 kB⬇
-
🌐 dialogs.cssWeb, 11.43 kB⬇
-
🌐 diction.jsWeb, 948 B⬇
-
🌐 document-index.jsWeb, 1.04 kB⬇
-
🌐 document-model.jsWeb, 12.88 kB⬇
-
🌐 document.cssWeb, 6.74 kB⬇
-
🌐 document.jsWeb, 44.96 kB⬇
-
🌐 dynamic.phpWeb, 533 B⬇
-
📷 favicon-lg.pngImage, 9.11 kB⬇
-
📷 favicon.pngImage, 3.12 kB⬇
-
🌐 index-lists.cssWeb, 4.77 kB⬇
-
🌐 index.phpWeb, 49.72 kB⬇
-
🌐 indexjs.jsWeb, 377 B⬇
-
🌐 instant-answer.cssWeb, 5.17 kB⬇
-
🌐 layout.cssWeb, 11.37 kB⬇
-
🌐 login.phpWeb, 997 B⬇
-
❓ manifest.webmanifestUnknown, 262 B⬇
-
🌐 memory.jsWeb, 407 B⬇
-
🌐 mermaid.cssWeb, 6.16 kB⬇
-
🌐 model-presets.jsonWeb, 5.51 kB⬇
-
🌐 models.jsonWeb, 8.05 kB⬇
-
🌐 notes.cssWeb, 11.97 kB⬇
-
🌐 overlays.cssWeb, 13.97 kB⬇
-
⚠️ php.iniOS File, 424 B⬇
-
🌐 rankings-view.jsWeb, 5.67 kB⬇
-
🌐 rankings.cssWeb, 3.35 kB⬇
-
🌐 scratchpad-conversation.jsWeb, 4.87 kB⬇
-
🌐 scratchpad-index.jsWeb, 15.55 kB⬇
-
🌐 scratchpad-model.jsWeb, 13.29 kB⬇
-
🌐 scratchpad.cssWeb, 3.12 kB⬇
-
🌐 settings.cssWeb, 4.65 kB⬇
-
🌐 shared-view.cssWeb, 3.48 kB⬇
-
🌐 shared.cssWeb, 16.26 kB⬇
-
🌐 shared.jsWeb, 14.63 kB⬇
-
🌐 shared.phpWeb, 3.28 kB⬇
-
🌐 sticky-notes-editor.jsWeb, 64.84 kB⬇
-
🌐 storage.jsWeb, 36.73 kB⬇
-
🌐 style.cssWeb, 12.06 kB⬇
-
🌐 sw.jsWeb, 1.69 kB⬇
-
🌐 sync.jsWeb, 19.02 kB⬇
-
🌐 tools-index.jsWeb, 15.00 kB⬇
-
🌐 users.jsWeb, 49.86 kB⬇
-
🌐 utility-models.jsonWeb, 1.04 kB⬇
-
🌐 voice-models.jsonWeb, 4.81 kB⬇
import { 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
Type: Web, text/plain
12.88 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File