-
❓ .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 { 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">✎</button>
<span class="span-document-gripper" title="Reorder">‖</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) + '.' : '•'}</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
Type: Web, text/plain
44.96 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File