0 directories, 46 files

util

Home / tinai / util
import { escapeHtml } from './format-utils.js';
import { openPluginDialog } from './plugin-dialog.js';

/**
 * Shared inline formatting for contenteditable editors. Used by the document
 * editor and the sticky notes editor: execCommand-based format actions for a
 * toolbar of `data-action="fmt-*"` buttons, caret-state highlighting, and the
 * sup/sub unwrap that removeFormat misses.
 */

/**
 * Returns the element hosting the caret, resolving element boundaries to
 * the adjacent child so inline formatting at the edge is detected.
 * @param {HTMLElement|null} root_el - The editor root the caret must be inside.
 * @returns {HTMLElement|null} The caret host element.
 */
export function inline_host_of(root_el) {
	const selection = window.getSelection();
	if (!selection || selection.rangeCount === 0) return null;
	const range = selection.getRangeAt(0);
	if (!root_el?.contains(range.startContainer)) return null;
	if (range.startContainer.nodeType === 3) {
		return range.startContainer.parentElement;
	}
	return range.startOffset > 0 ? (range.startContainer.childNodes[range.startOffset - 1] || null) : range.startContainer;
}

/**
 * Highlights the formatting buttons that are active at the caret: bold,
 * italic, underline, strike, superscript, subscript, inline code, and links.
 * Buttons absent from the toolbar are skipped.
 * @param {HTMLElement|null} toolbar_el - The toolbar holding the fmt buttons.
 * @param {HTMLElement|null} root_el - The editor root the caret must be inside.
 */
export function sync_inline_format_states(toolbar_el, root_el) {
	if (!toolbar_el) return;
	const host = inline_host_of(root_el);
	if (!host) return;
	const state = (command) => {
		try { return document.queryCommandState(command); } catch { return false; }
	};
	const toggle = (action, active) => {
		const btn = toolbar_el.querySelector(`button[data-action="${action}"]`);
		if (btn) btn.classList.toggle('selected', active);
	};
	toggle('fmt-bold', state('bold'));
	toggle('fmt-italic', state('italic'));
	toggle('fmt-underline', state('underline'));
	toggle('fmt-strike', state('strikeThrough'));
	toggle('fmt-superscript', state('superscript'));
	toggle('fmt-subscript', state('subscript'));
	toggle('fmt-code', !!host.closest('code'));
	toggle('fmt-link', !!host.closest('a'));
}

/**
 * Unwraps sup and sub elements that intersect the current selection
 * (removeFormat does not remove them).
 * @param {HTMLElement|null} root_el - The editor root to clean up.
 */
export function unwrap_sup_sub_in(root_el) {
	const selection = window.getSelection();
	if (!selection || selection.rangeCount === 0 || !root_el) return;
	const range = selection.getRangeAt(0);
	for (const el of Array.from(root_el.querySelectorAll('sup, sub'))) {
		if (range.intersectsNode(el)) {
			while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
			el.remove();
		}
	}
}

/**
 * Per-root memory of the element the last sup/sub apply created, so the next
 * toggle can unwrap it (Chrome applies the tag but collapses the selection
 * outside it, breaking execCommand's toggle).
 * @type {WeakMap<HTMLElement, HTMLElement|null>}
 */
const sup_sub_state = new WeakMap();

/**
 * Toggles superscript or subscript on the selection: the removal unwraps the
 * element remembered from the previous apply instead of relying on
 * execCommand's toggle.
 * @param {'sup'|'sub'} tag - The tag to toggle.
 * @param {HTMLElement|null} root_el - The editor root holding the selection.
 * @param {Function} on_change - Called after the content mutated.
 */
function format_sup_sub(tag, root_el, on_change) {
	const last = sup_sub_state.get(root_el);
	if (last?.isConnected) {
		while (last.firstChild) last.parentNode.insertBefore(last.firstChild, last);
		last.remove();
		sup_sub_state.delete(root_el);
		on_change();
		return;
	}
	sup_sub_state.delete(root_el);
	document.execCommand(tag === 'sup' ? 'superscript' : 'subscript', false, null);
	sup_sub_state.set(root_el, inline_host_of(root_el)?.closest(tag) || null);
	on_change();
}

/**
 * Runs an inline formatting action on the current selection: bold, italic,
 * underline, strike, superscript, subscript, clear, code, or link. The link
 * action opens the shared link dialog and wraps the saved selection in an
 * anchor when applied.
 * @param {string} action - The button's data-action identifier (fmt-*).
 * @param {HTMLElement|null} root_el - The editor root holding the selection.
 * @param {Function} on_change - Called after the content mutated.
 * @returns {boolean} Whether the action was handled.
 */
export function apply_inline_format(action, root_el, on_change) {
	const command = {
		'fmt-bold': 'bold',
		'fmt-italic': 'italic',
		'fmt-underline': 'underline',
		'fmt-strike': 'strikeThrough'
	}[action];
	if (command) {
		document.execCommand(command, false, null);
		on_change();
		return true;
	}
	if (action === 'fmt-clear') {
		document.execCommand('removeFormat', false, null);
		unwrap_sup_sub_in(root_el);
		on_change();
		return true;
	}
	if (action === 'fmt-superscript') {
		format_sup_sub('sup', root_el, on_change);
		return true;
	}
	if (action === 'fmt-subscript') {
		format_sup_sub('sub', root_el, on_change);
		return true;
	}
	if (action === 'fmt-code') {
		const selection = window.getSelection();
		if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return true;
		document.execCommand('insertHTML', false, '<code>' + escapeHtml(selection.toString()) + '</code>');
		on_change();
		return true;
	}
	if (action === 'fmt-link') {
		format_link(root_el, on_change);
		return true;
	}
	return false;
}

/**
 * Opens the link dialog for the current selection: the range is saved up
 * front and wrapped in an anchor when the dialog is applied, independent of
 * focus changes while the dialog is open.
 * @param {HTMLElement|null} root_el - The editor root holding the selection.
 * @param {Function} on_change - Called after the anchor was inserted.
 */
function format_link(root_el, on_change) {
	const selection = window.getSelection();
	if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
	const pending_range = selection.getRangeAt(0).cloneRange();

	const content = document.createElement('div');
	content.className = 'div-plugin-dialog-body';
	const label = document.createElement('p');
	const label_el = document.createElement('label');
	label_el.textContent = 'URL';
	label_el.setAttribute('for', 'id-inline-format-link-url');
	label.appendChild(label_el);
	const input = document.createElement('input');
	input.type = 'text';
	input.id = 'id-inline-format-link-url';
	input.className = 'prompt-style-input';
	input.placeholder = 'https://...';
	content.append(label, input);

	const apply = () => {
		const url = input.value.trim();
		handle.close();
		if (!url) return;
		const anchor = document.createElement('a');
		anchor.href = url;
		anchor.appendChild(pending_range.extractContents());
		pending_range.insertNode(anchor);
		on_change();
	};
	const actions = document.createElement('div');
	actions.className = 'div-document-item-dialog-actions';
	const apply_btn = document.createElement('button');
	apply_btn.type = 'button';
	apply_btn.className = 'as-icon';
	apply_btn.textContent = 'Apply';
	apply_btn.onclick = apply;
	actions.appendChild(apply_btn);
	content.appendChild(actions);

	input.addEventListener('keydown', (e) => {
		if (e.key === 'Enter') {
			e.preventDefault();
			apply();
		}
	});

	const handle = openPluginDialog('Insert Link', content, null);
	input.focus();
}
🌐
inline-format.js ×
Type: Web, text/x-java
7.35 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File