6 directories, 29 files

tinai

Home / tinai
import { createElementFromHTML } from './util/dom-utils.js';
import { SelectionManager } from './util/selection-manager.js';
import { customConfirm } from './util/confirm-dialog.js';
import { escapeHtml } from './util/format-utils.js';

/**
 * Class ScratchpadIndex
 * Manages the secondary navigation index for scratchpads.
 * Handles rendering the list of scratchpads (bookmarked and others),
 * managing selections, bookmarking, and deletion.
 */
class ScratchpadIndex {

	#storage;
	#app_callbacks;
	#selection;

	div_index_list;
	btn_scratchpad_index_select;
	btn_scratchpad_index_favorite;
	btn_scratchpad_index_delete;
	btn_show_scratchpads_new;

	BREAKPOINT_MOBILE;

	/**
	 * Initializes the ScratchpadIndex instance with storage, callbacks, and element references.
	 * @param {Storage} storage_instance - Storage manager instance.
	 * @param {object} app_callbacks - Application callback methods.
	 * @param {object} elements - DOM element references.
	 * @param {object} breakpoints - Responsive breakpoint constants.
	 */
	constructor(storage_instance, app_callbacks, elements, breakpoints) {
		this.#storage = storage_instance;
		this.#app_callbacks = app_callbacks;

		this.div_index_list = elements.div_index_list;
		this.btn_scratchpad_index_select = elements.btn_scratchpad_index_select || elements.btn_conversation_index_select;
		this.btn_scratchpad_index_favorite = elements.btn_scratchpad_index_favorite || elements.btn_conversation_index_favorite;
		this.btn_scratchpad_index_delete = elements.btn_scratchpad_index_delete || elements.btn_conversation_index_delete;
		this.btn_show_scratchpads_new = elements.btn_show_scratchpads_new;

		this.BREAKPOINT_MOBILE = breakpoints.BREAKPOINT_MOBILE;

		this.#selection = new SelectionManager(() => this._update_selection_ui());

		if (this.btn_show_scratchpads_new) {
			this.btn_show_scratchpads_new.onclick = () => {
				this.#app_callbacks.create_new_scratchpad();
			};
		}
	}

	//region Scratchpad Actions

	/**
	 * Toggles the bookmark status for all currently selected scratchpads,
	 * or toggles the bookmark status of the active scratchpad if no selection.
	 */
	toggle_selected_bookmarks() {
		const selectedGuids = this.#selection.selectedItems;
		if (selectedGuids.length === 0) {
			const config = this.#storage.get_app_config();
			const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			if (selected_guid) {
				const conversation = this.#storage.get_conversation(selected_guid);
				const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE];
				if (conversation && type === this.#storage.CONVERSATION_TYPE_SCRATCHPAD) {
					const isBookmarked = conversation.BOOKMARKED || false;
					conversation.BOOKMARKED = !isBookmarked;
					this.#storage.save_conversation(selected_guid, conversation);
					this.on_conversation_index_updated();
					this.#app_callbacks.on_conversation_updated_main_panel?.();
				}
			}
			return;
		}

		selectedGuids.forEach(guid => {
			const conversation = this.#storage.get_conversation(guid);
			if (conversation) {
				const isBookmarked = conversation.BOOKMARKED || false;
				conversation.BOOKMARKED = !isBookmarked;
				this.#storage.save_conversation(guid, conversation);
			}
		});

		this.#selection.clear();
		this._update_selection_ui();
		this.on_conversation_index_updated();
		this.#app_callbacks.on_conversation_updated_main_panel?.();
	}

	/**
	 * Alias for toggle_selected_bookmarks to conform to standard index interface.
	 */
	bookmark_selected_responses() {
		this.toggle_selected_bookmarks();
	}

	/**
	 * Deletes all currently selected scratchpads after user confirmation.
	 */
	async delete_selected_scratchpads() {
		const selectedGuids = this.#selection.selectedItems;
		const count = selectedGuids.length;
		if (count === 0) return;

		const message = count === 1
			? 'Are you sure you want to delete this scratchpad?'
			: `Are you sure you want to delete ${count} selected scratchpads?`;

		if (await customConfirm('Delete Scratchpads', message)) {
			const config = this.#storage.get_app_config();
			const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

			if (selectedGuids.includes(selected_guid)) {
				this.#app_callbacks.close_conversation();
			}

			const toDelete = [...selectedGuids];
			toDelete.forEach(guid => {
				if (this.#app_callbacks.abort_request) {
					this.#app_callbacks.abort_request(guid);
				}
				this.#storage.index_delete(guid);
			});

			this.#selection.clear();
			this._update_selection_ui();
			this.on_conversation_index_updated();
			this.#app_callbacks.on_conversation_updated_main_panel?.();
		}
	}

	/**
	 * Alias for delete_selected_scratchpads to conform to standard index interface.
	 */
	async delete_selected_responses() {
		await this.delete_selected_scratchpads();
	}

	/**
	 * Toggles the selection mode for scratchpads.
	 */
	toggle_scratchpad_selection_mode() {
		this.#selection.toggleMode();
		this._update_selection_ui();
		this.on_conversation_index_updated();
	}

	/**
	 * Alias for toggle_scratchpad_selection_mode to conform to standard index interface.
	 */
	toggle_response_selection_mode() {
		this.toggle_scratchpad_selection_mode();
	}

	/**
	 * Returns all scratchpad conversation entries from the index.
	 * @returns {Array<Object>}
	 */
	get_scratchpad_conversations() {
		const index = this.#storage.get_app_index() || [];
		return index.filter(item => {
			const guid = item[this.#storage.KEY_INDEX_GUID];
			const conversation = this.#storage.get_conversation(guid);
			const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || item?.[this.#storage.KEY_CONVERSATION_TYPE];
			return type === this.#storage.CONVERSATION_TYPE_SCRATCHPAD && conversation;
		});
	}

	//endregion

	//region HTML Generation

	/**
	 * Creates and returns a DOM element for a single scratchpad entry in the index.
	 * @param {Object} conversation - The conversation object.
	 * @param {string} guid - The unique identifier of the scratchpad.
	 * @param {boolean} [is_loading=false] - Whether an API request is running.
	 * @returns {HTMLElement} The created index item element.
	 */
	create_scratchpad_index_item(conversation, guid, is_loading = false) {
		const isChecked = this.#selection.isSelected(guid);
		const title = this.get_scratchpad_title(conversation);
		const isBookmarked = conversation.BOOKMARKED || false;
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const is_selected = (guid === selected_guid);
		const html = this._create_scratchpad_item_html(title, guid, isBookmarked, this.#selection.isSelectionMode, isChecked, is_loading, is_selected);
		const index_item = createElementFromHTML(html);
		if (!index_item) return document.createElement('div');

		const chk = index_item.querySelector('.response-item-checkbox');
		if (chk) {
			chk.onclick = (e) => {
				e.stopPropagation();
				this.#selection.toggleItem(guid, chk.checked);
				this._update_selection_ui();
			};
		}

		index_item.onclick = () => {
			if (window.innerWidth <= this.BREAKPOINT_MOBILE) {
				this.#app_callbacks.set_i_open(false);
				this.#app_callbacks.apply_panels_layout();
			}
			this.#app_callbacks.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, guid);
			this.#app_callbacks.on_conversation_updated_main_panel();
			this.highlight_active_index_item();
		};
		return index_item;
	}

	/**
	 * Generates the HTML string for a single scratchpad index item.
	 * @param {string} title - The title of the scratchpad.
	 * @param {string} guid - The GUID of the scratchpad.
	 * @param {boolean} isBookmarked - Whether it is bookmarked.
	 * @param {boolean} isSelectionMode - Whether selection mode is active.
	 * @param {boolean} isChecked - Whether checked.
	 * @param {boolean} [is_loading=false] - Whether an API request is active.
	 * @param {boolean} [is_selected=false] - Whether this scratchpad is selected.
	 * @returns {string} The HTML string.
	 */
	_create_scratchpad_item_html(title, guid, isBookmarked, isSelectionMode, isChecked, is_loading = false, is_selected = false) {
		const iconClass = isBookmarked ? 'bookmark-icon-active' : 'bookmark-icon-inactive';
		const escapedTitle = escapeHtml(title);
		return `
			<div class="div-index-item div-index-item-flex ${isBookmarked ? 'div-index-item-bookmarked' : ''} ${is_loading ? 'div-index-item-loading' : ''} ${is_selected ? 'div-index-item-selected' : ''}"
				 id="scratchpad-item-${guid}"
				 data-guid="${guid}">
				${isSelectionMode ? `<input type="checkbox" ${isChecked ? 'checked' : ''} class="response-item-checkbox index-item-checkbox-margin">` : ''}
				<span class="index-item-icon as-icon ${iconClass}">&#128278;</span>
				<span class="index-item-text-grow" title="${escapedTitle}">${escapedTitle}</span>
				<span class="index-item-indicator as-icon" style="display: ${is_loading ? 'inline-block' : 'none'};" title="Processing...">&#9679;</span>
			</div>
		`;
	}

	/**
	 * Generates the HTML string for a horizontal divider line.
	 * @returns {string}
	 */
	_create_divider_html() {
		return '<hr class="hr-chat-response-divider" />';
	}

	/**
	 * Creates and returns the DOM element for the "New Scratchpad" trigger button.
	 * @returns {HTMLElement}
	 */
	create_new_scratchpad_trigger_item() {
		const html = `
			<button class="btn-new-scratchpad">
				<span class="index-item-icon as-icon" style="margin-right: 0.5em;">&#10133;</span>
				<span>New Scratchpad</span>
			</button>
		`;
		const div = createElementFromHTML(html);
		if (!div) return document.createElement('button');

		div.onclick = (e) => {
			e.stopPropagation();
			this.#app_callbacks.create_new_scratchpad();
		};
		return div;
	}

	//endregion

	//region UI Updates

	/**
	 * Updates the loading indicator for a specific scratchpad item in the index.
	 * @param {string} guid - The scratchpad GUID.
	 * @param {boolean} isLoading - Whether the request is active.
	 */
	set_scratchpad_loading(guid, isLoading) {
		if (!this.div_index_list) return;
		const itemEl = document.getElementById(`scratchpad-item-${guid}`) || this.div_index_list.querySelector(`[data-guid="${guid}"]`);
		if (itemEl && this.div_index_list.contains(itemEl)) {
			if (isLoading) {
				itemEl.classList.add('div-index-item-loading');
			} else {
				itemEl.classList.remove('div-index-item-loading');
			}
			const indicator = itemEl.querySelector('.index-item-indicator, .index-item-spinner');
			if (indicator) {
				indicator.style.display = isLoading ? 'inline-block' : 'none';
			}
		}
	}

	/**
	 * Highlights the active scratchpad conversation in the index.
	 */
	highlight_active_index_item() {
		if (!this.div_index_list) return;
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		const indexItems = this.div_index_list.querySelectorAll('.div-index-item');
		indexItems.forEach((item) => {
			if (item.dataset.guid && item.dataset.guid === selected_guid) {
				item.classList.add('div-index-item-selected');
			} else {
				item.classList.remove('div-index-item-selected');
			}
		});
		this._update_selection_ui();
	}

	/**
	 * Updates the scratchpad index selection controls (buttons, active states).
	 * @private
	 */
	_update_selection_ui() {
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const selectedConv = selected_guid ? this.#storage.get_conversation(selected_guid) : null;
		const isScratchpadActive = !!(selectedConv && selectedConv[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD);

		if (this.#selection.isSelectionMode) {
			this.#selection.updateControls(
				this.btn_scratchpad_index_select,
				[this.btn_scratchpad_index_favorite, this.btn_scratchpad_index_delete],
				'Select'
			);
		} else {
			if (this.btn_scratchpad_index_select) {
				this.btn_scratchpad_index_select.textContent = 'Select';
			}
			if (this.btn_scratchpad_index_favorite) {
				this.btn_scratchpad_index_favorite.disabled = !isScratchpadActive;
			}
			if (this.btn_scratchpad_index_delete) {
				this.btn_scratchpad_index_delete.disabled = true;
			}
		}
	}

	/**
	 * Determines the display title for a scratchpad conversation.
	 * @param {Object} conversation - The conversation object.
	 * @returns {string} The display title.
	 */
	get_scratchpad_title(conversation) {
		if (!conversation) return 'New Scratchpad';
		const history = conversation[this.#storage.KEY_CONVERSATION_HISTORY] || [];
		if (history.length > 0) {
			for (let i = history.length - 1; i >= 0; i--) {
				const item = history[i];
				if (item && (item.title || item.conversationTitle)) {
					return item.title || item.conversationTitle;
				}
			}
		}
		return conversation[this.#storage.KEY_CONVERSATION_TITLE] || 'New Scratchpad';
	}

	/**
	 * Re-renders the scratchpad index view in the DOM.
	 */
	on_conversation_index_updated() {
		if (this.div_index_list) {
			this.div_index_list.innerHTML = '';

			this.div_index_list.appendChild(this.create_new_scratchpad_trigger_item());

			const index = this.#storage.get_app_index() || [];
			const scratchpads = [];

			index.forEach(item => {
				const guid = item[this.#storage.KEY_INDEX_GUID];
				const conversation = this.#storage.get_conversation(guid);
				const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE] || item?.[this.#storage.KEY_CONVERSATION_TYPE];
				if (type === this.#storage.CONVERSATION_TYPE_SCRATCHPAD && conversation) {
					scratchpads.push({ guid, conversation });
				}
			});

			if (this.btn_scratchpad_index_select) {
				this.btn_scratchpad_index_select.disabled = (scratchpads.length === 0);
			}

			const bookmarked = scratchpads.filter(item => item.conversation.BOOKMARKED === true);
			const others = scratchpads.filter(item => item.conversation.BOOKMARKED !== true);

			bookmarked.forEach(item => {
				const is_loading = this.#app_callbacks.is_request_active ? this.#app_callbacks.is_request_active(item.guid) : false;
				this.div_index_list.appendChild(this.create_scratchpad_index_item(item.conversation, item.guid, is_loading));
			});

			if (bookmarked.length > 0 && others.length > 0) {
				const divider = createElementFromHTML(this._create_divider_html());
				if (divider) this.div_index_list.appendChild(divider);
			}

			others.forEach(item => {
				const is_loading = this.#app_callbacks.is_request_active ? this.#app_callbacks.is_request_active(item.guid) : false;
				this.div_index_list.appendChild(this.create_scratchpad_index_item(item.conversation, item.guid, is_loading));
			});
		}

		this.highlight_active_index_item();
		this._update_selection_ui();
	}

	//endregion

}

export default ScratchpadIndex;
🌐
scratchpad-index.js ×
Type: Web, text/plain
14.47 Kilobytes
Last Modified 2026-09-23 02:25:01
⬇ Download File