Replace remote with current working tree
Verificar despliegue Docker / build (push) Has been cancelled

This commit is contained in:
Andres
2026-10-02 11:30:28 -04:00
commit 33353c2e89
178 changed files with 31784 additions and 0 deletions
@@ -0,0 +1,404 @@
import { ICON_SPARKLES } from '../../constants/icons.js';
import { ANNOTATION_MARKER_STYLES } from './styles.js';
import { getEditId } from '../../constants/selectors.js';
import { setComments } from '../../state/annotation-state.js';
import { getAppRoot, isolateEditorUiEvents } from '../../utils/dom-utils.js';
import {
showHoverOutline, holdHoverOutline, releaseHoverOutline, lockHoverOutline,
HOVER_OUTLINE_CLASS, LOCKED_OUTLINE_CLASS,
} from '../overlays/hover-outline.js';
/**
* `class` attribute with our own hover/locked outline classes removed, so an
* anchor captured while the panel had the element locked never bakes in a
* class the element won't carry once re-mounted on a route round-trip.
* @param {HTMLElement} element
* @returns {string}
*/
function stableClassName(element) {
return Array.from(element.classList)
.filter(className => className !== HOVER_OUTLINE_CLASS && className !== LOCKED_OUTLINE_CLASS)
.join(' ');
}
const _markers = [];
let _scrollListenerActive = false;
let _stylesInjected = false;
function ensureStyles() {
if (_stylesInjected) return;
const style = document.createElement('style');
style.id = 'annotation-marker-styles';
style.textContent = ANNOTATION_MARKER_STYLES;
document.head.appendChild(style);
_stylesInjected = true;
}
/** Route an annotation belongs to; it is never re-anchored on any other one. */
function currentRoute() {
return location.pathname + location.hash;
}
/**
* Structural anchor: the element's child-index path from the app root plus the
* tag and classes expected there. Its captured `selector` is unusable for
* re-querying — Tailwind variants (`md:py-32`) make it invalid CSS.
* @param {HTMLElement} element
* @returns {{path: number[], tagName: string, className: string}|null}
*/
function captureAnchor(element) {
const root = getAppRoot();
const path = [];
let node = element;
while (node && node !== root) {
const parent = node.parentElement;
if (!parent) return null;
path.unshift(Array.prototype.indexOf.call(parent.children, node));
node = parent;
}
const anchor = { path, tagName: element.tagName, className: stableClassName(element) };
return anchor;
}
/**
* Corrects for a reordered list: items rendered from one `.map()` are identical
* in tag and classes, so only their text tells them apart. When no sibling
* matches either, the text was edited since and the position stands.
* @param {HTMLElement} candidate
* @param {object|null} selection - `captureElementMetadata` output.
* @returns {HTMLElement}
*/
function disambiguateSiblings(candidate, selection) {
const text = selection?.textContent;
if (!text || candidate.textContent?.trim() === text) return candidate;
const siblings = Array.from(candidate.parentElement?.children ?? []);
return siblings.find(sibling =>
sibling.tagName === candidate.tagName
&& stableClassName(sibling) === stableClassName(candidate)
&& sibling.textContent?.trim() === text) ?? candidate;
}
/**
* Node currently sitting at the anchor's tree position, or null when the
* position is empty or now holds a different element.
* @param {{path: number[], tagName: string, className: string}|null} anchor
* @param {object|null} selection - `captureElementMetadata` output.
* @returns {HTMLElement|null}
*/
function resolveByAnchor(anchor, selection) {
if (!anchor) return null;
let candidate = getAppRoot();
for (const index of anchor.path) {
candidate = candidate?.children[index];
}
if (!candidate) return null;
const actualClassName = stableClassName(candidate);
const sameElement = candidate.tagName === anchor.tagName && actualClassName === anchor.className;
return sameElement ? disambiguateSiblings(candidate, selection) : null;
}
/**
* An edit id is a JSX source location, so every node rendered from one `.map()`
* call site carries the same one; the anchor picks the annotated instance.
* @param {{editId: string, anchor: object|null, selection: object|null}} target
* @returns {HTMLElement|null}
*/
function resolveByEditId(target) {
const matches = Array.from(document.querySelectorAll(`[data-edit-id="${target.editId}"], [data-edit-assisted-id="${target.editId}"]`));
if (matches.length <= 1) return matches[0] ?? null;
const anchored = resolveByAnchor(target.anchor, target.selection);
if (anchored && matches.includes(anchored)) return anchored;
const text = target.selection?.textContent;
return (text && matches.find(match => match.textContent?.trim() === text)) || null;
}
/**
* Element one annotated target currently points at, re-resolved when the stored
* node was unmounted (re-render, route change); null when it is gone.
* @param {object} target
* @param {string} route - Route the annotation was made on.
* @returns {HTMLElement|null}
*/
function resolveTarget(target, route) {
if (target.element?.isConnected) return target.element;
if (currentRoute() !== route) return null;
const element = target.editId ? resolveByEditId(target) : resolveByAnchor(target.anchor, target.selection);
if (element) target.element = element;
return element;
}
/**
* Elements a marker's annotation still points at. A group keeps its pin as long
* as at least one member survives, so a partial re-render doesn't drop it.
* @param {object} entry
* @returns {HTMLElement[]}
*/
function resolveMarkerElements(entry) {
const elements = entry.targets
.map(target => resolveTarget(target, entry.route))
.filter(Boolean);
entry.elements = elements;
if (elements.length) entry.comment.elements = elements;
return elements;
}
/**
* Places one marker over its annotation's current elements.
* @param {object} entry
* @param {HTMLElement[]} elements - `resolveMarkerElements` output, never empty.
*/
function positionMarker(entry, elements) {
const anchorElement = elements[0];
const rect = anchorElement.getBoundingClientRect();
const fixed = entry.fixed;
const anchored = elements.length === 1
&& Array.from(anchorElement.attributes || []).some(attr => attr.name.startsWith('data-edit-'));
const relativeX = anchored ? 0 : entry.relativeX;
const relativeY = anchored ? rect.height / 2 : entry.relativeY;
const scrollX = fixed ? 0 : window.scrollX;
const scrollY = fixed ? 0 : window.scrollY;
entry.marker.style.left = `${rect.left + relativeX + scrollX}px`;
entry.marker.style.top = `${rect.top + relativeY + scrollY}px`;
}
/**
* Repositions every marker over its annotation, hiding the ones whose elements
* are no longer on the page so annotations never bleed onto another route. This
* is also what anchors a restored annotation, once its route renders.
*/
export function refreshAnnotationMarkers() {
for (const entry of _markers) {
const elements = resolveMarkerElements(entry);
if (!elements.length) {
entry.marker.style.display = 'none';
continue;
}
entry.marker.style.display = '';
positionMarker(entry, elements);
}
}
function ensureScrollListener() {
if (_scrollListenerActive) return;
_scrollListenerActive = true;
window.addEventListener('scroll', refreshAnnotationMarkers, true);
window.addEventListener('resize', refreshAnnotationMarkers);
}
/**
* Builds and registers the pin of one annotation. `targets` may describe elements
* that are not on the page: the pin stays hidden until a refresh resolves them.
* @param {{
* comment: import('../../state/annotation-state.js').AnnotationComment,
* targets: Array<{element: HTMLElement|null, editId: string|null, anchor: object|null, selection: object|null}>,
* relativeX: number,
* relativeY: number,
* fixed: boolean,
* route: string,
* onMarkerClick: (comment: object, clientX: number, clientY: number) => void,
* }} options
* @returns {object} The marker entry.
*/
function createMarkerEntry({ comment, targets, relativeX, relativeY, fixed, route, onMarkerClick }) {
ensureStyles();
ensureScrollListener();
const marker = document.createElement('div');
marker.className = 'selection-mode-annotation-marker';
marker.style.position = fixed ? 'fixed' : 'absolute';
marker.style.display = 'none';
marker.innerHTML = ICON_SPARKLES;
// The outline and panel target `entry.elements`, not the captured ones:
// re-resolution after a re-render swaps in the freshly mounted nodes.
const entry = {
marker,
elements: targets.map(target => target.element).filter(Boolean),
relativeX,
relativeY,
fixed,
comment,
targets,
route,
};
marker.addEventListener('mouseenter', () => {
holdHoverOutline();
showHoverOutline(entry.elements);
});
marker.addEventListener('mouseleave', () => {
releaseHoverOutline();
});
marker.addEventListener('click', (event) => {
event.stopPropagation();
event.preventDefault();
lockHoverOutline(entry.elements);
onMarkerClick(comment, event.clientX, event.clientY);
});
isolateEditorUiEvents(marker);
document.body.appendChild(marker);
_markers.push(entry);
comment.marker = marker;
return entry;
}
/**
* Adds the single pin that represents one annotation, tracked against the first of its elements
* @param {import('../../state/annotation-state.js').AnnotationComment} comment
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
*/
export function addAnnotationMarker(comment, onMarkerClick) {
const { elements, selections, clickX, clickY, fixed } = comment;
const anchorElement = elements[0];
const rect = anchorElement.getBoundingClientRect();
const anchorToLeftBorder = elements.length === 1
&& (anchorElement.hasAttribute('data-edit-id') || anchorElement.hasAttribute('data-edit-assisted-id'));
const entry = createMarkerEntry({
comment,
targets: elements.map((element, index) => ({
element,
editId: getEditId(element),
anchor: captureAnchor(element),
selection: selections[index] ?? null,
})),
relativeX: anchorToLeftBorder ? 0 : clickX - rect.left,
relativeY: anchorToLeftBorder ? rect.height / 2 : clickY - rect.top,
fixed,
route: currentRoute(),
onMarkerClick,
});
entry.marker.style.display = '';
positionMarker(entry, elements);
}
/**
* Recreates the pin of a persisted annotation
* @param {import('../../state/annotation-state.js').AnnotationComment} comment
* @param {ReturnType<typeof exportAnnotationMarkers>[number]} serialized
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
*/
function addRestoredAnnotationMarker(comment, serialized, onMarkerClick) {
createMarkerEntry({
comment,
targets: (serialized.targets ?? []).map(target => ({
element: null,
editId: target.editId ?? null,
anchor: target.anchor ?? null,
selection: target.selection ?? null,
})),
relativeX: serialized.relativeX ?? 0,
relativeY: serialized.relativeY ?? 0,
fixed: !!serialized.fixed,
route: serialized.route ?? currentRoute(),
onMarkerClick,
});
}
/**
* Rebuilds annotation comments and their pins from a snapshot. Nothing is
* resolved here: the refresh pass anchors each annotation as its route renders
* @param {ReturnType<typeof exportAnnotationMarkers>|null|undefined} serialized
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
*/
export function importAnnotationMarkers(serialized, onMarkerClick) {
clearAllAnnotationMarkers();
setComments([]);
if (!Array.isArray(serialized)) return;
// Without targets an annotation can never be anchored again, so it would sit
// in the draft as a change the user cannot see, reach or remove.
const annotations = serialized.filter(annotation => annotation?.targets?.length);
if (!annotations.length) return;
const comments = annotations.map(annotation => ({
elements: [],
selections: annotation.targets.map(target => target.selection).filter(Boolean),
text: annotation.text ?? '',
attachments: Array.isArray(annotation.attachments) ? annotation.attachments : [],
clickX: annotation.clickX ?? 0,
clickY: annotation.clickY ?? 0,
fixed: !!annotation.fixed,
}));
setComments(comments);
comments.forEach((comment, index) => addRestoredAnnotationMarker(comment, annotations[index], onMarkerClick));
refreshAnnotationMarkers();
}
/**
* Serializes every annotation for persistence
* @returns {Array<{
* targets: Array<{editId: string|null, anchor: object|null, selection: object|null}>,
* text: string, clickX: number, clickY: number,
* relativeX: number, relativeY: number, fixed: boolean, route: string,
* attachments: Array<{url: string, fileName: string, size: number}>,
* }>}
*/
export function exportAnnotationMarkers() {
return _markers.map(entry => ({
targets: entry.targets.map(({ editId, anchor, selection }) => ({ editId, anchor, selection })),
text: entry.comment.text ?? '',
clickX: entry.comment.clickX ?? 0,
clickY: entry.comment.clickY ?? 0,
relativeX: entry.relativeX,
relativeY: entry.relativeY,
fixed: entry.fixed,
route: entry.route,
attachments: entry.comment.attachments ?? [],
}));
}
export function removeMarker(comment) {
const index = _markers.findIndex(e => e.comment === comment);
if (index === -1) return;
const entry = _markers[index];
entry.marker.remove();
_markers.splice(index, 1);
}
/** Highlights the pin of whichever annotation covers the element, group or not. */
export function highlightMarkerForElement(element) {
for (const entry of _markers) {
if (entry.elements.includes(element)) {
entry.marker.classList.add('highlighted');
}
}
}
export function unhighlightAllMarkers() {
for (const entry of _markers) {
entry.marker.classList.remove('highlighted');
}
}
export function clearAllAnnotationMarkers() {
for (const entry of _markers) {
entry.marker.remove();
}
_markers.length = 0;
window.removeEventListener('scroll', refreshAnnotationMarkers, true);
window.removeEventListener('resize', refreshAnnotationMarkers);
_scrollListenerActive = false;
}
@@ -0,0 +1,544 @@
import { ANNOTATION_PANEL_HTML } from './template.js';
import { ANNOTATION_PANEL_STYLES } from './styles.js';
import { addAnnotationMarker, removeMarker, unhighlightAllMarkers } from './annotation-markers.js';
import { lockHoverOutline, unlockHoverOutline } from '../overlays/hover-outline.js';
import { isInFixedContext, getElementType, isolateEditorUiEvents } from '../../utils/dom-utils.js';
import { captureElementMetadata } from '../../utils/selection-mode-metadata.js';
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, MAX_ANNOTATION_ATTACHMENTS, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js';
import {
getComments, setComments,
getPanelMode, setPanelMode,
getEditingComment, setEditingComment,
getPendingElements, setPendingElements,
getPendingSelections, setPendingSelections,
getPendingAttachments, setPendingAttachments,
getAnnotationClickX, setAnnotationClickX,
getAnnotationClickY, setAnnotationClickY,
getEditorTranslations,
} from '../../state/annotation-state.js';
import { ICON_DELETE, ICON_IMAGE } from '../../constants/icons.js';
import { ParentMessage } from '../../constants/messages.js';
import { postToParent } from '../../utils/parent-frame.js';
import { clearSelectedElements } from '../../state/multi-select-state.js';
import { notifyDraftStateChanged } from '../../api/draft-snapshot.js';
import { getEditing } from '../../state/editing-state.js';
import { startInlineEdit, commitCurrentEdit } from '../inline-edit/edit-action.js';
import { showTextFormatToolbar, hideTextFormatToolbar, getToolbarEl } from '../text-format/toolbar/toolbar.js';
const DEFAULT_LABELS = {
annotationAddComment: 'Add comment',
annotationSaveComment: 'Save comment',
annotationPlaceholder: 'Tell AI what to change',
annotationElementSelected: '{count} element selected',
annotationElementsSelected: '{count} elements selected',
annotationAttachImage: 'Add images',
annotationRemoveImage: 'Remove image',
annotationDeleteComment: 'Delete comment',
};
/** Gap (px) between a panel button and the tooltip floating above it. */
const BUTTON_TOOLTIP_GAP = 8;
function label(key, replacements = {}) {
const raw = getEditorTranslations()[key] || DEFAULT_LABELS[key] || key;
return Object.entries(replacements).reduce(
(text, [token, value]) => text.replace(`{${token}}`, value), raw
);
}
let _annotationPanelEl = null;
let _buttonTooltipEl = null;
let _stylesInjected = false;
let _panelKeydownHandler = null;
let _isAttachmentUploadInProgress = false;
// Attachments already stored on the comment when the panel opened, so cancelling
// can tell apart images added this session from ones the user saved earlier.
let _savedAttachments = [];
function selectionLabel(elements) {
const count = elements.length;
const key = count === 1 ? 'annotationElementSelected' : 'annotationElementsSelected';
return label(key, { count });
}
function ensureStyles() {
if (_stylesInjected) return;
const style = document.createElement('style');
style.id = 'annotation-panel-styles';
style.textContent = ANNOTATION_PANEL_STYLES;
document.head.appendChild(style);
_stylesInjected = true;
}
function ensurePanelElement() {
if (_annotationPanelEl) return _annotationPanelEl;
ensureStyles();
const wrapper = document.createElement('div');
wrapper.innerHTML = ANNOTATION_PANEL_HTML;
_annotationPanelEl = wrapper.firstElementChild;
document.body.appendChild(_annotationPanelEl);
const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea');
const commentBtn = _annotationPanelEl.querySelector('#selection-mode-comment-btn');
const discardBtn = _annotationPanelEl.querySelector('#selection-mode-discard-btn');
const deleteBtn = _annotationPanelEl.querySelector('#selection-mode-delete-btn');
const attachImageBtn = _annotationPanelEl.querySelector('#selection-mode-attach-image-btn');
textarea.placeholder = label('annotationPlaceholder');
commentBtn.textContent = label('annotationAddComment');
attachImageBtn.setAttribute('aria-label', label('annotationAttachImage'));
deleteBtn.setAttribute('aria-label', label('annotationDeleteComment'));
bindButtonTooltip(attachImageBtn);
bindButtonTooltip(deleteBtn);
textarea.addEventListener('input', () => {
commentBtn.disabled = !textarea.value.trim();
});
commentBtn.addEventListener('click', handleCommentClick);
discardBtn.addEventListener('click', handleDiscardClick);
deleteBtn.addEventListener('click', handleDeleteClick);
attachImageBtn.addEventListener('click', () => {
if (attachImageBtn.disabled) return;
setAttachmentUploadInProgress(true);
postToParent(ParentMessage.ANNOTATION_IMAGE_ATTACH_REQUESTED);
});
isolateEditorUiEvents(_annotationPanelEl);
return _annotationPanelEl;
}
/**
* Shows the editor's hover tooltip centred above a panel button, reusing the
* `element-type-tooltip` look. The tooltip is a child of the panel: the panel
* already carries the highest z-index the editor has, so a tooltip anywhere
* else on the page could never paint over it. Offsets are therefore measured
* against the panel's own box rather than the viewport.
* @param {HTMLButtonElement} button
*/
function showButtonTooltip(button) {
const text = button.getAttribute('aria-label');
if (!text || !_annotationPanelEl) return;
if (!_buttonTooltipEl) {
_buttonTooltipEl = document.createElement('div');
_buttonTooltipEl.className = 'element-type-tooltip selection-mode-button-tooltip';
_annotationPanelEl.appendChild(_buttonTooltipEl);
}
_buttonTooltipEl.textContent = text;
_buttonTooltipEl.classList.add('active');
const panelRect = _annotationPanelEl.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const { width, height } = _buttonTooltipEl.getBoundingClientRect();
const centeredLeft = buttonRect.left + (buttonRect.width - width) / 2;
const clampedLeft = Math.min(
Math.max(centeredLeft, TOOLTIP_VIEWPORT_MARGIN),
Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - width - TOOLTIP_VIEWPORT_MARGIN)
);
_buttonTooltipEl.style.left = `${clampedLeft - panelRect.left}px`;
_buttonTooltipEl.style.top = `${buttonRect.top - panelRect.top - height - BUTTON_TOOLTIP_GAP}px`;
}
function hideButtonTooltip() {
_buttonTooltipEl?.classList.remove('active');
}
function bindButtonTooltip(button) {
button.addEventListener('mouseenter', () => {
if (button.disabled) return;
showButtonTooltip(button);
});
button.addEventListener('mouseleave', hideButtonTooltip);
button.addEventListener('blur', hideButtonTooltip);
button.addEventListener('click', hideButtonTooltip);
}
function setAttachmentUploadInProgress(isUploading) {
_isAttachmentUploadInProgress = Boolean(isUploading);
updateAttachImageButtonState();
}
function releaseAttachments(attachments) {
attachments.forEach(({ url }) => postToParent(ParentMessage.ANNOTATION_IMAGE_REMOVED, { url }));
}
function attachmentsNotIn(attachments, others) {
const urls = new Set(others.map(({ url }) => url));
return attachments.filter(({ url }) => !urls.has(url));
}
/**
* Adds attachments the parent frame finished uploading to the open panel.
* Clears the paperclip loading state started when attach was requested.
* @param {import('../../state/annotation-state.js').AnnotationAttachment[]} attachments
*/
export function addPendingAttachments(attachments) {
setAttachmentUploadInProgress(false);
if (!attachments?.length) return;
if (!_annotationPanelEl?.classList.contains('active')) {
releaseAttachments(attachments);
return;
}
setPendingAttachments([...getPendingAttachments(), ...attachments]);
renderAttachments();
}
function removePendingAttachment(url) {
const removed = getPendingAttachments().filter(attachment => attachment.url === url);
setPendingAttachments(getPendingAttachments().filter(attachment => attachment.url !== url));
renderAttachments();
// An image already stored on the comment is only released once Save confirms
// the removal, so cancelling the panel can bring it back.
if (!_savedAttachments.some(attachment => attachment.url === url)) {
releaseAttachments(removed);
}
}
function getVisualEditorAttachmentCount() {
const editing = getEditingComment();
const savedAttachmentsCount = getComments().reduce((total, comment) => {
if (comment === editing) {
return total;
}
return total + (comment.attachments?.length ?? 0);
}, 0);
return savedAttachmentsCount + getPendingAttachments().length;
}
function updateAttachImageButtonState() {
const attachImageBtn = _annotationPanelEl?.querySelector('#selection-mode-attach-image-btn');
if (!attachImageBtn) return;
const isAtLimit = getVisualEditorAttachmentCount() >= MAX_ANNOTATION_ATTACHMENTS;
const isUploading = _isAttachmentUploadInProgress;
attachImageBtn.disabled = isAtLimit || isUploading;
attachImageBtn.classList.toggle('is-loading', isUploading);
attachImageBtn.innerHTML = isUploading
? '<span class="selection-mode-attach-spinner" aria-hidden="true"></span>'
: ICON_IMAGE;
if (attachImageBtn.disabled) hideButtonTooltip();
}
function renderAttachments() {
const container = _annotationPanelEl?.querySelector('#selection-mode-annotation-attachments');
if (!container) return;
const attachments = getPendingAttachments();
container.textContent = '';
container.classList.toggle('active', attachments.length > 0);
attachments.forEach(({ url, fileName }) => {
const chip = document.createElement('div');
chip.className = 'selection-mode-annotation-attachment';
const thumbnail = document.createElement('img');
thumbnail.className = 'selection-mode-annotation-attachment-thumb';
thumbnail.src = url;
thumbnail.alt = fileName;
thumbnail.title = fileName;
thumbnail.loading = 'lazy';
thumbnail.decoding = 'async';
thumbnail.addEventListener('click', () => {
const allAttachments = getPendingAttachments().map(({ url: attachmentUrl, fileName: attachmentName }) => ({
url: attachmentUrl,
fileName: attachmentName,
}));
postToParent(ParentMessage.ANNOTATION_IMAGE_PREVIEW_REQUESTED, { url, attachments: allAttachments });
});
const removeBtn = document.createElement('button');
removeBtn.className = 'selection-mode-annotation-attachment-remove';
removeBtn.title = label('annotationRemoveImage');
removeBtn.innerHTML = ICON_DELETE;
removeBtn.addEventListener('click', () => removePendingAttachment(url));
chip.append(thumbnail, removeBtn);
container.appendChild(chip);
});
updateAttachImageButtonState();
}
function handleCommentClick() {
const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea');
const text = textarea.value.trim();
if (!text) return;
if (getPanelMode() === 'edit') {
const editing = getEditingComment();
if (editing) {
editing.text = text;
editing.attachments = getPendingAttachments();
notifyDraftStateChanged();
}
hideAnnotationPanel({ commitPendingAttachments: true });
return;
}
const elements = getPendingElements();
const selections = getPendingSelections();
const clickX = getAnnotationClickX();
const clickY = getAnnotationClickY();
const comment = {
elements,
selections,
text,
attachments: getPendingAttachments(),
clickX,
clickY,
fixed: elements.length ? isInFixedContext(elements[0]) : false,
};
setComments([...getComments(), comment]);
addAnnotationMarker(comment, openPanelForEdit);
notifyDraftStateChanged();
hideAnnotationPanel({ commitPendingAttachments: true });
}
function handleDiscardClick() {
hideAnnotationPanel();
}
function handleDeleteClick() {
const editing = getEditingComment();
if (!editing) return;
releaseAttachments(editing.attachments ?? []);
removeMarker(editing);
setComments(getComments().filter(c => c !== editing));
notifyDraftStateChanged();
hideAnnotationPanel();
}
function positionPanel(clickX, clickY, elements, anchorTop = clickY) {
const panel = _annotationPanelEl;
const fixed = elements.length ? isInFixedContext(elements[0]) : false;
panel.style.position = fixed ? 'fixed' : 'absolute';
const scrollX = fixed ? 0 : window.scrollX;
const scrollY = fixed ? 0 : window.scrollY;
let left = clickX;
let top = clickY + PANEL_GAP;
if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) {
left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN;
}
if (left < PANEL_MARGIN) left = PANEL_MARGIN;
if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) {
// anchorTop is the anchor's top edge (equals clickY for a plain click
// point, but the toolbar's top when anchored to it) so the panel
// flips fully above the anchor instead of overlapping its bottom half.
top = anchorTop - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP;
}
if (top < PANEL_MARGIN) top = PANEL_MARGIN;
panel.style.left = `${left + scrollX}px`;
panel.style.top = `${top + scrollY}px`;
}
/**
* Opens the panel in create mode for a group of one or more elements.
* @param {HTMLElement[]} elements
* @param {object[]} selections - `captureElementMetadata` output, one per element.
*/
export function showAnnotationPanel(elements, selections, clickX, clickY, anchorTop = clickY) {
const panel = ensurePanelElement();
setPendingElements(elements);
setPendingSelections(selections);
setPendingAttachments([]);
_savedAttachments = [];
setAnnotationClickX(clickX);
setAnnotationClickY(clickY);
setPanelMode('create');
setEditingComment(null);
const textarea = panel.querySelector('#selection-mode-annotation-textarea');
const commentBtn = panel.querySelector('#selection-mode-comment-btn');
const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper');
const countEl = panel.querySelector('#selection-mode-annotation-count');
textarea.value = '';
commentBtn.disabled = true;
commentBtn.textContent = label('annotationAddComment');
deleteWrapper.style.display = 'none';
countEl.textContent = selectionLabel(elements);
renderAttachments();
lockHoverOutline(elements);
positionPanel(clickX, clickY, elements, anchorTop);
panel.classList.add('active');
textarea.focus();
attachPanelKeydown();
}
export function openPanelForEdit(comment, clickX, clickY) {
const panel = ensurePanelElement();
const elements = comment.elements;
const single = elements.length === 1 ? elements[0] : null;
const currentEditing = getEditing();
if (currentEditing && currentEditing.targetElement !== single) {
hideTextFormatToolbar();
commitCurrentEdit();
}
const elementType = single ? getElementType(single) : null;
const openToolbar = !!single
&& (elementType === 'text' || elementType === 'button')
&& (single.hasAttribute('data-edit-id') || single.hasAttribute('data-edit-assisted-id'));
setPanelMode('edit');
setEditingComment(comment);
setPendingElements(elements);
setPendingSelections(comment.selections);
setPendingAttachments(comment.attachments ? [...comment.attachments] : []);
_savedAttachments = comment.attachments ? [...comment.attachments] : [];
setAnnotationClickX(clickX);
setAnnotationClickY(clickY);
const textarea = panel.querySelector('#selection-mode-annotation-textarea');
const commentBtn = panel.querySelector('#selection-mode-comment-btn');
const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper');
const countEl = panel.querySelector('#selection-mode-annotation-count');
countEl.textContent = selectionLabel(elements);
textarea.value = comment.text;
commentBtn.disabled = !comment.text.trim();
commentBtn.textContent = label('annotationSaveComment');
deleteWrapper.style.display = 'flex';
renderAttachments();
let anchorTop = clickY;
if (openToolbar) {
if (!getEditing() || getEditing().targetElement !== single) {
const editId = single.getAttribute('data-edit-id') || single.getAttribute('data-edit-assisted-id');
if (editId) {
startInlineEdit(single, editId, single.innerHTML || "", { focusTarget: false });
}
}
showTextFormatToolbar(single);
const toolbarRect = getToolbarEl()?.getBoundingClientRect();
if (toolbarRect) {
clickX = toolbarRect.left;
clickY = toolbarRect.bottom;
anchorTop = toolbarRect.top;
}
}
panel.classList.add('active');
lockHoverOutline(elements);
unhighlightAllMarkers();
if (comment.marker) comment.marker.classList.add('highlighted');
positionPanel(clickX, clickY, elements, anchorTop);
textarea.focus();
attachPanelKeydown();
}
/**
* `restoreFocus: false` skips re-focusing the inline edit, whose `selectionchange`
* would close a panel the caller opens next; `keepSelection` keeps the group.
* `commitPendingAttachments` keeps the images the caller just saved onto a comment
* and releases the ones it dropped; without it the panel is cancelling, so images
* added during this session are released and previously saved ones are kept.
* @param {{ restoreFocus?: boolean, keepSelection?: boolean, commitPendingAttachments?: boolean }} [options]
*/
export function hideAnnotationPanel({ restoreFocus = true, keepSelection = false, commitPendingAttachments = false } = {}) {
if (!_annotationPanelEl?.classList.contains('active')) return;
releaseAttachments(commitPendingAttachments
? attachmentsNotIn(_savedAttachments, getPendingAttachments())
: attachmentsNotIn(getPendingAttachments(), _savedAttachments));
_annotationPanelEl.classList.remove('active');
hideButtonTooltip();
setAttachmentUploadInProgress(false);
setPendingElements([]);
setPendingSelections([]);
setPendingAttachments([]);
_savedAttachments = [];
setEditingComment(null);
setPanelMode('create');
if (!keepSelection) clearSelectedElements();
unlockHoverOutline();
unhighlightAllMarkers();
const editing = getEditing();
if (restoreFocus && editing?.targetElement?.hasAttribute('contenteditable')) {
editing.targetElement.focus({ preventScroll: true });
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editing.targetElement);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
detachPanelKeydown();
}
export function getAnnotationPanelEl() {
return _annotationPanelEl;
}
function attachPanelKeydown() {
detachPanelKeydown();
_panelKeydownHandler = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
hideAnnotationPanel();
}
};
window.addEventListener('keydown', _panelKeydownHandler, true);
}
function detachPanelKeydown() {
if (_panelKeydownHandler) {
window.removeEventListener('keydown', _panelKeydownHandler, true);
_panelKeydownHandler = null;
}
}
/**
* Opens the panel for one element or a group, resuming the existing comment when
* the same target is already annotated and capturing fresh metadata otherwise.
* @param {HTMLElement|HTMLElement[]} target
* @param {number} clickX
* @param {number} clickY
* @param {number} [anchorTop] - Top edge of the anchor (e.g. a toolbar) used
* when flipping the panel above; defaults to clickY for a plain click point.
*/
export function openOrCreateAnnotation(target, clickX, clickY, anchorTop = clickY) {
const elements = (Array.isArray(target) ? target : [target]).filter(Boolean);
if (!elements.length) return;
const existing = getComments().find(comment => comment.elements.length === elements.length
&& comment.elements.every(element => elements.includes(element)));
if (existing) {
openPanelForEdit(existing, existing.clickX, existing.clickY);
} else {
showAnnotationPanel(elements, elements.map(captureElementMetadata), clickX, clickY, anchorTop);
}
}
@@ -0,0 +1,288 @@
import { ANNOTATION_COLOR, PANEL_BG, PANEL_INPUT_BG, BORDER_COLOR, HOVER_BG, TEXT_MUTED, COLOR_WHITE, BUTTON_LIGHT_TEXT, BUTTON_LIGHT_BG_HOVER, BOX_SHADOW_TOOLBAR, SCROLLBAR_THUMB, SCROLLBAR_THUMB_HOVER, Z_INDEX_EDITOR_PANEL, Z_INDEX_EDITOR_MARKER } from '../../constants/theme.js';
export const ANNOTATION_PANEL_STYLES = `
#selection-mode-annotation-panel {
display: none;
flex-direction: column;
gap: 8px;
position: fixed;
background: ${PANEL_BG};
border-radius: 16px;
border: 1px solid ${BORDER_COLOR};
padding: 8px;
width: 320px;
box-shadow: ${BOX_SHADOW_TOOLBAR};
pointer-events: all;
box-sizing: border-box;
font-family: DM Sans, sans-serif;
z-index: ${Z_INDEX_EDITOR_PANEL};
&.active {
display: flex;
}
}
#selection-mode-annotation-header {
display: flex;
align-items: center;
gap: 16px;
padding-left: 8px;
}
#selection-mode-annotation-count {
flex: 1 0 0;
min-width: 0;
color: ${TEXT_MUTED};
font-size: 14px;
line-height: 20px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#selection-mode-annotation-input-wrapper {
display: flex;
flex-direction: column;
background: ${PANEL_INPUT_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
box-sizing: border-box;
padding: 16px;
}
#selection-mode-annotation-textarea {
width: 100%;
min-height: 88px;
max-height: 160px;
background: transparent;
border: none;
outline: none;
padding: 0 4px;
color: ${COLOR_WHITE};
font-size: 16px;
font-family: inherit;
resize: none;
box-sizing: border-box;
caret-color: ${COLOR_WHITE};
line-height: 24px;
overflow-y: auto;
}
#selection-mode-annotation-textarea::placeholder {
color: ${TEXT_MUTED};
}
#selection-mode-annotation-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Mirrors the chat file previews: chips scroll sideways instead of stacking into rows. */
#selection-mode-annotation-attachments {
display: none;
overflow-x: auto;
padding-top: 4px;
margin-top: -4px;
margin-bottom: 16px;
scrollbar-width: thin;
scrollbar-color: ${SCROLLBAR_THUMB} transparent;
&.active {
display: flex;
}
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${SCROLLBAR_THUMB};
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
&::-webkit-scrollbar-thumb:hover {
background: ${SCROLLBAR_THUMB_HOVER};
}
}
.selection-mode-annotation-attachment {
position: relative;
display: inline-block;
flex-shrink: 0;
padding-right: 12px;
}
.selection-mode-annotation-attachment-thumb {
width: 48px;
height: 48px;
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
object-fit: cover;
cursor: pointer;
user-select: none;
display: block;
box-sizing: border-box;
}
.selection-mode-annotation-attachment-remove {
position: absolute;
top: -4px;
right: 8px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
color: ${COLOR_WHITE};
& svg {
width: 16px;
height: 16px;
}
&:hover {
background: ${BORDER_COLOR};
}
}
#selection-mode-annotation-actions,
#selection-mode-annotation-primary-actions {
display: flex;
align-items: center;
gap: 4px;
}
/* Overrides the hover tooltip's fixed positioning; the id keeps this winning
no matter which of the two stylesheets is injected last. */
#selection-mode-annotation-panel .selection-mode-button-tooltip {
position: absolute;
}
#selection-mode-discard-btn,
#selection-mode-attach-image-btn,
#selection-mode-delete-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
}
#selection-mode-discard-btn:hover,
#selection-mode-attach-image-btn:hover:not(:disabled),
#selection-mode-delete-btn:hover {
background: ${HOVER_BG};
}
#selection-mode-attach-image-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
#selection-mode-attach-image-btn.is-loading:disabled {
opacity: 1;
cursor: wait;
}
/* Mirrors horizons-frontend Spinner.vue (w-4 border-1 border-light border-t-transparent, 2s spin). */
.selection-mode-attach-spinner {
box-sizing: border-box;
width: 16px;
height: 16px;
border-radius: 9999px;
border: 1px solid ${COLOR_WHITE};
border-top-color: transparent;
animation: selection-mode-attach-spin 2s linear infinite;
}
@keyframes selection-mode-attach-spin {
to {
transform: rotate(360deg);
}
}
#selection-mode-delete-wrapper {
position: relative;
display: flex;
align-items: center;
width: 32px;
height: 32px;
}
#selection-mode-comment-btn {
background: ${COLOR_WHITE};
border: none;
color: ${BUTTON_LIGHT_TEXT};
font-family: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 600;
cursor: pointer;
padding: 6px 12px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
white-space: nowrap;
}
#selection-mode-comment-btn:hover {
background: ${BUTTON_LIGHT_BG_HOVER};
}
#selection-mode-comment-btn:disabled {
opacity: 0.2;
cursor: not-allowed;
background: ${COLOR_WHITE};
}
`;
export const ANNOTATION_MARKER_STYLES = `
.selection-mode-annotation-marker > * {
pointer-events: none;
}
.selection-mode-annotation-marker {
width: 28px;
height: 28px;
border-radius: 50%;
background: ${ANNOTATION_COLOR};
border: 1px solid ${COLOR_WHITE}25;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
cursor: pointer;
user-select: none;
transform: translate(-50%, -50%);
transition: transform 0.15s ease;
z-index: ${Z_INDEX_EDITOR_MARKER};
}
.selection-mode-annotation-marker:hover,
.selection-mode-annotation-marker.highlighted {
transform: translate(-50%, -50%) scale(1.1);
}
`;
@@ -0,0 +1,25 @@
import { ICON_DELETE, ICON_CLOSE, ICON_IMAGE } from '../../constants/icons.js';
export const ANNOTATION_PANEL_HTML = `
<div id="selection-mode-annotation-panel">
<div id="selection-mode-annotation-header">
<span id="selection-mode-annotation-count"></span>
<button id="selection-mode-discard-btn" title="Close">${ICON_CLOSE}</button>
</div>
<div id="selection-mode-annotation-input-wrapper">
<div id="selection-mode-annotation-attachments"></div>
<textarea id="selection-mode-annotation-textarea" placeholder="Tell AI what to change" rows="1"></textarea>
</div>
<div id="selection-mode-annotation-buttons">
<div id="selection-mode-annotation-actions">
<button id="selection-mode-attach-image-btn" aria-label="Add images">${ICON_IMAGE}</button>
</div>
<div id="selection-mode-annotation-primary-actions">
<div id="selection-mode-delete-wrapper">
<button id="selection-mode-delete-btn" aria-label="Delete comment">${ICON_DELETE}</button>
</div>
<button id="selection-mode-comment-btn" disabled>Add comment</button>
</div>
</div>
</div>
`;
@@ -0,0 +1,93 @@
import { getEditing } from "../../state/editing-state.js";
import { stopFontSizeEdit, updateTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js";
import { hideAllPanels } from "../../state/panel-state.js";
import { resolveInlineStylesAtRange } from "../../utils/inline-style-state.js";
import { EDITOR_UI_SELECTOR } from "../../constants/selectors.js";
/**
* @param {HTMLElement} element
* @param {HTMLElement} container
* @returns {{ value: string, isGradient: boolean }}
*/
function resolveTextColor(element, container) {
const color = getComputedStyle(element).color;
if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
return { value: color, isGradient: false };
}
let current = element;
while (current && current !== container.parentElement) {
const computedStyle = getComputedStyle(current);
const backgroundImage = computedStyle.backgroundImage;
const backgroundClip = computedStyle.backgroundClip || computedStyle['webkitBackgroundClip'];
if (backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('gradient') && backgroundClip === 'text') {
return { value: backgroundImage, isGradient: true };
}
current = current.parentElement;
}
return { value: color, isGradient: false };
}
/**
* Resolves caret/selection presentation state for the formatting toolbar.
* Bold, italic, and underline are detected independently via ancestor walks
* so nested markup (`<em><u><strong>…</strong></u></em>`) reports all active styles.
* @returns {object|null}
*/
export function getStylesAtCursor() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
let node = range.startContainer;
if (node.nodeType === Node.ELEMENT_NODE) {
const child = node.childNodes[range.startOffset];
if (child) node = child;
}
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement;
}
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
node = getEditing()?.targetElement;
}
if (!node) return null;
const container = getEditing()?.targetElement || node;
const presentation = resolveInlineStylesAtRange(range, container);
const styles = getComputedStyle(node);
const containerStyles = getComputedStyle(container);
const resolvedColor = resolveTextColor(node, container);
return {
color: resolvedColor.value,
isGradient: resolvedColor.isGradient,
fontFamily: styles.fontFamily,
fontSize: styles.fontSize,
textAlign: containerStyles.textAlign,
...presentation,
};
}
/** Updates toolbar and parent when the selection moves during inline edit. */
export function onSelectionChange(force = false) {
const editing = getEditing();
if (!editing?.targetElement?.hasAttribute("contenteditable")) return;
if (!force && document.activeElement?.closest(EDITOR_UI_SELECTOR)) return;
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && !editing.targetElement.contains(selection.getRangeAt(0).commonAncestorContainer)) {
return;
}
stopFontSizeEdit();
hideAllPanels();
const styles = getStylesAtCursor();
if (!styles) return;
updateTextFormatToolbar(styles);
scheduleRepositionEditOverlays();
}
@@ -0,0 +1,368 @@
import { getEditing, setEditing, clearEditing } from "../../state/editing-state.js";
import { normalizeContentEditableHtml, patchRemoveChild, serializeContentEditableHtml } from "../../utils/html-utils.js";
import { postToParent } from "../../utils/parent-frame.js";
import { ParentMessage } from "../../constants/messages.js";
import { beginEditSession, recordEdit } from "../../state/history-state.js";
import { notifyDraftStateChanged } from "../../api/draft-snapshot.js";
import { showTextFormatToolbar, hideTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js";
import { onSelectionChange } from "./cursor-styles.js";
import { captureElementMetadata } from "../../utils/selection-mode-metadata.js";
/**
* Converts a CSS property name to its camelCase JS form
* (e.g. `background-clip` → `backgroundClip`), matching the keys used by the
* JSX style objects in the source write-back.
*/
function cssPropertyToCamelCase(cssProperty) {
return cssProperty.replace(/^-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}
const TRANSPARENT_TEXT_CARET_COLOR = "#6B7280";
function isTransparentColor(color) {
if (!color || color === "transparent") return true;
const match = color.replace(/\s+/g, "").match(/^rgba?\(([^)]+)\)$/i);
if (!match) return false;
const segments = match[1].split(",");
return segments.length === 4 && parseFloat(segments[3]) === 0;
}
/**
* Reports whether the element or any descendant renders transparent text
* (e.g. gradient text via `background-clip: text` + `color: transparent`),
* where the caret would otherwise be invisible.
* @param {HTMLElement} element
* @returns {boolean}
*/
function hasTransparentText(element) {
if (isTransparentColor(getComputedStyle(element).color)) return true;
return Array.from(element.querySelectorAll("*")).some(
descendant => isTransparentColor(getComputedStyle(descendant).color)
);
}
/** `caret-color: currentColor` is invisible on transparent/gradient text, so use a visible caret there. */
function applyEditingCaretColor(element) {
if (hasTransparentText(element)) {
element.style.caretColor = TRANSPARENT_TEXT_CARET_COLOR;
} else {
element.style.removeProperty("caret-color");
}
}
/**
* Snapshots every style property set inline on the element itself.
* @param {HTMLElement} element
* @returns {Record<string, string>} camelCase property → value
*/
function captureInlineStyles(element) {
const styles = {};
for (let index = 0; index < element.style.length; index++) {
const cssProperty = element.style.item(index);
styles[cssPropertyToCamelCase(cssProperty)] = element.style.getPropertyValue(cssProperty);
}
return styles;
}
/**
* Diffs the element's current inline styles against a snapshot, covering any
* panel action that styles the element itself ('' marks a removed property).
* `textAlign` is excluded here — it is compared against computed style instead,
* so re-applying an alignment inherited from CSS is not logged as a change.
* @param {HTMLElement} element
* @param {Record<string, string>} original
* @returns {{ changed: Record<string, string>, previous: Record<string, string> }|null}
*/
function getInlineStyleDiff(element, original) {
const current = captureInlineStyles(element);
const changed = {};
const previous = {};
for (const key of new Set([...Object.keys(current), ...Object.keys(original)])) {
if (key === 'textAlign') continue;
// Transient editing caret-color must never be written back to source.
if (key === 'caretColor') continue;
if ((current[key] ?? '') !== (original[key] ?? '')) {
changed[key] = current[key] ?? '';
previous[key] = original[key] ?? '';
}
}
return Object.keys(changed).length > 0 ? { changed, previous } : null;
}
/**
* Computes element-level style changes (alignment plus any other inline style
* the panel set on the element) since the edit session's last snapshot.
* @param {object} editing - Current editing state.
* @returns {{ style: object|null }}
*/
function buildStyleDiff(editing) {
const { targetElement, originalTextAlign, originalInlineTextAlign, originalInlineStyles } = editing;
const alignChanged = getComputedStyle(targetElement).textAlign !== originalTextAlign;
const inlineDiff = getInlineStyleDiff(targetElement, originalInlineStyles);
if (!alignChanged && !inlineDiff) return { style: null, oldStyle: null };
const style = {};
const oldStyle = {};
if (alignChanged) {
style.textAlign = targetElement.style.textAlign;
// Keep previous inline value around so re-applying the inherited default still serializes.
if (!style.textAlign) style.textAlign = originalInlineTextAlign ?? '';
oldStyle.textAlign = originalInlineTextAlign ?? '';
}
if (inlineDiff) {
Object.assign(style, inlineDiff.changed);
Object.assign(oldStyle, inlineDiff.previous);
}
return { style, oldStyle };
}
/** Re-snapshots the editing state's style baselines after a soft-save. */
function refreshStyleSnapshots(editing) {
const { targetElement } = editing;
editing.originalTextAlign = getComputedStyle(targetElement).textAlign;
editing.originalInlineTextAlign = targetElement.style.textAlign || '';
editing.originalInlineStyles = captureInlineStyles(targetElement);
}
/**
* Snapshots child nodes so DOM mutations during edit can be restored if needed.
* @param {HTMLElement} element
* @returns {Array<{ node: Node, value: string|null }>}
*/
export function saveChildNodes(element) {
const saved = [];
for (const child of element.childNodes) {
saved.push({
node: child,
value: child.nodeType === Node.TEXT_NODE ? child.nodeValue : null,
});
}
return saved;
}
/**
* Places a collapsed caret at the end of the editable container's contents.
* @param {HTMLElement} container
*/
export function placeCursorAtEnd(container) {
if (!container.childNodes.length) {
container.appendChild(document.createTextNode(''));
}
let target = container;
while (target.lastChild) {
target = target.lastChild;
}
const range = document.createRange();
if (target.nodeType === Node.TEXT_NODE) {
range.setStart(target, target.textContent.length);
} else {
range.selectNodeContents(target);
}
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Places the text caret at viewport coordinates, clamped to the editable container.
* @param {number} x
* @param {number} y
* @param {HTMLElement} [container]
*/
export function placeCursorAtPoint(x, y, container) {
let range = null;
if (document.caretPositionFromPoint) {
const position = document.caretPositionFromPoint(x, y);
if (position) {
range = document.createRange();
range.setStart(position.offsetNode, position.offset);
range.collapse(true);
}
} else if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(x, y);
}
// If the caret resolved outside our editable target (e.g. an absolute overlay
// sat on top of the click point), fall back to placing it at the end of the
// target so typing actually goes into the contenteditable.
if (container && (!range || !container.contains(range.startContainer))) {
range = document.createRange();
range.selectNodeContents(container);
range.collapse(false);
}
if (!range) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Enables contenteditable on the target and wires edit listeners and toolbar.
* @param {HTMLElement} targetElement
* @param {string} editId
* @param {string} currentContent - Initial HTML content.
* @param {{ focusTarget?: boolean }} [options] - Pass `focusTarget: false` when the caller focuses its own UI instead.
*/
export function startInlineEdit(targetElement, editId, currentContent, { focusTarget = true } = {}) {
if (getEditing()?.targetElement === targetElement) return;
const savedChildren = saveChildNodes(targetElement);
const normalizedOriginal = normalizeContentEditableHtml(currentContent);
const sessionId = beginEditSession();
const selectionMode = captureElementMetadata(targetElement);
setEditing({
editId,
targetElement,
sessionId,
originalContent: normalizedOriginal,
originalTextAlign: getComputedStyle(targetElement).textAlign,
originalInlineTextAlign: targetElement.style.textAlign || '',
originalInlineStyles: captureInlineStyles(targetElement),
savedChildren,
selectionMode,
});
document.getElementById("root")?.setAttribute("data-inline-editing", "true");
targetElement.setAttribute("data-editing", "true");
patchRemoveChild(targetElement);
targetElement.setAttribute("contenteditable", "true");
applyEditingCaretColor(targetElement);
if (focusTarget) {
targetElement.focus();
placeCursorAtEnd(targetElement);
requestAnimationFrame(() => {
if (getEditing()?.targetElement !== targetElement) return;
placeCursorAtEnd(targetElement);
targetElement.focus();
});
}
postToParent(ParentMessage.EDIT_ENTER, {
currentText: currentContent,
elementType: targetElement.tagName.toLowerCase(),
isDirectlyEditable: targetElement.hasAttribute('data-edit-id'),
});
showTextFormatToolbar(targetElement);
targetElement.addEventListener("keydown", onInlineEditKeyDown);
targetElement.addEventListener("beforeinput", onInlineEditBeforeInput);
targetElement.addEventListener("input", onInlineEditInput);
targetElement.addEventListener("tft-color-commit", saveCurrentEdit);
document.addEventListener("selectionchange", onSelectionChange);
setTimeout(onSelectionChange, 0);
}
/**
* Soft-saves the current edit without tearing down contenteditable (e.g. color commit),
* recording it into the in-iframe edit history so it can be undone/redone and
* later batch-persisted on draft save.
*/
export function saveCurrentEdit() {
const editing = getEditing();
if (!editing) return;
const { editId, targetElement, originalContent, sessionId, selectionMode } = editing;
const newHTML = serializeContentEditableHtml(targetElement);
const { style, oldStyle } = buildStyleDiff(editing);
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
if (newHTML !== originalContent || style) {
recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode });
notifyDraftStateChanged();
editing.originalContent = newHTML;
editing.hasSoftSaved = true;
refreshStyleSnapshots(editing);
}
}
/** Finalizes the active inline edit, records it into history, and clears editing state. */
export function commitCurrentEdit() {
const editing = getEditing();
if (!editing) return;
const { editId, targetElement, originalContent, sessionId, selectionMode } = editing;
const newHTML = serializeContentEditableHtml(targetElement);
const { style, oldStyle } = buildStyleDiff(editing);
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
targetElement.removeAttribute("contenteditable");
targetElement.removeAttribute("data-editing");
targetElement.style.removeProperty("caret-color");
document.getElementById("root")?.removeAttribute("data-inline-editing");
targetElement.removeEventListener("keydown", onInlineEditKeyDown);
targetElement.removeEventListener("beforeinput", onInlineEditBeforeInput);
targetElement.removeEventListener("input", onInlineEditInput);
targetElement.removeEventListener("tft-color-commit", saveCurrentEdit);
document.removeEventListener("selectionchange", onSelectionChange);
hideTextFormatToolbar();
patchRemoveChild(targetElement);
targetElement.innerHTML = newHTML;
const { hasSoftSaved } = editing;
if (newHTML !== originalContent || style) {
recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode });
notifyDraftStateChanged();
} else if (!hasSoftSaved) {
postToParent(ParentMessage.EDIT_CANCEL, {});
}
clearEditing();
}
/**
* Blocks browser replacement-text input during inline edit.
* @param {InputEvent} e
*/
export function onInlineEditBeforeInput(event) {
if (event.inputType === "insertReplacementText") {
event.preventDefault();
return;
}
scheduleRepositionEditOverlays();
}
/** Repositions the toolbar as the editable content grows/shrinks. */
export function onInlineEditInput() {
if (!getEditing()) return;
saveCurrentEdit();
scheduleRepositionEditOverlays();
}
/**
* Handles Escape/Enter to commit and stops propagation for other keys.
* @param {KeyboardEvent} e
*/
export function onInlineEditKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
commitCurrentEdit();
return;
}
if (event.key === "Enter") {
if (event.shiftKey) {
event.stopPropagation();
return;
}
event.preventDefault();
commitCurrentEdit();
return;
}
if (event.key === " " && event.currentTarget.tagName.toLowerCase() === "button") {
// Native <button> treats Space as activation, not text input, so the
// browser never inserts a character. Prevent the activation and insert
// the space manually (execCommand keeps the input event).
event.preventDefault();
document.execCommand("insertText", false, " ");
return;
}
event.stopPropagation();
}
@@ -0,0 +1,85 @@
import { EDIT_BORDER_COLOR, EDIT_BACKGROUND_COLOR, PANEL_BG, BORDER_COLOR, COLOR_WHITE, Z_INDEX_EDITOR_OVERLAY } from '../../constants/theme.js';
import { ICON_TEXT_EDIT_CURSOR } from '../../constants/icons.js';
import { HOVER_OUTLINE_STROKE_WIDTH } from '../../constants/layout.js';
import { EDITOR_UI_SELECTOR } from '../../constants/selectors.js';
/** Injected CSS for edit-mode hover outlines, contenteditable affordances, and the element-type tooltip. */
export const EDIT_MODE_STYLES = `
#root[data-edit-mode-enabled="true"] [data-edit-id],
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id] {
cursor: default !important;
overflow-wrap: anywhere;
}
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"],
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] {
outline: 1px solid ${EDIT_BORDER_COLOR};
caret-color: currentColor;
user-select: text;
-webkit-user-select: text;
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
}
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"] *,
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] * {
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
}
/* Suppress native cursor on actionable elements while editing. */
#root[data-edit-mode-enabled="true"] a,
#root[data-edit-mode-enabled="true"] button,
#root[data-edit-mode-enabled="true"] [role="button"],
#root[data-edit-mode-enabled="true"] [type="submit"] {
cursor: default !important;
}
.inline-editor-hover-outline,
.inline-editor-locked-outline {
outline-offset: -${HOVER_OUTLINE_STROKE_WIDTH}px;
box-shadow: inset 0 0 0 999px ${EDIT_BACKGROUND_COLOR};
}
.inline-editor-hover-outline {
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px dashed ${EDIT_BORDER_COLOR};
}
.inline-editor-locked-outline {
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px solid ${EDIT_BORDER_COLOR};
}
@keyframes element-type-tooltip-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.element-type-tooltip {
display: none;
opacity: 0;
position: fixed;
top: 0;
left: 0;
background-color: ${PANEL_BG};
color: ${COLOR_WHITE};
padding: 0 4px;
border-radius: 6px;
font-family: DM Sans, sans-serif;
font-size: 12px;
font-weight: 400;
line-height: 20px;
border: 1px solid ${BORDER_COLOR};
pointer-events: none;
white-space: nowrap;
z-index: ${Z_INDEX_EDITOR_OVERLAY};
}
.element-type-tooltip.active {
display: block;
animation: element-type-tooltip-fade-in 0.2s ease-out forwards;
}
${EDITOR_UI_SELECTOR} {
pointer-events: auto;
}
`;
@@ -0,0 +1,71 @@
export const HOVER_OUTLINE_CLASS = 'inline-editor-hover-outline';
export const LOCKED_OUTLINE_CLASS = 'inline-editor-locked-outline';
/** @param {HTMLElement|HTMLElement[]|null} value @returns {HTMLElement[]} */
function toArray(value) {
if (!value) return [];
return Array.isArray(value) ? value.filter(Boolean) : [value];
}
/** @param {HTMLElement[]} elements @param {string} className */
function addClassTo(elements, className) {
for (const element of elements) element.classList.add(className);
}
/** @param {HTMLElement[]} elements @param {string} className */
function removeClassFrom(elements, className) {
for (const element of elements) element.classList.remove(className);
}
/** @type {HTMLElement[]} */
let _hoveredElements = [];
/** @type {HTMLElement[]} */
let _lockedElements = [];
let _holdCount = 0;
/**
* Dashed outline for the transient hover. Elements already carrying a locked
* outline are skipped so they aren't outlined twice.
* @param {HTMLElement|HTMLElement[]} elements
*/
export function showHoverOutline(elements) {
const targets = toArray(elements).filter(element => !_lockedElements.includes(element));
if (!targets.length) return;
removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS);
_hoveredElements = targets;
addClassTo(_hoveredElements, HOVER_OUTLINE_CLASS);
}
/** Hides the hover outline (no-op while held). */
export function hideHoverOutline() {
if (_holdCount > 0) return;
removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS);
_hoveredElements = [];
}
export function holdHoverOutline() { _holdCount++; }
export function releaseHoverOutline() {
_holdCount = Math.max(0, _holdCount - 1);
if (_holdCount === 0) hideHoverOutline();
}
/**
* Solid outline for a committed selection — a drag preview, a shift+click
* group, or the element(s) an open annotation panel targets.
* @param {HTMLElement|HTMLElement[]} elements
*/
export function lockHoverOutline(elements) {
const targets = toArray(elements);
if (!targets.length) {
unlockHoverOutline();
return;
}
removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS);
_lockedElements = targets;
addClassTo(_lockedElements, LOCKED_OUTLINE_CLASS);
}
export function unlockHoverOutline() {
removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS);
_lockedElements = [];
}
@@ -0,0 +1,140 @@
import { SELECTION_RECT_ID, isInsideEditorUi, getEditId } from '../../constants/selectors.js';
import { EDIT_BACKGROUND_COLOR, Z_INDEX_EDITOR_OVERLAY, EDIT_BORDER_COLOR } from '../../constants/theme.js';
import { MIN_SELECTION_CANDIDATE_SIZE, MAX_SELECTED_ELEMENTS } from '../../constants/layout.js';
import { getAppRoot, getOpenModals } from '../../utils/dom-utils.js';
import { orderSelection } from '../../state/multi-select-state.js';
/** Set on `<body>` for the duration of a selection gesture, to keep the browser from painting text over it. */
export const NO_TEXT_SELECT_CLASS = 'selection-mode-no-text-select';
const SELECTION_RECT_STYLES = `
#${SELECTION_RECT_ID} {
position: fixed;
display: none;
pointer-events: none;
background: ${EDIT_BACKGROUND_COLOR};
border: 1px solid ${EDIT_BORDER_COLOR};
z-index: ${Z_INDEX_EDITOR_OVERLAY};
}
body.${NO_TEXT_SELECT_CLASS}, body.${NO_TEXT_SELECT_CLASS} * {
user-select: none !important;
}
`;
let _rectElement = null;
let _stylesInjected = false;
function ensureStyles() {
if (_stylesInjected) return;
const style = document.createElement('style');
style.id = 'selection-rect-styles';
style.textContent = SELECTION_RECT_STYLES;
document.head.appendChild(style);
_stylesInjected = true;
}
/* ------------------------------------------------------------------ *
* Geometry
* ------------------------------------------------------------------ */
/**
* Normalises two drag corners into a viewport-space rect.
* @returns {{x: number, y: number, w: number, h: number}}
*/
export function getSelectionRect(x1, y1, x2, y2) {
return { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
}
function intersects(bounds, rect) {
return bounds.right > rect.x && bounds.left < rect.x + rect.w
&& bounds.bottom > rect.y && bounds.top < rect.y + rect.h;
}
/* ------------------------------------------------------------------ *
* Candidate resolution
* ------------------------------------------------------------------ */
/**
* True when the element is a plausible annotation target: visible, big enough to
* aim at, and not part of the editor's own UI.
* @param {HTMLElement} element
* @param {DOMRect} bounds
* @returns {boolean}
*/
function isSelectableCandidate(element, bounds) {
if (bounds.width < MIN_SELECTION_CANDIDATE_SIZE || bounds.height < MIN_SELECTION_CANDIDATE_SIZE) return false;
if (isInsideEditorUi(element)) return false;
const styles = window.getComputedStyle(element);
return styles.display !== 'none' && styles.visibility !== 'hidden';
}
/** Children the band also touches; an empty result means this element is as deep as it goes. */
function intersectingChildren(element, rect) {
return Array.from(element.children).filter((child) => {
const bounds = child.getBoundingClientRect();
return isSelectableCandidate(child, bounds) && intersects(bounds, rect);
});
}
/**
* Everything the band touches, taking the deepest element on each branch so a
* container never comes along with the children that made it a hit.
* @param {{x: number, y: number, w: number, h: number}} rect
* @returns {HTMLElement[]} Document-ordered, with no member nested in another.
*/
export function collectSelection(rect) {
const selected = [];
const openModals = getOpenModals();
const roots = openModals.length ? openModals : [getAppRoot()];
const queue = roots.flatMap((root) => [...root.children]);
while (queue.length && selected.length < MAX_SELECTED_ELEMENTS) {
const element = queue.shift();
const bounds = element.getBoundingClientRect();
// No overlap prunes the whole subtree: children sit inside their parent's box.
if (!isSelectableCandidate(element, bounds) || !intersects(bounds, rect)) continue;
// An edit id marks the editor's own annotation target, so stop there rather
// than splitting a button or heading into the spans it is built from.
const children = getEditId(element) ? [] : intersectingChildren(element, rect);
if (children.length) {
queue.push(...children);
} else {
selected.push(element);
}
}
return orderSelection(selected);
}
/* ------------------------------------------------------------------ *
* Band rendering
* ------------------------------------------------------------------ */
export function showSelectionRect(x1, y1, x2, y2) {
if (!_rectElement) {
ensureStyles();
_rectElement = document.createElement('div');
_rectElement.id = SELECTION_RECT_ID;
document.body.appendChild(_rectElement);
}
if (!_rectElement.isConnected) document.body.appendChild(_rectElement);
const rect = getSelectionRect(x1, y1, x2, y2);
_rectElement.style.left = `${rect.x}px`;
_rectElement.style.top = `${rect.y}px`;
_rectElement.style.width = `${rect.w}px`;
_rectElement.style.height = `${rect.h}px`;
_rectElement.style.display = 'block';
}
export function hideSelectionRect() {
if (_rectElement) _rectElement.style.display = 'none';
}
export function destroySelectionRect() {
_rectElement?.remove();
_rectElement = null;
}
@@ -0,0 +1,73 @@
import { getElementType } from '../../utils/dom-utils.js';
import { TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js';
import { ELEMENT_TYPE_TRANSLATIONS } from '../../constants/selectors.js';
import { showHoverOutline, hideHoverOutline } from './hover-outline.js';
import { highlightMarkerForElement, unhighlightAllMarkers } from '../annotation-panel/annotation-markers.js';
import { getEditorTranslations } from '../../state/annotation-state.js';
const ELEMENT_TYPE_TOOLTIP_CLASS = 'element-type-tooltip';
let typeTooltipElement = null;
let displayedType = null;
let cachedWidth = 0;
let cachedHeight = 0;
function createTypeTooltip() {
if (typeTooltipElement) return;
typeTooltipElement = document.createElement('div');
typeTooltipElement.className = ELEMENT_TYPE_TOOLTIP_CLASS;
document.body.appendChild(typeTooltipElement);
}
/**
* Positions the tooltip's top-left corner near `(clientX, clientY)`, clamped
* per axis so it never crosses within `TOOLTIP_VIEWPORT_MARGIN` of the
* viewport edge. The cursor is free to keep moving over the parked tooltip —
* it is `pointer-events: none`, so hit-testing resolves straight through it.
* @param {number} clientX
* @param {number} clientY
*/
function positionAt(clientX, clientY) {
const maxLeft = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - cachedWidth - TOOLTIP_VIEWPORT_MARGIN);
const maxTop = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerHeight - cachedHeight - TOOLTIP_VIEWPORT_MARGIN);
const left = Math.min(Math.max(clientX + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxLeft);
const top = Math.min(Math.max(clientY + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxTop);
typeTooltipElement.style.transform = `translate3d(${left}px, ${top}px, 0)`;
}
/**
* Shows the type tooltip near the cursor, labelling `element`'s logical type.
* @param {HTMLElement} element
* @param {number} clientX
* @param {number} clientY
*/
export function showTypeTooltip(element, clientX, clientY) {
const type = getElementType(element);
if (!typeTooltipElement) createTypeTooltip();
if (!typeTooltipElement.isConnected) document.body.appendChild(typeTooltipElement);
if (type !== displayedType) {
displayedType = type;
const translations = getEditorTranslations();
const translationKey = ELEMENT_TYPE_TRANSLATIONS[type]?.key;
typeTooltipElement.textContent = (translationKey && translations[translationKey]) || type;
cachedWidth = typeTooltipElement.offsetWidth;
cachedHeight = typeTooltipElement.offsetHeight;
}
typeTooltipElement.classList.add('active');
positionAt(clientX, clientY);
showHoverOutline(element);
unhighlightAllMarkers();
highlightMarkerForElement(element);
}
/** Hides the type tooltip and its paired hover outline. */
export function hideTypeTooltip() {
typeTooltipElement?.classList.remove('active');
displayedType = null;
hideHoverOutline();
unhighlightAllMarkers();
}
@@ -0,0 +1,766 @@
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { getToolbarEl, getToolbarTarget } from '../toolbar/toolbar.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { getActiveRange, wrapRange, syncUnderlineColorFromElement } from '../style-actions.js';
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { COLOR_ACTION_HTML, SOLID_COLORS, GRADIENTS } from './template.js';
import { COLOR_ACTION_STYLES } from './styles.js';
import { hsvToHex, hsvToColor, hexToHsv, parseHexInput, parseColor, extractAlpha } from './color-conversions.js';
import { parseGradientStops, buildGradient } from './gradient.js';
import {
initSwatches, syncSolidSwatchActive, syncGradientSwatchActive,
ensureCustomSwatch, ensureCustomGradientSwatch,
appendCustomSwatch, appendCustomGradientSwatch, restorePersistedSwatches,
recolorSolidSwatch, recolorGradientSwatch, setActiveSwatch,
} from './swatches.js';
import { initHexInput, setHexFromPicker, setHexInputActive, hexPointerDown, hexPointerMove, handleHexKeydown, handleHexPaste, handleHexCopy, selectAllHex } from './hex-input.js';
import { initPickerSquare } from './picker-square.js';
let colorActionElement = null;
let savedColorRange = null;
let pickerHue = 0, pickerSaturation = 1, pickerValue = 1, pickerAlpha = 1;
let pickerOnChange = null;
export let pickerDragging = false;
let hexDragging = false;
let activeSwatchOption = null;
let lastColorWrapper = null;
let _suppressHide = false;
let _colorEdited = false;
let _pickerSquare = null, _pickerIndicator = null;
let _pickerHueInput = null, _pickerAlphaInput = null;
let _eyedropperPickAbort = null;
let gradientColors = ['#FF6B6B', '#FFE66D'];
let gradientAngle = 90;
let activeGradientStop = 0;
/**
* Runs `handler` with pickerDragging forced on (so live previews skip expensive
* input events), restoring the previous flag afterwards.
* @param {() => void} handler
*/
function withPickerDragging(handler) {
const wasDragging = pickerDragging;
pickerDragging = true;
try {
handler();
} finally {
pickerDragging = wasDragging;
}
}
/** Applies a solid color to a wrapper element, clearing gradient props. */
function setWrapperSolid(element, color) {
element.style.color = color;
element.style.removeProperty('background-image');
element.style.removeProperty('background-clip');
element.style.removeProperty('-webkit-background-clip');
}
/** Applies a gradient text fill to a wrapper element. */
function setWrapperGradient(element, gradient) {
element.style.backgroundImage = gradient;
element.style.backgroundClip = 'text';
element.style.webkitBackgroundClip = 'text';
element.style.color = 'transparent';
}
/** Updates the toolbar's color swatch preview. */
function setToolbarPreview(value) {
getToolbarEl()?.querySelector('.tft-swatch-preview')?.style.setProperty('background', value);
}
/**
* Commits a solid color: restyles the existing wrapper when connected,
* otherwise wraps the saved selection silently (no input event).
* @param {string} color
*/
function applySolidFull(color) {
if (lastColorWrapper?.isConnected) {
setWrapperSolid(lastColorWrapper, color);
} else {
withPickerDragging(() => applyColorAction(color, false));
}
}
/**
* Commits a gradient like {@link applySolidFull}, re-resolving the wrapper
* after a fresh wrap so subsequent live updates can target it.
* @param {string} gradient
*/
function applyGradientFull(gradient) {
recoverGradientWrapper();
if (lastColorWrapper?.isConnected) {
setWrapperGradient(lastColorWrapper, gradient);
} else {
withPickerDragging(() => applyColorAction(gradient, true));
recoverGradientWrapper();
}
}
function isGradientTabActive() {
return !!colorActionElement?.querySelector('.tft-color-tab[data-tab="gradient"]')?.classList.contains('tca-tab-active');
}
function refreshGradientStopSwatches() {
if (!colorActionElement) return;
[0, 1].forEach(index => {
const element = colorActionElement.querySelector(`.tft-gradient-stop-swatch[data-stop="${index}"]`);
if (!element) return;
element.querySelector('.tft-color-swatch').style.background = gradientColors[index] || '#000000';
element.classList.toggle('tca-active', index === activeGradientStop);
});
}
function selectGradientStop(stopIndex) {
activeGradientStop = stopIndex;
refreshGradientStopSwatches();
const hex = parseColor(gradientColors[stopIndex]) || '#000000';
seedPicker(hex);
}
function updatePickerUI(skipHex = false) {
const hex = hsvToHex(pickerHue, pickerSaturation, pickerValue);
if (isGradientTabActive()) {
gradientColors[activeGradientStop] = hex;
refreshGradientStopSwatches();
const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
if (canApplyColor()) {
recoverGradientWrapper();
if (lastColorWrapper?.isConnected) {
lastColorWrapper.style.backgroundImage = gradient;
}
}
setToolbarPreview(gradient);
// Edit the selected gradient swatch in place (keeps it highlighted); otherwise just highlight a match.
if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-gradient')) {
recolorGradientSwatch(activeSwatchOption, gradient);
setActiveSwatch(activeSwatchOption);
} else {
activeSwatchOption = syncGradientSwatchActive(gradient);
}
} else if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-color')) {
recolorSolidSwatch(activeSwatchOption, hex);
setActiveSwatch(activeSwatchOption);
} else {
activeSwatchOption = syncSolidSwatchActive(hex);
}
if (_pickerSquare) _pickerSquare.style.background = `hsl(${pickerHue}, 100%, 50%)`;
if (_pickerIndicator) {
const width = _pickerSquare.offsetWidth;
const height = _pickerSquare.offsetHeight;
_pickerIndicator.style.left = `${pickerSaturation * width}px`;
_pickerIndicator.style.top = `${(1 - pickerValue) * height}px`;
_pickerIndicator.style.background = hex;
}
if (_pickerHueInput) _pickerHueInput.value = pickerHue;
if (_pickerAlphaInput) {
const hslColor = `hsl(${pickerHue}, 100%, 50%)`;
_pickerAlphaInput.style.background = [
`linear-gradient(to right, transparent, ${hslColor})`,
`linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 0 0 / 8px 8px`,
`linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 4px 4px / 8px 8px`,
`#2a2b2f`,
].join(', ');
}
if (!skipHex) setHexFromPicker(hex);
}
function seedPicker(hex) {
const hsv = hexToHsv(hex) ?? { h: 0, s: 0, v: 0 };
pickerHue = hsv.h;
pickerSaturation = hsv.s;
pickerValue = hsv.v;
updatePickerUI();
}
function findGradientWrapper() {
if (!getToolbarTarget()) return null;
const selection = window.getSelection();
let node = selection?.rangeCount > 0 ? selection.getRangeAt(0).commonAncestorContainer : null;
if (!node) node = savedColorRange?.commonAncestorContainer;
if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement;
while (node && node !== getToolbarTarget().parentElement) {
if (node === getToolbarTarget()) { node = node.parentElement; continue; }
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.style?.backgroundClip === 'text' || node.style?.webkitBackgroundClip === 'text') {
return node;
}
try {
const computedStyle = getComputedStyle(node);
const backgroundClip = computedStyle.backgroundClip || computedStyle.webkitBackgroundClip;
if (computedStyle.backgroundImage?.includes('gradient') && backgroundClip === 'text') {
return node;
}
} catch { /* ignore */ }
}
node = node.parentElement;
}
return null;
}
function recoverGradientWrapper() {
if (lastColorWrapper?.isConnected) return;
lastColorWrapper = findGradientWrapper();
if (lastColorWrapper) {
try {
const range = document.createRange();
range.selectNodeContents(lastColorWrapper);
savedColorRange = range.cloneRange();
} catch { /* ignore */ }
}
}
function applyLive(color) {
if (isGradientTabActive()) {
gradientColors[activeGradientStop] = color;
refreshGradientStopSwatches();
const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
if (canApplyColor()) {
recoverGradientWrapper();
if (lastColorWrapper?.isConnected) {
lastColorWrapper.style.backgroundImage = gradient;
} else {
withPickerDragging(() => applyColorAction(gradient, true));
}
}
setToolbarPreview(gradient);
} else {
if (canApplyColor()) {
if (lastColorWrapper?.isConnected) {
lastColorWrapper.style.color = color;
} else {
withPickerDragging(() => applyColorAction(color, false));
}
}
setToolbarPreview(color);
}
}
function notifyChange() {
if (!getToolbarTarget()) return;
// Swatches are only created via the "+" button now, never auto-added on commit.
getToolbarTarget().dispatchEvent(new CustomEvent('tft-color-commit', { bubbles: true }));
}
function applyColorAction(value, isGradient) {
if (!savedColorRange || !getToolbarTarget() || !value) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedColorRange);
applyColor(getToolbarTarget(), value, isGradient);
if (selection.rangeCount > 0) savedColorRange = selection.getRangeAt(0).cloneRange();
if (!pickerDragging) onSelectionChange();
}
function stripColorProps(element) {
element.style.removeProperty('color');
element.style.removeProperty('background-image');
element.style.removeProperty('background-clip');
element.style.removeProperty('-webkit-background-clip');
}
function hasColorProps(element) {
return !!(element.style.color || element.style.backgroundImage || element.style.backgroundClip || element.style.webkitBackgroundClip);
}
function findReusableAncestor(range, editTarget) {
let element = range.commonAncestorContainer;
if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
while (element && element !== editTarget) {
if (element.nodeType === Node.ELEMENT_NODE) {
try {
const elementRange = document.createRange();
elementRange.selectNodeContents(element);
const selectionText = range.toString();
const elementText = elementRange.toString();
if (selectionText && elementText && selectionText === elementText) return element;
} catch { /* ignore */ }
}
element = element.parentElement;
}
return null;
}
function splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient) {
const parentParent = parent.parentNode;
if (!parentParent) return;
const before = parent.cloneNode(false);
const after = parent.cloneNode(false);
let node = parent.firstChild;
while (node && node !== wrapper) {
const next = node.nextSibling;
before.appendChild(node);
node = next;
}
node = wrapper.nextSibling;
while (node) {
const next = node.nextSibling;
after.appendChild(node);
node = next;
}
parent.removeChild(wrapper);
// Inherit parent styles then apply new color
wrapper.style.cssText = parent.style.cssText;
Object.assign(wrapper.style, styleProperties);
if (!isGradient) {
wrapper.style.removeProperty('background-image');
wrapper.style.removeProperty('background-clip');
wrapper.style.removeProperty('-webkit-background-clip');
}
if (before.hasChildNodes()) parentParent.insertBefore(before, parent);
parentParent.insertBefore(wrapper, parent);
if (after.hasChildNodes()) parentParent.insertBefore(after, parent);
parentParent.removeChild(parent);
}
function applyColor(editTarget, value, isGradient) {
const range = getActiveRange(editTarget);
if (!range) return;
const styleProperties = isGradient
? { backgroundImage: value, backgroundClip: 'text', webkitBackgroundClip: 'text', color: 'transparent' }
: { color: value };
const reuse = findReusableAncestor(range, editTarget);
if (reuse) {
Object.assign(reuse.style, styleProperties);
if (!isGradient) {
reuse.style.removeProperty('background-image');
reuse.style.removeProperty('background-clip');
reuse.style.removeProperty('-webkit-background-clip');
}
lastColorWrapper = reuse;
reuse.querySelectorAll('*').forEach(element => {
if (hasColorProps(element)) stripColorProps(element);
});
syncUnderlineColorFromElement(reuse, editTarget);
if (!pickerDragging) editTarget.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const wrapper = wrapRange(range, 'span', styleProperties);
lastColorWrapper = wrapper ?? null;
if (wrapper) {
wrapper.querySelectorAll('*').forEach(element => {
if (hasColorProps(element)) stripColorProps(element);
});
const parent = wrapper.parentElement;
if (parent && parent !== editTarget && parent.tagName === 'SPAN' && hasColorProps(parent)) {
const parentRange = document.createRange();
parentRange.selectNodeContents(parent);
if (parentRange.toString() === wrapper.textContent) {
stripColorProps(parent);
} else {
splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient);
}
}
syncUnderlineColorFromElement(wrapper, editTarget);
}
if (!pickerDragging) {
requestAnimationFrame(() => {
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
});
}
}
export function positionColorAction() {
if (!isPanelVisible(PanelId.COLOR) || !colorActionElement) return;
positionDropdownPanel(colorActionElement, { buttonAction: 'color' });
}
const outsideDismiss = createOutsideDismiss({
getElement: () => colorActionElement,
dismiss: () => {
_suppressHide = false;
hideColorAction();
},
ignoreToolbar: false,
ignoreMouseDown: (event) => !!event.target.closest('[data-action="color"]'),
captureMouseDown: true,
});
/**
* @returns {boolean} Whether color can be applied to content. A highlighted
* range colors that selection; a collapsed caret inside the edit target colors
* the whole element (same as bold/italic via {@link getActiveRange}).
*/
function canApplyColor() {
if (!savedColorRange || !getToolbarTarget()) return false;
if (!savedColorRange.collapsed) return true;
return getToolbarTarget().contains(savedColorRange.commonAncestorContainer);
}
/** True only when the user has highlighted text (not a bare caret). */
function hasExpandedTextSelection() {
return !!savedColorRange && !savedColorRange.collapsed;
}
function onSelectionChangeForColor() {
if (!isPanelVisible(PanelId.COLOR)) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (!getToolbarTarget()?.contains(range.commonAncestorContainer)) return;
savedColorRange = range.cloneRange();
}
function switchColorTab(tab) {
if (!colorActionElement) return;
colorActionElement.querySelectorAll('.tft-color-tab').forEach(tabElement =>
tabElement.classList.toggle('tca-tab-active', tabElement.dataset.tab === tab));
colorActionElement.querySelectorAll('[data-tab-panel]').forEach(panelElement =>
panelElement.classList.toggle('tca-hidden', panelElement.dataset.tabPanel !== tab));
if (tab === 'gradient' && isPanelVisible(PanelId.COLOR)) {
const firstPreset = parseGradientStops(GRADIENTS[0]);
if (firstPreset) {
gradientAngle = firstPreset.angle;
gradientColors = firstPreset.colors.map(color => parseColor(color) || color);
}
refreshGradientStopSwatches();
selectGradientStop(0);
activeSwatchOption = syncGradientSwatchActive(GRADIENTS[0]);
if (hasExpandedTextSelection() && getToolbarTarget()) {
applyGradientFull(GRADIENTS[0]);
notifyChange();
}
setToolbarPreview(GRADIENTS[0]);
} else if (tab === 'solid' && isPanelVisible(PanelId.COLOR)) {
const defaultSolid = SOLID_COLORS[0];
seedPicker(defaultSolid);
activeSwatchOption = syncSolidSwatchActive(defaultSolid);
syncGradientSwatchActive(null);
if (hasExpandedTextSelection() && getToolbarTarget()) {
recoverGradientWrapper();
applySolidFull(defaultSolid);
notifyChange();
}
setToolbarPreview(defaultSolid);
}
positionColorAction();
}
function initColorAction() {
if (colorActionElement) return;
colorActionElement = createPanelElement({
styleId: 'text-format-color-action-styles',
styles: COLOR_ACTION_STYLES,
html: COLOR_ACTION_HTML,
});
initSwatches(colorActionElement);
restorePersistedSwatches();
colorActionElement.addEventListener('mousedown', (event) => {
if (event.target.closest('input[type="range"]')) return;
event.preventDefault();
if (event.target.closest('.tft-picker-hex-input')) {
setHexInputActive(true);
hexPointerDown(event.clientX);
hexDragging = true;
}
});
document.addEventListener('mousemove', (event) => {
if (hexDragging) hexPointerMove(event.clientX);
});
document.addEventListener('mouseup', () => { hexDragging = false; });
_pickerSquare = colorActionElement.querySelector('.tft-picker-square');
_pickerIndicator = colorActionElement.querySelector('.tft-picker-indicator');
_pickerHueInput = colorActionElement.querySelector('.tft-picker-hue');
_pickerAlphaInput = colorActionElement.querySelector('.tft-picker-alpha');
const hexElement = colorActionElement.querySelector('.tft-picker-hex-input');
initHexInput(hexElement);
initPickerSquare(_pickerSquare, {
onDragStart: (saturation, value) => {
pickerDragging = true;
_colorEdited = true;
if (canApplyColor() && !lastColorWrapper?.isConnected) {
if (isGradientTabActive()) {
applyColorAction(buildGradient(gradientAngle, gradientColors[0], gradientColors[1]), true);
} else {
applyColorAction(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha), false);
}
}
pickerSaturation = saturation; pickerValue = value;
updatePickerUI();
},
onPickChange: (saturation, value) => {
if (!pickerDragging) return;
pickerSaturation = saturation; pickerValue = value;
updatePickerUI();
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
},
onDragEnd: () => {
pickerDragging = false;
notifyChange();
},
});
_pickerHueInput.addEventListener('input', () => {
_colorEdited = true;
pickerHue = Number(_pickerHueInput.value);
updatePickerUI();
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
});
_pickerHueInput.addEventListener('change', () => notifyChange());
_pickerAlphaInput.addEventListener('input', () => {
_colorEdited = true;
pickerAlpha = Number(_pickerAlphaInput.value) / 100;
updatePickerUI(true);
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
});
_pickerAlphaInput.addEventListener('change', () => notifyChange());
const eyedropperButton = colorActionElement.querySelector('.tft-picker-hex-picker');
if (eyedropperButton) {
if (!('EyeDropper' in window)) {
eyedropperButton.style.display = 'none';
} else {
eyedropperButton.addEventListener('click', async (event) => {
event.stopPropagation();
_suppressHide = true;
_eyedropperPickAbort?.abort();
_eyedropperPickAbort = new AbortController();
try {
const { sRGBHex } = await new window.EyeDropper().open({ signal: _eyedropperPickAbort.signal });
const parsed = parseHexInput(sRGBHex);
if (!parsed) return;
_colorEdited = true;
seedPicker(parsed);
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
notifyChange();
} catch (error) {
if (error?.name !== 'AbortError') console.warn('EyeDropper failed', error);
} finally {
_eyedropperPickAbort = null;
requestAnimationFrame(() => { _suppressHide = false; });
}
});
}
}
colorActionElement.addEventListener('dblclick', (event) => {
if (event.target.closest('.tft-picker-hex-input')) {
event.preventDefault();
selectAllHex();
}
});
colorActionElement.addEventListener('click', (event) => {
const tab = event.target.closest('.tft-color-tab');
if (tab) {
switchColorTab(tab.dataset.tab);
return;
}
if (event.target.closest('.tft-picker-hex-input')) return;
const stopSwatch = event.target.closest('.tft-gradient-stop-swatch');
if (stopSwatch) {
selectGradientStop(Number(stopSwatch.dataset.stop));
return;
}
const addButton = event.target.closest('.tft-color-add');
if (addButton) {
_colorEdited = true;
if (isGradientTabActive()) {
const gradient = activeSwatchOption?.dataset.gradient
|| buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
const newSwatch = appendCustomGradientSwatch(gradient);
if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); }
setToolbarPreview(gradient);
} else {
const hex = activeSwatchOption?.dataset.color
|| hsvToHex(pickerHue, pickerSaturation, pickerValue);
const newSwatch = appendCustomSwatch(hex);
if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); }
setToolbarPreview(hex);
}
return;
}
const option = event.target.closest('.tft-color-option');
if (!option) return;
if (option.dataset.gradient) {
const gradient = option.dataset.gradient;
activeSwatchOption = option;
setActiveSwatch(option);
const parsed = parseGradientStops(gradient);
if (parsed) {
gradientAngle = parsed.angle;
gradientColors = [...parsed.colors];
}
refreshGradientStopSwatches();
selectGradientStop(0);
if (canApplyColor()) applyGradientFull(gradient);
setToolbarPreview(gradient);
notifyChange();
} else {
const hex = option.dataset.color;
activeSwatchOption = option;
setActiveSwatch(option);
seedPicker(hex);
if (canApplyColor()) applySolidFull(hex);
setToolbarPreview(hex);
notifyChange();
}
});
}
function tryLiveFromHex(rawValue) {
const hex = parseHexInput(rawValue);
if (!hex) return;
_colorEdited = true;
const hsv = hexToHsv(hex);
if (!hsv) return;
pickerHue = hsv.h; pickerSaturation = hsv.s; pickerValue = hsv.v;
updatePickerUI(true);
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
}
function onHexCommit(hex) {
seedPicker(hex);
_suppressHide = false;
if (isGradientTabActive()) {
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
} else {
pickerOnChange?.(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
}
hideColorAction();
}
function onHexCancel() {
_suppressHide = false;
hideColorAction();
}
function onHexKeydownHandler(event) {
if (!isPanelVisible(PanelId.COLOR)) return;
handleHexKeydown(event, { onLiveChange: tryLiveFromHex, onCommit: onHexCommit, onCancel: onHexCancel });
}
function onHexPasteHandler(event) {
if (!isPanelVisible(PanelId.COLOR)) return;
const newValue = handleHexPaste(event);
if (newValue !== undefined) tryLiveFromHex(newValue);
}
function onHexCopyHandler(event) {
if (!isPanelVisible(PanelId.COLOR)) return;
const cut = handleHexCopy(event);
if (cut) tryLiveFromHex('');
}
/** Opens the color panel seeded from the current selection styles. */
export function showColorAction() {
if (!colorActionElement) initColorAction();
savedColorRange = captureSelectionRange();
const styles = getStylesAtCursor();
const currentValue = (styles?.color ?? '').toLowerCase();
const isGradientActive = !!styles?.isGradient;
_colorEdited = false;
if (isGradientActive) {
ensureCustomGradientSwatch(currentValue);
activeSwatchOption = syncGradientSwatchActive(currentValue);
syncSolidSwatchActive(null);
} else {
const parsedColor = parseColor(currentValue);
if (parsedColor) ensureCustomSwatch(parsedColor);
activeSwatchOption = syncSolidSwatchActive(parsedColor);
syncGradientSwatchActive(null);
}
switchColorTab(isGradientActive ? 'gradient' : 'solid');
colorActionElement.classList.add('tca-visible');
setPanelVisible(PanelId.COLOR, true);
_suppressHide = true;
positionColorAction();
pickerAlpha = isGradientActive ? 1 : extractAlpha(currentValue);
if (_pickerAlphaInput) _pickerAlphaInput.value = Math.round(pickerAlpha * 100);
pickerOnChange = (color) => {
if (canApplyColor()) applySolidFull(color);
setToolbarPreview(color);
notifyChange();
};
setHexInputActive(false);
if (isGradientActive) {
const parsed = parseGradientStops(currentValue);
if (parsed) {
gradientAngle = parsed.angle;
gradientColors = parsed.colors.map(color => parseColor(color) || color);
}
refreshGradientStopSwatches();
selectGradientStop(0);
} else {
activeGradientStop = 0;
seedPicker(parseColor(currentValue) || '#000000');
}
document.addEventListener('keydown', onHexKeydownHandler, true);
document.addEventListener('paste', onHexPasteHandler, true);
document.addEventListener('copy', onHexCopyHandler, true);
document.addEventListener('cut', onHexCopyHandler, true);
document.addEventListener('selectionchange', onSelectionChangeForColor);
outsideDismiss.attach();
}
/**
* Closes the color panel and resets picker state.
* @param {boolean} [force] - Close even when hide is suppressed (e.g. eyedropper).
*/
export function hideColorAction(force = false) {
if (!force && _suppressHide) return;
if (!colorActionElement) return;
_eyedropperPickAbort?.abort();
_eyedropperPickAbort = null;
// Swatches are added/edited explicitly via the grid now — nothing to persist on close.
_colorEdited = false;
_suppressHide = false;
colorActionElement.classList.remove('tca-visible');
setPanelVisible(PanelId.COLOR, false);
savedColorRange = null;
pickerOnChange = null;
activeSwatchOption = null;
lastColorWrapper = null;
setHexInputActive(false);
gradientColors = ['#FF6B6B', '#FFE66D'];
gradientAngle = 90;
activeGradientStop = 0;
document.removeEventListener('keydown', onHexKeydownHandler, true);
document.removeEventListener('paste', onHexPasteHandler, true);
document.removeEventListener('copy', onHexCopyHandler, true);
document.removeEventListener('cut', onHexCopyHandler, true);
document.removeEventListener('selectionchange', onSelectionChangeForColor);
outsideDismiss.detach();
}
/** Toggles the color panel visibility. */
export function toggleColorAction() {
if (isPanelVisible(PanelId.COLOR)) {
_suppressHide = false;
hideColorAction();
} else {
showColorAction();
}
}
registerPanel({ id: PanelId.COLOR, hide: hideColorAction });
@@ -0,0 +1,104 @@
/**
* @param {number} hue - Hue 0–360.
* @param {number} saturation - Saturation 0–1.
* @param {number} value - Value 0–1.
* @returns {string}
*/
export function hsvToHex(hue, saturation, value) {
const f = (n, k = (n + hue / 60) % 6) => value - value * saturation * Math.max(Math.min(k, 4 - k, 1), 0);
const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
return `#${toHex(f(5))}${toHex(f(3))}${toHex(f(1))}`;
}
/**
* @param {number} hue
* @param {number} saturation
* @param {number} value
* @param {number} [alpha] - Alpha 0–1.
* @returns {string}
*/
export function hsvToColor(hue, saturation, value, alpha = 1) {
const hex = hsvToHex(hue, saturation, value);
if (alpha >= 1) return hex;
const red = parseInt(hex.slice(1, 3), 16);
const green = parseInt(hex.slice(3, 5), 16);
const blue = parseInt(hex.slice(5, 7), 16);
return `rgba(${red}, ${green}, ${blue}, ${parseFloat(alpha.toFixed(3))})`;
}
/**
* @param {string} hex
* @returns {{ h: number, s: number, v: number }|null}
*/
export function hexToHsv(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.split('').map(character => character + character).join('');
if (hex.length !== 6) return null;
const red = parseInt(hex.slice(0, 2), 16) / 255;
const green = parseInt(hex.slice(2, 4), 16) / 255;
const blue = parseInt(hex.slice(4, 6), 16) / 255;
const max = Math.max(red, green, blue), min = Math.min(red, green, blue);
const value = max, delta = max - min;
const saturation = max === 0 ? 0 : delta / max;
let hue = 0;
if (delta !== 0) {
switch (max) {
case red: hue = ((green - blue) / delta + (green < blue ? 6 : 0)) / 6; break;
case green: hue = ((blue - red) / delta + 2) / 6; break;
default: hue = ((red - green) / delta + 4) / 6; break;
}
}
return { h: hue * 360, s: saturation, v: value };
}
/**
* @param {string} value
* @returns {string|null} Normalized #rrggbb or null.
*/
export function parseHexInput(value) {
value = value.trim().replace(/^#/, '');
if (value.length === 3) value = value.split('').map(character => character + character).join('');
if (!/^[0-9a-fA-F]{6}$/.test(value)) return null;
return `#${value.toLowerCase()}`;
}
/**
* @param {string} value - Any CSS color string.
* @returns {string|null}
*/
export function cssColorToHex(value) {
const cleaned = value.replace(/\s+[\d.]+(%|px|em|rem)\s*$/, '').trim();
try {
const context = document.createElement('canvas').getContext('2d');
context.fillStyle = '#010203';
context.fillStyle = cleaned;
const result = context.fillStyle;
if (result === '#010203' && cleaned !== '#010203') return null;
return parseHexInput(result);
} catch { return null; }
}
/**
* @param {string} value
* @returns {string|null} Hex color when parseable.
*/
export function parseColor(value) {
if (!value) return null;
const hex = parseHexInput(value);
if (hex) return hex;
const rgbMatch = value.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
if (rgbMatch) return `#${[rgbMatch[1], rgbMatch[2], rgbMatch[3]].map(component => Number(component).toString(16).padStart(2, '0')).join('')}`;
const rgbaMatch = value.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)/);
if (rgbaMatch) return `#${[rgbaMatch[1], rgbaMatch[2], rgbaMatch[3]].map(component => Number(component).toString(16).padStart(2, '00')).join('')}`;
return cssColorToHex(value);
}
/**
* @param {string} value
* @returns {number} Alpha 0–1.
*/
export function extractAlpha(value) {
if (!value) return 1;
const match = value.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
return match ? Math.min(1, Math.max(0, parseFloat(match[1]))) : 1;
}
@@ -0,0 +1,59 @@
import { parseColor } from './color-conversions.js';
/**
* @param {string} value - CSS linear-gradient value.
* @returns {{ angle: number, colors: string[] }|null}
*/
export function parseGradientStops(value) {
if (!value) return null;
const match = value.match(/linear-gradient\(\s*([\s\S]+)\s*\)/);
if (!match) return null;
const segments = [];
let depth = 0, current = '';
for (const character of match[1] + ',') {
if (character === '(') { depth++; current += character; }
else if (character === ')') { depth--; current += character; }
else if (character === ',' && depth === 0) { segments.push(current.trim()); current = ''; }
else { current += character; }
}
if (segments.length < 2) return null;
let angle = 90;
let colorStart = 0;
const first = segments[0];
if (/^\d+(\.\d+)?deg$/.test(first)) {
angle = parseFloat(first);
colorStart = 1;
} else if (/^to\s+/i.test(first)) {
const map = { 'to right': 90, 'to left': 270, 'to top': 0, 'to bottom': 180,
'to top right': 45, 'to top left': 315, 'to bottom right': 135, 'to bottom left': 225 };
angle = map[first.toLowerCase()] ?? 90;
colorStart = 1;
}
const colors = segments.slice(colorStart);
if (colors.length < 2) return null;
return { angle, colors: [colors[0], colors[1]] };
}
/**
* @param {number} angle - Degrees.
* @param {string} firstColor
* @param {string} secondColor
* @returns {string}
*/
export function buildGradient(angle, firstColor, secondColor) {
return `linear-gradient(${angle}deg, ${firstColor}, ${secondColor})`;
}
/**
* Normalized key for comparing gradient presets and custom swatches.
* @param {string} value
* @returns {string}
*/
export function canonicalGradientKey(value) {
if (!value) return '';
const parsed = parseGradientStops(value);
if (!parsed) return value.replace(/\s/g, '').toLowerCase();
const firstColor = (parseColor(parsed.colors[0]) || parsed.colors[0]).toLowerCase();
const secondColor = (parseColor(parsed.colors[1]) || parsed.colors[1]).toLowerCase();
return `${parsed.angle}|${firstColor}|${secondColor}`;
}
@@ -0,0 +1,252 @@
import { parseHexInput } from './color-conversions.js';
let _element = null;
let hexVirtualValue = '';
let hexVirtualCursor = 0;
let hexAnchor = 0;
let hexInputActive = false;
/** @param {HTMLElement} element */
export function initHexInput(element) { _element = element; }
/** @returns {boolean} */
export function isHexInputActive() { return hexInputActive; }
/** @returns {string} */
export function getHexValue() { return hexVirtualValue; }
/** @returns {number} Lower selection bound. */
function selectionStart() { return Math.min(hexAnchor, hexVirtualCursor); }
/** @returns {number} Upper selection bound. */
function selectionEnd() { return Math.max(hexAnchor, hexVirtualCursor); }
/** @returns {boolean} Whether a (non-collapsed) selection exists. */
function hasSelection() { return hexAnchor !== hexVirtualCursor; }
/** Collapses the selection onto the caret. */
function collapseSelection() { hexAnchor = hexVirtualCursor; }
/**
* @param {string} hex
*/
export function setHexFromPicker(hex) {
hexVirtualValue = hex;
hexVirtualCursor = hex.length;
hexAnchor = hex.length;
renderHexInput();
}
/** @param {boolean} active */
export function setHexInputActive(active) {
hexInputActive = active;
if (!active) {
_element?.classList.remove('tca-focused');
collapseSelection();
}
}
/** Selects the whole hex value (e.g. on double-click) so the next input/paste replaces it. */
export function selectAllHex() {
hexInputActive = true;
_element?.classList.add('tca-focused');
hexAnchor = 0;
hexVirtualCursor = hexVirtualValue.length;
renderHexInput();
}
/** Renders the virtual hex field as per-character spans (so selection can be highlighted). */
export function renderHexInput() {
if (!_element) return;
if (!hexInputActive) {
_element.textContent = hexVirtualValue;
return;
}
const start = selectionStart();
const end = selectionEnd();
const caret = '<span class="tft-picker-hex-cursor"></span>';
let html = '';
for (let index = 0; index < hexVirtualValue.length; index++) {
if (index === hexVirtualCursor) html += caret;
const selectionClass = index >= start && index < end ? ' tft-picker-hex-csel' : '';
html += `<span class="tft-picker-hex-char${selectionClass}" data-i="${index}">${hexVirtualValue[index]}</span>`;
}
if (hexVirtualCursor === hexVirtualValue.length) html += caret;
_element.innerHTML = html;
}
/**
* Maps a viewport x coordinate to a caret index between characters.
* @param {number} pointerX
* @returns {number}
*/
function caretIndexFromPointerX(pointerX) {
if (!_element) return 0;
const characters = _element.querySelectorAll('.tft-picker-hex-char');
for (let index = 0; index < characters.length; index++) {
const characterBounds = characters[index].getBoundingClientRect();
if (pointerX < characterBounds.left + characterBounds.width / 2) return index;
}
return characters.length;
}
/**
* Begins a click/drag in the field: activates it and drops a collapsed caret.
* @param {number} pointerX
*/
export function hexPointerDown(pointerX) {
hexInputActive = true;
_element?.classList.add('tca-focused');
renderHexInput();
const caretIndex = caretIndexFromPointerX(pointerX);
hexAnchor = caretIndex;
hexVirtualCursor = caretIndex;
renderHexInput();
}
/**
* Extends the selection to the dragged-to position.
* @param {number} pointerX
*/
export function hexPointerMove(pointerX) {
if (!hexInputActive) return;
hexVirtualCursor = caretIndexFromPointerX(pointerX);
renderHexInput();
}
/**
* @param {KeyboardEvent} event
* @param {{ onLiveChange: (hex: string) => void, onCommit: (hex: string) => void, onCancel: () => void }} callbacks
*/
export function handleHexKeydown(event, { onLiveChange, onCommit, onCancel }) {
if (event.key === 'Enter') {
event.preventDefault(); event.stopPropagation();
const hex = parseHexInput(hexVirtualValue);
if (hex) onCommit(hex);
return;
}
if (event.key === 'Escape') {
event.preventDefault(); event.stopPropagation();
onCancel();
return;
}
if (!hexInputActive) return;
if (event.key === 'a' && (event.metaKey || event.ctrlKey)) {
event.preventDefault(); event.stopPropagation();
hexAnchor = 0;
hexVirtualCursor = hexVirtualValue.length;
renderHexInput();
return;
}
if (hasSelection()) {
const start = selectionStart();
const end = selectionEnd();
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && /[#0-9a-fA-F]/.test(event.key)) {
event.preventDefault(); event.stopPropagation();
hexVirtualValue = (hexVirtualValue.slice(0, start) + event.key + hexVirtualValue.slice(end)).slice(0, 7);
hexVirtualCursor = start + 1;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
return;
}
if (event.key === 'Backspace' || event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(end);
hexVirtualCursor = start;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
return;
}
collapseSelection();
}
if (event.key === 'Backspace') {
event.preventDefault(); event.stopPropagation();
if (hexVirtualCursor > 0) {
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor - 1) + hexVirtualValue.slice(hexVirtualCursor);
hexVirtualCursor--;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
}
return;
}
if (event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
if (hexVirtualCursor < hexVirtualValue.length) {
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + hexVirtualValue.slice(hexVirtualCursor + 1);
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
}
return;
}
if ((event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey)) || event.key === 'Home') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = 0; collapseSelection(); renderHexInput(); return;
}
if ((event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey)) || event.key === 'End') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = hexVirtualValue.length; collapseSelection(); renderHexInput(); return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = Math.max(0, hexVirtualCursor - 1); collapseSelection(); renderHexInput(); return;
}
if (event.key === 'ArrowRight') {
event.preventDefault(); event.stopPropagation();
hexVirtualCursor = Math.min(hexVirtualValue.length, hexVirtualCursor + 1); collapseSelection(); renderHexInput(); return;
}
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
event.preventDefault(); event.stopPropagation();
if (hexVirtualValue.length < 7 && /[#0-9a-fA-F]/.test(event.key)) {
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + event.key + hexVirtualValue.slice(hexVirtualCursor);
hexVirtualCursor++;
collapseSelection();
renderHexInput();
onLiveChange(hexVirtualValue);
}
}
}
/**
* Handles copy/cut for the virtual hex field: writes the selected text to the
* clipboard ourselves (the field isn't a real input, so the browser can't).
* @param {ClipboardEvent} event
* @returns {boolean} Whether the value changed (cut).
*/
export function handleHexCopy(event) {
if (!hexInputActive || !hasSelection()) return false;
const text = hexVirtualValue.slice(selectionStart(), selectionEnd());
if (!text) return false;
event.preventDefault(); event.stopPropagation();
event.clipboardData?.setData('text/plain', text);
if (event.type === 'cut') {
const start = selectionStart();
hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(selectionEnd());
hexVirtualCursor = start;
collapseSelection();
renderHexInput();
return true;
}
return false;
}
/**
* @param {ClipboardEvent} event
* @returns {string|undefined} Updated hex value after paste.
*/
export function handleHexPaste(event) {
event.preventDefault(); event.stopPropagation();
const inserted = (event.clipboardData?.getData('text/plain') || '').replace(/[^#0-9a-fA-F]/g, '');
// Replace the current selection (if any), otherwise insert at the caret.
const start = selectionStart();
const end = selectionEnd();
const next = (hexVirtualValue.slice(0, start) + inserted + hexVirtualValue.slice(end)).slice(0, 7);
hexVirtualCursor = Math.min(next.length, start + inserted.length);
hexVirtualValue = next;
collapseSelection();
renderHexInput();
return hexVirtualValue;
}
@@ -0,0 +1,35 @@
/**
* Wires mousedown/drag on the SV square to saturation/value callbacks.
* @param {HTMLElement} squareElement
* @param {{ onDragStart: (s: number, v: number) => void, onPickChange: (s: number, v: number) => void, onDragEnd: () => void }} callbacks
*/
export function initPickerSquare(squareElement, callbacks) {
const { onDragStart, onPickChange, onDragEnd } = callbacks;
squareElement.addEventListener('mousedown', (event) => {
const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement);
onDragStart(saturation, value);
function onMove(event) {
event.preventDefault();
const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement);
onPickChange(saturation, value);
}
function onUp(event) {
event.preventDefault();
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
onDragEnd();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
function getSaturationValueFromPointer(event, squareElement) {
const bounds = squareElement.getBoundingClientRect();
return {
s: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
v: Math.max(0, Math.min(1, 1 - (event.clientY - bounds.top) / bounds.height)),
};
}
@@ -0,0 +1,305 @@
import { COLOR_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, HOVER_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the text color and gradient action panel. */
export const COLOR_ACTION_STYLES = `
#${COLOR_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
width: 216px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 20px;
padding: 12px;
box-shadow: ${BOX_SHADOW_DROPDOWN};
font-family: DM Sans, sans-serif;
user-select: none;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${COLOR_ACTION_ID}.tca-visible {
display: flex;
}
.tft-color-tabs {
display: flex;
gap: 1px;
padding: 1px;
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
}
.tft-color-tab {
flex: 1;
background: transparent;
border: none;
color: ${COLOR_WHITE};
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 20px;
}
.tft-color-tab:hover {
background: ${HOVER_BG};
}
.tft-color-tab.tca-tab-active {
background: ${BORDER_COLOR};
}
.tft-color-grid {
display: flex;
flex-wrap: wrap;
margin: 10px 0px;
}
.tft-color-grid.tca-hidden {
display: none;
}
.tft-color-option {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
padding: 2px;
}
.tft-color-swatch {
display: block;
width: 20px;
height: 20px;
border-radius: 5px;
border: 1px solid #262831;
pointer-events: none;
transition: border-color 0.15s;
}
/* Indicate hover/active by recoloring the swatch's own border (no extra ring). */
.tft-color-option:hover .tft-color-swatch,
.tft-color-option.tca-active .tft-color-swatch {
border-color: ${COLOR_WHITE};
}
.tft-color-add {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
padding: 2px;
cursor: pointer;
}
.tft-color-add-box {
box-sizing: border-box;
position: relative;
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid ${COLOR_WHITE}4d;
transition: border-color 0.15s;
}
/* Plus drawn with two bars so it is always geometrically centered. */
.tft-color-add-box::before,
.tft-color-add-box::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: ${COLOR_WHITE}99;
transition: background 0.15s;
}
.tft-color-add-box::before { width: 9px; height: 1px; }
.tft-color-add-box::after { width: 1px; height: 9px; }
.tft-color-add:hover .tft-color-add-box {
border-color: ${COLOR_WHITE};
}
.tft-color-add:hover .tft-color-add-box::before,
.tft-color-add:hover .tft-color-add-box::after {
background: ${COLOR_WHITE};
}
.tft-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.tft-picker-square {
position: relative;
width: 100%;
height: 116px;
border-radius: 6px;
overflow: hidden;
cursor: crosshair;
}
.tft-picker-sq-white {
position: absolute;
inset: 0;
background: linear-gradient(to right, ${COLOR_WHITE}, transparent);
pointer-events: none;
}
.tft-picker-sq-black {
position: absolute;
inset: 0;
background: linear-gradient(to top, #000, transparent);
pointer-events: none;
}
.tft-picker-indicator {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid ${COLOR_WHITE};
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
transform: translate(-50%, -50%);
pointer-events: none;
}
.tft-picker-hue,
.tft-picker-alpha {
width: 100%;
height: 10px;
-webkit-appearance: none;
appearance: none;
border-radius: 5px;
outline: none;
cursor: pointer;
border: none;
}
.tft-picker-hue {
background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
}
.tft-picker-hue::-webkit-slider-thumb,
.tft-picker-alpha::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: ${COLOR_WHITE};
border: 1.5px solid rgba(255,255,255,0.4);
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
cursor: pointer;
}
.tft-picker-hue::-moz-range-thumb,
.tft-picker-alpha::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: ${COLOR_WHITE};
border: 1.5px solid rgba(255,255,255,0.4);
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
cursor: pointer;
}
.tft-picker-hex-row {
display: flex;
align-items: center;
gap: 6px;
}
.tft-picker-hex-input {
flex: 1;
background: transparent;
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
color: ${COLOR_WHITE};
font-family: inherit;
font-size: 12px;
line-height: 20px;
padding: 6px 8px;
height: 28px;
box-sizing: border-box;
cursor: text;
position: relative;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
transition: border-color 0.15s;
}
.tft-picker-hex-input.tca-focused {
border-color: ${BORDER_COLOR_FOCUS};
}
.tft-picker-hex-cursor {
display: inline-block;
width: 1px;
height: 1em;
background: ${COLOR_WHITE};
margin: 0 -1px;
vertical-align: text-bottom;
animation: tft-cursor-blink 1s step-end infinite;
}
.tft-picker-hex-char {
white-space: pre;
}
.tft-picker-hex-csel {
background: #357DF966;
border-radius: 1px;
}
@keyframes tft-cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.tft-picker-hex-picker {
background: transparent;
border: 1px solid ${BORDER_COLOR};
padding: 0;
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.tft-picker-hex-picker:hover { background: rgba(255, 255, 255, 0.05); }
.tft-gradient-stops {
display: flex;
gap: 4px;
margin: 0 0 8px;
}
.tft-gradient-stops.tca-hidden { display: none; }
.tft-gradient-stop-swatch {
flex: 1;
height: 28px;
border-radius: 10px;
}
.tft-gradient-stop-swatch .tft-color-swatch {
width: 100%;
height: 100%;
border-radius: 7px;
}
`;
@@ -0,0 +1,295 @@
import { SOLID_COLORS, GRADIENTS } from './template.js';
import { canonicalGradientKey } from './gradient.js';
/** Maximum total swatches shown in a grid (presets + custom): 3 rows of 7. */
export const COLOR_GRID_MAX = 21;
/** Maximum custom solid/gradient swatches persisted in localStorage. */
export const CUSTOM_SWATCHES_MAX = 32;
/** localStorage key for user-added solid color swatches. */
const CUSTOM_SOLIDS_KEY = 'tft-color-custom-solids';
/** localStorage key for user-added gradient swatches. */
const CUSTOM_GRADIENTS_KEY = 'tft-color-custom-gradients';
/** Lowercase preset solid colors used to skip duplicate custom entries. */
const PRESET_SOLIDS_LOWERCASE = SOLID_COLORS.map(color => color.toLowerCase());
/** Canonical keys for preset gradients used to skip duplicate custom entries. */
const PRESET_GRADIENT_KEYS = GRADIENTS.map(gradient => canonicalGradientKey(gradient));
let _element = null;
/** @param {HTMLElement} colorActionElement */
export function initSwatches(colorActionElement) {
_element = colorActionElement;
}
/**
* @param {HTMLElement} grid
* @returns {boolean} Whether the grid is already at the 21-swatch cap.
*/
function isGridFull(grid) {
return grid.querySelectorAll('.tft-color-option').length >= COLOR_GRID_MAX;
}
/** @param {HTMLElement} option @returns {boolean} */
export function isCustomSwatchEl(option) {
return !!option && option.classList?.contains('tft-custom');
}
/** Highlights `option` as the active swatch within its grid (and clears siblings). */
export function setActiveSwatch(option) {
const grid = option?.parentElement;
if (!grid) return;
grid.querySelectorAll('.tft-color-option').forEach(swatchOption => swatchOption.classList.toggle('tca-active', swatchOption === option));
}
/** Shows/hides the grid's "+" add button based on the 21-swatch cap. */
export function updateAddButtonVisibility(grid) {
const addButton = grid?.querySelector('.tft-color-add');
if (!addButton) return;
addButton.style.display = isGridFull(grid) ? 'none' : '';
}
/** Inserts a swatch before the trailing "+" add button (or appends it). */
function appendSwatch(grid, element) {
const addButton = grid.querySelector('.tft-color-add');
if (addButton) grid.insertBefore(element, addButton);
else grid.appendChild(element);
updateAddButtonVisibility(grid);
}
/**
* @param {string} key - localStorage key.
* @returns {string[]}
*/
export function loadCustomList(key) {
try {
const raw = localStorage.getItem(key);
const array = raw ? JSON.parse(raw) : [];
return Array.isArray(array) ? array.filter(value => typeof value === 'string') : [];
} catch { return []; }
}
/**
* @param {string} key
* @param {string[]} array
*/
export function saveCustomList(key, array) {
try { localStorage.setItem(key, JSON.stringify(array.slice(-CUSTOM_SWATCHES_MAX))); } catch { /* quota / disabled */ }
}
/** Rewrites the persisted custom solids list from the swatches currently in the grid. */
function persistSolidsFromDom(grid) {
const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom'))
.map(option => (option.dataset.color || '').toLowerCase())
.filter(Boolean);
saveCustomList(CUSTOM_SOLIDS_KEY, customs);
}
/** Rewrites the persisted custom gradients list from the swatches currently in the grid. */
function persistGradientsFromDom(grid) {
const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom'))
.map(option => (option.dataset.gradient || '').toLowerCase())
.filter(Boolean);
saveCustomList(CUSTOM_GRADIENTS_KEY, customs);
}
/**
* Highlights the solid swatch matching the given hex.
* @param {string|null} hex
* @returns {HTMLElement|null} Matched option element.
*/
export function syncSolidSwatchActive(hex) {
if (!_element) return null;
const target = hex?.toLowerCase();
let found = null;
_element.querySelectorAll('.tft-color-grid[data-tab-panel="solid"] .tft-color-option').forEach(option => {
const match = !!target && (option.dataset.color || '').toLowerCase() === target;
option.classList.toggle('tca-active', match);
if (match) found = option;
});
return found;
}
/**
* Highlights the gradient swatch matching the given CSS gradient.
* @param {string|null} gradient
* @returns {HTMLElement|null} Matched option element.
*/
export function syncGradientSwatchActive(gradient) {
if (!_element) return null;
const targetKey = gradient ? canonicalGradientKey(gradient) : '';
let found = null;
_element.querySelectorAll('.tft-color-grid[data-tab-panel="gradient"] .tft-color-option').forEach(option => {
const optionKey = canonicalGradientKey(option.dataset.gradient || '');
const match = !!targetKey && optionKey === targetKey;
option.classList.toggle('tca-active', match);
if (match) found = option;
});
return found;
}
/** Recolors a swatch in place (presets too); re-persists when it's a custom one. */
export function recolorSolidSwatch(option, hex) {
if (!option || !hex) return;
hex = hex.toLowerCase();
option.dataset.color = hex;
const innerSwatch = option.querySelector('.tft-color-swatch');
if (innerSwatch) innerSwatch.style.background = hex;
if (isCustomSwatchEl(option)) persistSolidsFromDom(option.parentElement);
}
/** Recolors a gradient swatch in place (presets too); re-persists when custom. */
export function recolorGradientSwatch(option, gradient) {
if (!option || !gradient) return;
const normalizedGradient = gradient.toLowerCase();
option.dataset.gradient = normalizedGradient;
const innerSwatch = option.querySelector('.tft-color-swatch');
if (innerSwatch) innerSwatch.style.background = normalizedGradient;
if (isCustomSwatchEl(option)) persistGradientsFromDom(option.parentElement);
}
/**
* Returns the existing solid swatch matching `hex`, or appends a new custom one
* (up to the 21-swatch cap). Returns null only when the grid is full.
* @param {string} hex
* @returns {HTMLElement|null}
*/
export function ensureCustomSwatch(hex) {
if (!_element || !hex) return null;
hex = hex.toLowerCase();
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
if (!grid) return null;
const existing = Array.from(grid.querySelectorAll('.tft-color-option'))
.find(candidateOption => (candidateOption.dataset.color || '').toLowerCase() === hex);
if (existing) return existing;
if (isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.color = hex;
option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`;
appendSwatch(grid, option);
persistSolidsFromDom(grid);
return option;
}
/**
* Returns the existing gradient swatch matching `gradient`, or appends a new
* custom one (up to the cap). Returns null only when the grid is full.
* @param {string} gradient
* @returns {HTMLElement|null}
*/
export function ensureCustomGradientSwatch(gradient) {
if (!_element || !gradient) return null;
const normalizedGradient = gradient.toLowerCase();
const key = canonicalGradientKey(normalizedGradient);
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
if (!grid) return null;
const existing = Array.from(grid.querySelectorAll('.tft-color-option'))
.find(candidateOption => canonicalGradientKey(candidateOption.dataset.gradient || '') === key);
if (existing) return existing;
if (isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.gradient = normalizedGradient;
option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`;
appendSwatch(grid, option);
persistGradientsFromDom(grid);
return option;
}
/**
* Appends a new custom solid swatch even when the color already exists — used by
* the "+" button to duplicate the active swatch. Returns null when the grid is full.
* @param {string} hex
* @returns {HTMLElement|null}
*/
export function appendCustomSwatch(hex) {
if (!_element || !hex) return null;
hex = hex.toLowerCase();
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
if (!grid || isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.color = hex;
option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`;
appendSwatch(grid, option);
persistSolidsFromDom(grid);
return option;
}
/**
* Appends a new custom gradient swatch even when it already exists — used by the
* "+" button to duplicate the active gradient. Returns null when the grid is full.
* @param {string} gradient
* @returns {HTMLElement|null}
*/
export function appendCustomGradientSwatch(gradient) {
if (!_element || !gradient) return null;
const normalizedGradient = gradient.toLowerCase();
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
if (!grid || isGridFull(grid)) return null;
const option = document.createElement('div');
option.className = 'tft-color-option tft-custom';
option.dataset.gradient = normalizedGradient;
option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`;
appendSwatch(grid, option);
persistGradientsFromDom(grid);
return option;
}
/** Rebuilds custom solid and gradient swatches from localStorage. */
export function restorePersistedSwatches() {
if (!_element) return;
const solidGrid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
if (solidGrid) {
const stored = loadCustomList(CUSTOM_SOLIDS_KEY);
const cleaned = [];
const seen = new Set();
stored.forEach(hex => {
const lowercaseHex = (hex || '').toLowerCase();
if (!lowercaseHex || PRESET_SOLIDS_LOWERCASE.includes(lowercaseHex) || seen.has(lowercaseHex)) return;
seen.add(lowercaseHex);
cleaned.push(lowercaseHex);
const exists = Array.from(solidGrid.querySelectorAll('.tft-color-option'))
.some(option => (option.dataset.color || '').toLowerCase() === lowercaseHex);
if (exists || isGridFull(solidGrid)) return;
const div = document.createElement('div');
div.className = 'tft-color-option tft-custom';
div.dataset.color = lowercaseHex;
div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseHex}"></span>`;
appendSwatch(solidGrid, div);
});
if (cleaned.length !== stored.length) saveCustomList(CUSTOM_SOLIDS_KEY, cleaned);
updateAddButtonVisibility(solidGrid);
}
const gradientGrid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
if (gradientGrid) {
const stored = loadCustomList(CUSTOM_GRADIENTS_KEY);
const cleaned = [];
const seen = new Set();
stored.forEach(gradient => {
const lowercaseGradient = (gradient || '').toLowerCase();
if (!lowercaseGradient) return;
const key = canonicalGradientKey(lowercaseGradient);
if (PRESET_GRADIENT_KEYS.includes(key) || seen.has(key)) return;
seen.add(key);
cleaned.push(lowercaseGradient);
const exists = Array.from(gradientGrid.querySelectorAll('.tft-color-option'))
.some(option => canonicalGradientKey(option.dataset.gradient || '') === key);
if (exists || isGridFull(gradientGrid)) return;
const div = document.createElement('div');
div.className = 'tft-color-option tft-custom';
div.dataset.gradient = lowercaseGradient;
div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseGradient}"></span>`;
appendSwatch(gradientGrid, div);
});
if (cleaned.length !== stored.length) saveCustomList(CUSTOM_GRADIENTS_KEY, cleaned);
updateAddButtonVisibility(gradientGrid);
}
}
@@ -0,0 +1,60 @@
/** DOM id for the text color/gradient action panel root element. */
export const COLOR_ACTION_ID = 'text-format-color-action';
/** Preset solid fill colors for the swatch grid. */
export const SOLID_COLORS = [
'#000000',
'#FF3B30',
'#FF9500',
'#34C759',
'#007AFF',
];
/** Preset CSS linear gradients for the swatch grid. */
export const GRADIENTS = [
'linear-gradient(90deg, #FF6B6B, #FFE66D)',
'linear-gradient(90deg, #4A90E2, #50C9C3)',
'linear-gradient(90deg, #F12711, #F5AF19)',
'linear-gradient(90deg, #8E2DE2, #4A00E0)',
'linear-gradient(90deg, #00B09B, #96C93D)',
];
/** HTML scaffold for the color action panel (tabs, swatches, picker). */
export const COLOR_ACTION_HTML = `
<div id="${COLOR_ACTION_ID}">
<div class="tft-color-tabs">
<button class="tft-color-tab tca-tab-active" data-tab="solid">Solid</button>
<button class="tft-color-tab" data-tab="gradient">Gradient</button>
</div>
<div class="tft-color-grid" data-tab-panel="solid">
${SOLID_COLORS.map(color => `<div class="tft-color-option" data-color="${color}"><span class="tft-color-swatch" style="background:${color}"></span></div>`).join('\n ')}
<div class="tft-color-add" role="button" aria-label="Add color" title="Add color"><span class="tft-color-add-box"></span></div>
</div>
<div class="tft-color-grid tca-hidden" data-tab-panel="gradient">
${GRADIENTS.map(gradient => `<div class="tft-color-option" data-gradient="${gradient}"><span class="tft-color-swatch" style="background:${gradient}"></span></div>`).join('\n ')}
<div class="tft-color-add" role="button" aria-label="Add gradient" title="Add gradient"><span class="tft-color-add-box"></span></div>
</div>
<div class="tft-gradient-stops tca-hidden" data-tab-panel="gradient">
<div class="tft-color-option tft-gradient-stop-swatch" data-stop="0"><span class="tft-color-swatch"></span></div>
<div class="tft-color-option tft-gradient-stop-swatch" data-stop="1"><span class="tft-color-swatch"></span></div>
</div>
<div class="tft-picker">
<div class="tft-picker-square">
<div class="tft-picker-sq-white"></div>
<div class="tft-picker-sq-black"></div>
<div class="tft-picker-indicator"></div>
</div>
<input type="range" class="tft-picker-hue" min="0" max="360" value="0">
<input type="range" class="tft-picker-alpha" min="0" max="100" value="100">
<div class="tft-picker-hex-row">
<div class="tft-picker-hex-input" role="textbox"></div>
<button class="tft-picker-hex-picker">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8.22768 7.04306L4.90249 10.3683C4.84755 10.4232 4.8201 10.5012 4.83022 10.5782L4.90249 11.1212L5.39174 11.1661L5.44643 11.1651C5.50056 11.1581 5.55163 11.1331 5.59096 11.0938L8.93471 7.75009L9.99624 8.81064L6.65151 12.1544C6.33072 12.4751 5.89991 12.6576 5.45132 12.6671L5.25796 12.6602L4.52065 12.5938L4.27553 12.839C3.98275 13.1317 3.50791 13.1315 3.21499 12.839C2.94053 12.5645 2.92334 12.1306 3.16323 11.836L3.21499 11.7784L3.44643 11.547L3.34389 10.7735C3.273 10.2342 3.45731 9.69234 3.84194 9.30771L7.16714 5.98251L8.22768 7.04306Z" fill="#ffffff"/>
<path d="M10.3869 2.83798C11.1679 2.05694 12.4349 2.05694 13.216 2.83798C13.9966 3.61894 13.9966 4.88512 13.216 5.66611L11.6242 7.25693L11.9777 7.61044C12.2706 7.90334 12.2706 8.37907 11.9777 8.67197C11.6848 8.96443 11.2099 8.96461 10.9171 8.67197L7.38198 5.13584C7.08909 4.84294 7.08909 4.36818 7.38198 4.07529C7.67488 3.78249 8.14967 3.78243 8.44253 4.07529L8.79604 4.4288L10.3869 2.83798Z" fill="#ffffff"/>
</svg>
</button>
</div>
</div>
</div>
`.trim();
@@ -0,0 +1,94 @@
/**
* Shared lifecycle helpers for the toolbar dropdown action panels
* (size, font, link, color): DOM bootstrapping, positioning relative to the
* toolbar, and outside-interaction dismissal.
*/
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT } from '../../constants/layout.js';
import { isolateEditorUiEvents } from '../../utils/dom-utils.js';
import { getToolbarEl } from './toolbar/toolbar.js';
/**
* Injects the panel stylesheet (once) and mounts the panel root element.
* @param {{ styleId: string, styles: string, html: string }} config
* @returns {HTMLElement} The mounted panel root.
*/
export function createPanelElement({ styleId, styles, html }) {
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = styles;
document.head.appendChild(style);
}
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const element = wrapper.firstElementChild;
document.body.appendChild(element);
isolateEditorUiEvents(element);
return element;
}
/**
* Positions a dropdown panel below (or above) the toolbar, left-aligned to its
* trigger button and clamped to the viewport.
* @param {HTMLElement} element - Panel root.
* @param {{ buttonAction?: string|null, matchToolbarWidth?: boolean }} [options]
*/
export function positionDropdownPanel(element, { buttonAction = null, matchToolbarWidth = false } = {}) {
const toolbarElement = getToolbarEl();
if (!toolbarElement || !element) return;
const toolbarBounds = toolbarElement.getBoundingClientRect();
if (matchToolbarWidth) element.style.width = `${toolbarBounds.width}px`;
const button = buttonAction ? toolbarElement.querySelector(`[data-action="${buttonAction}"]`) : null;
const anchorBounds = button ? button.getBoundingClientRect() : toolbarBounds;
const panelWidth = matchToolbarWidth ? toolbarBounds.width : element.offsetWidth;
const panelHeight = element.offsetHeight;
const spaceBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarBounds.bottom;
const top = Math.max(PANEL_MARGIN, spaceBelow >= panelHeight + PANEL_GAP
? toolbarBounds.bottom + PANEL_GAP
: toolbarBounds.top - panelHeight - PANEL_GAP);
const left = Math.max(PANEL_MARGIN, Math.min(anchorBounds.left, window.innerWidth - panelWidth - PANEL_MARGIN));
element.style.left = `${left}px`;
element.style.top = `${top}px`;
}
/**
* Builds attach/detach-able listeners that dismiss a panel on outside
* mousedown.
* @param {{
* getElement: () => HTMLElement|null,
* dismiss: () => void,
* ignoreToolbar?: boolean,
* ignoreMouseDown?: ((event: MouseEvent) => boolean)|null,
* captureMouseDown?: boolean,
* }} config
* @returns {{ attach: () => void, detach: () => void }}
*/
export function createOutsideDismiss({ getElement, dismiss, ignoreToolbar = true, ignoreMouseDown = null, captureMouseDown = false }) {
function onMouseDown(event) {
if (getElement()?.contains(event.target)) return;
if (ignoreToolbar && getToolbarEl()?.contains(event.target)) return;
if (ignoreMouseDown?.(event)) return;
dismiss();
}
return {
attach() {
document.addEventListener('mousedown', onMouseDown, captureMouseDown);
},
detach() {
document.removeEventListener('mousedown', onMouseDown, captureMouseDown);
},
};
}
/**
* @returns {Range|null} Clone of the current selection range, if any.
*/
export function captureSelectionRange() {
const selection = window.getSelection();
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
}
@@ -0,0 +1,113 @@
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { getToolbarTarget } from '../toolbar/toolbar.js';
import { wrapSelectionWithStyle } from '../style-actions.js';
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { SYSTEM_FONTS, FONT_ACTION_HTML } from './template.js';
import { FONT_ACTION_STYLES } from './styles.js';
let fontActionElement = null;
let savedFontRange = null;
const outsideDismiss = createOutsideDismiss({
getElement: () => fontActionElement,
dismiss: () => hideFontAction(),
});
function getAvailableFonts() {
const fonts = [...SYSTEM_FONTS];
const seen = new Set(SYSTEM_FONTS.map(font => font.name.toLowerCase()));
document.fonts?.forEach(face => {
if (face.status !== 'loaded') return;
const family = face.family.replace(/^['"]|['"]$/g, '');
const key = family.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
const needsQuotes = /\s/.test(family);
const quoted = needsQuotes ? `"${family}"` : family;
fonts.push({ name: family, stack: `${quoted}, sans-serif` });
});
return fonts;
}
function renderFontOptions() {
if (!fontActionElement) return;
fontActionElement.innerHTML = getAvailableFonts()
.map(font => `<div class="tft-font-option" data-font='${font.stack}' style='font-family: ${font.stack}'>${font.name}</div>`)
.join('\n ');
}
function initFontAction() {
if (fontActionElement) return;
fontActionElement = createPanelElement({
styleId: 'text-format-font-action-styles',
styles: FONT_ACTION_STYLES,
html: FONT_ACTION_HTML,
});
fontActionElement.addEventListener('mousedown', (event) => event.preventDefault());
fontActionElement.addEventListener('click', (event) => {
const option = event.target.closest('.tft-font-option');
if (!option) return;
applyFontAction(option.dataset.font);
hideFontAction();
});
}
function applyFontAction(family) {
if (!savedFontRange || !getToolbarTarget() || !family) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedFontRange);
wrapSelectionWithStyle(getToolbarTarget(), 'fontFamily', 'font-family', family);
onSelectionChange();
}
export function positionFontAction() {
if (!isPanelVisible(PanelId.FONT) || !fontActionElement) return;
positionDropdownPanel(fontActionElement, { buttonAction: 'font-family' });
}
/** Opens the font family panel with the current family highlighted. */
export function showFontAction() {
if (!fontActionElement) initFontAction();
renderFontOptions();
savedFontRange = captureSelectionRange();
const styles = getStylesAtCursor();
const currentFamily = styles?.fontFamily
?? (getToolbarTarget() ? getComputedStyle(getToolbarTarget()).fontFamily : '');
const normalizedCurrent = currentFamily.split(',')[0].replace(/['"]/g, '').trim().toLowerCase();
fontActionElement.querySelectorAll('.tft-font-option').forEach(option => {
const optionName = option.dataset.font.split(',')[0].replace(/['"]/g, '').trim().toLowerCase();
option.classList.toggle('tfa-active', optionName === normalizedCurrent);
});
fontActionElement.classList.add('tfa-visible');
setPanelVisible(PanelId.FONT, true);
positionFontAction();
outsideDismiss.attach();
}
/** Closes the font family panel. */
export function hideFontAction() {
if (!fontActionElement) return;
fontActionElement.classList.remove('tfa-visible');
setPanelVisible(PanelId.FONT, false);
savedFontRange = null;
outsideDismiss.detach();
}
/** Toggles the font family panel visibility. */
export function toggleFontAction() {
if (isPanelVisible(PanelId.FONT)) {
hideFontAction();
} else {
showFontAction();
}
}
registerPanel({ id: PanelId.FONT, hide: hideFontAction });
@@ -0,0 +1,41 @@
import { FONT_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the font family dropdown panel. */
export const FONT_ACTION_STYLES = `
#${FONT_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
overflow-y: auto;
max-height: 280px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
padding: 4px;
box-shadow: ${BOX_SHADOW_DROPDOWN};
user-select: none;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${FONT_ACTION_ID}.tfa-visible {
display: flex;
}
.tft-font-option {
padding: 4px 10px;
color: ${COLOR_WHITE};
font-size: 14px;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
}
.tft-font-option:hover {
background: ${HOVER_BG};
}
.tft-font-option.tfa-active {
background: ${ACTIVE_BG};
}
`;
@@ -0,0 +1,16 @@
/** DOM id for the font family action panel root element. */
export const FONT_ACTION_ID = 'text-format-font-action';
/** System font presets shown in the font family dropdown. */
export const SYSTEM_FONTS = [
{ name: 'Arial', stack: 'Arial, sans-serif' },
{ name: 'Helvetica', stack: 'Helvetica, sans-serif' },
{ name: 'Verdana', stack: 'Verdana, sans-serif' },
{ name: 'Trebuchet MS', stack: '"Trebuchet MS", sans-serif' },
{ name: 'Georgia', stack: 'Georgia, serif' },
{ name: 'Times New Roman', stack: '"Times New Roman", serif' },
{ name: 'Courier New', stack: '"Courier New", monospace' },
];
/** HTML scaffold for the font family action panel. */
export const FONT_ACTION_HTML = `<div id="${FONT_ACTION_ID}"></div>`;
@@ -0,0 +1,427 @@
import { getEditing } from '../../../state/editing-state.js';
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { recordEdit } from '../../../state/history-state.js';
import { notifyDraftStateChanged } from '../../../api/draft-snapshot.js';
import { escapeHtml } from '../../../utils/html-utils.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { captureElementMetadata } from '../../../utils/selection-mode-metadata.js';
import { LINK_ACTION_HTML } from './template.js';
import { LINK_ACTION_STYLES } from './styles.js';
let linkActionElement = null;
let savedLinkRange = null;
let linkVirtualValue = '';
let linkVirtualCursor = 0;
let linkAnchor = 0;
let linkDragging = false;
const outsideDismiss = createOutsideDismiss({
getElement: () => linkActionElement,
dismiss: () => saveLink(),
});
/** @returns {number} Lower selection bound. */
function selectionStart() { return Math.min(linkAnchor, linkVirtualCursor); }
/** @returns {number} Upper selection bound. */
function selectionEnd() { return Math.max(linkAnchor, linkVirtualCursor); }
/** @returns {boolean} Whether a (non-collapsed) selection exists. */
function hasSelection() { return linkAnchor !== linkVirtualCursor; }
/** Collapses the selection onto the caret. */
function collapseSelection() { linkAnchor = linkVirtualCursor; }
function renderLinkInput() {
const element = linkActionElement?.querySelector('#tft-link-url');
if (!element) return;
const caret = '<span class="tft-link-vcursor"></span>';
if (!linkVirtualValue) {
element.innerHTML = `<span class="tft-link-placeholder">Paste or type a link</span>${caret}`;
return;
}
const start = selectionStart();
const end = selectionEnd();
let html = '';
for (let index = 0; index < linkVirtualValue.length; index++) {
if (index === linkVirtualCursor) html += caret;
const selectionClass = index >= start && index < end ? ' tft-link-csel' : '';
html += `<span class="tft-link-char${selectionClass}">${escapeHtml(linkVirtualValue[index])}</span>`;
}
if (linkVirtualCursor === linkVirtualValue.length) html += caret;
element.innerHTML = html;
}
/** Selects the whole link value (double-click / Cmd+A) so the next input or paste replaces it. */
function selectAllLink() {
linkAnchor = 0;
linkVirtualCursor = linkVirtualValue.length;
renderLinkInput();
}
/**
* Maps a viewport x coordinate to a caret index between characters.
* @param {number} pointerX
* @returns {number}
*/
function caretIndexFromPointerX(pointerX) {
const element = linkActionElement?.querySelector('#tft-link-url');
if (!element) return 0;
const characters = element.querySelectorAll('.tft-link-char');
for (let index = 0; index < characters.length; index++) {
const characterBounds = characters[index].getBoundingClientRect();
// Caret goes before this character once the click lands left of its midpoint.
if (pointerX < characterBounds.left + characterBounds.width / 2) return index;
}
return characters.length;
}
/**
* Begins a click/drag in the field: drops a collapsed caret at the pointer.
* @param {number} pointerX
*/
function linkPointerDown(pointerX) {
renderLinkInput();
const caretIndex = caretIndexFromPointerX(pointerX);
linkAnchor = caretIndex;
linkVirtualCursor = caretIndex;
renderLinkInput();
}
/**
* Extends the selection to the dragged-to position.
* @param {number} pointerX
*/
function linkPointerMove(pointerX) {
linkVirtualCursor = caretIndexFromPointerX(pointerX);
renderLinkInput();
}
function onLinkKeydown(event) {
if (!isPanelVisible(PanelId.LINK)) return;
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); saveLink(); return; }
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); hideLinkAction(); return; }
if (event.key === 'a' && (event.metaKey || event.ctrlKey)) {
event.preventDefault(); event.stopPropagation();
selectAllLink();
return;
}
if (hasSelection()) {
const start = selectionStart();
const end = selectionEnd();
if (event.key === 'Backspace' || event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(end);
linkVirtualCursor = start;
collapseSelection();
renderLinkInput();
return;
}
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
event.preventDefault(); event.stopPropagation();
linkVirtualValue = linkVirtualValue.slice(0, start) + event.key + linkVirtualValue.slice(end);
linkVirtualCursor = start + 1;
collapseSelection();
renderLinkInput();
return;
}
// Any other key collapses the selection before normal handling.
collapseSelection();
}
if (event.key === 'Backspace') {
event.preventDefault(); event.stopPropagation();
if (linkVirtualCursor > 0) {
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor - 1) + linkVirtualValue.slice(linkVirtualCursor);
linkVirtualCursor--;
collapseSelection();
renderLinkInput();
}
return;
}
if (event.key === 'Delete') {
event.preventDefault(); event.stopPropagation();
if (linkVirtualCursor < linkVirtualValue.length) {
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + linkVirtualValue.slice(linkVirtualCursor + 1);
collapseSelection();
renderLinkInput();
}
return;
}
if (event.key === 'Home' || (event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey))) {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = 0;
collapseSelection();
renderLinkInput();
return;
}
if (event.key === 'End' || (event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey))) {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = linkVirtualValue.length;
collapseSelection();
renderLinkInput();
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = Math.max(0, linkVirtualCursor - 1);
collapseSelection();
renderLinkInput();
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault(); event.stopPropagation();
linkVirtualCursor = Math.min(linkVirtualValue.length, linkVirtualCursor + 1);
collapseSelection();
renderLinkInput();
return;
}
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
event.preventDefault(); event.stopPropagation();
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + event.key + linkVirtualValue.slice(linkVirtualCursor);
linkVirtualCursor++;
collapseSelection();
renderLinkInput();
}
}
function onLinkCopy(event) {
if (!isPanelVisible(PanelId.LINK)) return;
if (!hasSelection()) return;
const text = linkVirtualValue.slice(selectionStart(), selectionEnd());
if (!text) return;
event.preventDefault(); event.stopPropagation();
event.clipboardData?.setData('text/plain', text);
if (event.type === 'cut') {
const start = selectionStart();
linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(selectionEnd());
linkVirtualCursor = start;
collapseSelection();
renderLinkInput();
}
}
function onLinkPaste(event) {
if (!isPanelVisible(PanelId.LINK)) return;
event.preventDefault(); event.stopPropagation();
const text = (event.clipboardData?.getData('text/plain') || '').replace(/\s/g, '');
// Replace the current selection (if any), otherwise insert at the caret.
const start = selectionStart();
const end = selectionEnd();
linkVirtualValue = linkVirtualValue.slice(0, start) + text + linkVirtualValue.slice(end);
linkVirtualCursor = start + text.length;
collapseSelection();
renderLinkInput();
}
function initLinkAction() {
if (linkActionElement) return;
linkActionElement = createPanelElement({
styleId: 'text-format-link-action-styles',
styles: LINK_ACTION_STYLES,
html: LINK_ACTION_HTML,
});
linkActionElement.addEventListener('mousedown', (event) => {
event.preventDefault();
// Start a drag-selection inside the virtual link field (it isn't a real
// <input>, so we emulate caret/selection from pointer coordinates).
if (event.target.closest('#tft-link-url')) {
linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused');
linkPointerDown(event.clientX);
linkDragging = true;
}
});
document.addEventListener('mousemove', (event) => {
if (linkDragging) linkPointerMove(event.clientX);
});
document.addEventListener('mouseup', () => { linkDragging = false; });
linkActionElement.addEventListener('dblclick', (event) => {
if (event.target.closest('#tft-link-url')) {
event.preventDefault();
selectAllLink();
}
});
linkActionElement.addEventListener('click', (event) => {
const labelElement = event.target.closest('.tft-link-newwindow');
if (labelElement) {
const checkbox = labelElement.querySelector('#tft-link-new-window');
if (checkbox) checkbox.checked = !checkbox.checked;
}
});
}
function findAnchorInRange(range) {
if (!range) return null;
for (const container of [range.startContainer, range.endContainer]) {
let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
while (node && node !== document.body) {
if (node.tagName === 'A') return node;
node = node.parentNode;
}
}
return null;
}
// A "source anchor" is an <a> rendered from JSX <a> or <Link> in the user's source
// (carries data-edit-url-attr). Its URL is editable in place by writing back to source.
// Distinct from toolbar-inserted anchors which only live inside the contenteditable.
function findSourceAnchorInRange(range) {
if (!range) return null;
for (const container of [range.startContainer, range.endContainer]) {
let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
while (node && node !== document.body) {
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('data-edit-url-attr')) return node;
node = node.parentNode;
}
}
return null;
}
function unwrapAnchor(anchor) {
const parent = anchor.parentNode;
while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
parent.removeChild(anchor);
}
function applyLink(range, url, openNewTab) {
const anchor = document.createElement('a');
anchor.href = url;
anchor.style.textDecoration = 'underline';
if (openNewTab) {
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
}
try {
range.surroundContents(anchor);
} catch {
anchor.appendChild(range.extractContents());
range.insertNode(anchor);
}
const selection = window.getSelection();
const newRange = document.createRange();
newRange.selectNodeContents(anchor);
selection.removeAllRanges();
selection.addRange(newRange);
}
function saveLink() {
const url = linkVirtualValue.trim();
const newTab = linkActionElement.querySelector('#tft-link-new-window').checked;
let range = savedLinkRange;
// Source-anchor path: rendered <a>/<Link> from JSX. Update href in place and
// persist the attribute edit. We must NOT unwrap — that destroys the contenteditable target.
const sourceAnchor = findSourceAnchorInRange(range);
if (sourceAnchor) {
// Empty URL on a source anchor would break navigation; no "remove link" semantic
// for source-attached anchors — leave as-is.
if (!url) { hideLinkAction(); return; }
const editId = sourceAnchor.getAttribute('data-edit-id');
const urlAttribute = sourceAnchor.getAttribute('data-edit-url-attr');
const oldUrl = sourceAnchor.getAttribute('href');
if (oldUrl !== url && editId && urlAttribute) {
const selectionMode = captureElementMetadata(sourceAnchor);
sourceAnchor.setAttribute('href', url);
recordEdit(editId, { beforeContent: oldUrl, afterContent: url }, { attribute: urlAttribute, element: sourceAnchor, selectionMode });
notifyDraftStateChanged();
}
hideLinkAction();
return;
}
// Inline anchor path: anchors that live only inside contenteditable HTML
// (created/edited by the toolbar itself). Wrap/unwrap the DOM and let the
// outer text edit's innerHTML diff carry the change to source on commit.
const existing = findAnchorInRange(range);
let changed = false;
if (existing) {
const children = Array.from(existing.childNodes);
const entirelyInside = children.length > 0
&& existing.contains(range.startContainer)
&& existing.contains(range.endContainer);
unwrapAnchor(existing);
changed = true;
if (entirelyInside) {
range = document.createRange();
range.setStartBefore(children[0]);
range.setEndAfter(children[children.length - 1]);
}
}
if (url && range) {
applyLink(range, url, newTab);
changed = true;
}
if (changed) {
getEditing()?.targetElement?.dispatchEvent(new Event('input', { bubbles: true }));
}
hideLinkAction();
}
export function positionLinkAction() {
if (!isPanelVisible(PanelId.LINK) || !linkActionElement) return;
positionDropdownPanel(linkActionElement, { matchToolbarWidth: true });
}
/** Opens the link panel prefilled from an existing anchor in the selection. */
export function showLinkAction() {
if (!linkActionElement) initLinkAction();
savedLinkRange = captureSelectionRange();
const existing = findAnchorInRange(savedLinkRange);
linkVirtualValue = existing ? (existing.getAttribute('href') || '') : '';
linkVirtualCursor = linkVirtualValue.length;
linkAnchor = linkVirtualValue.length;
// Source anchors don't roundtrip target/rel back to JSX yet, so hide the
// "open in new tab" checkbox to avoid a no-op control.
const sourceAnchor = findSourceAnchorInRange(savedLinkRange);
const newTabLabel = linkActionElement.querySelector('.tft-link-newwindow');
if (newTabLabel) newTabLabel.style.display = sourceAnchor ? 'none' : '';
linkActionElement.querySelector('#tft-link-new-window').checked = !sourceAnchor && existing ? existing.target === '_blank' : false;
linkActionElement.classList.add('tla-visible');
setPanelVisible(PanelId.LINK, true);
linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused');
positionLinkAction();
renderLinkInput();
document.addEventListener('keydown', onLinkKeydown, true);
document.addEventListener('paste', onLinkPaste, true);
document.addEventListener('copy', onLinkCopy, true);
document.addEventListener('cut', onLinkCopy, true);
outsideDismiss.attach();
}
/** Closes the link panel and clears saved range. */
export function hideLinkAction() {
if (!linkActionElement) return;
linkActionElement.classList.remove('tla-visible');
linkActionElement.querySelector('#tft-link-url')?.classList.remove('tla-focused');
setPanelVisible(PanelId.LINK, false);
savedLinkRange = null;
linkAnchor = linkVirtualCursor;
linkDragging = false;
document.removeEventListener('keydown', onLinkKeydown, true);
document.removeEventListener('paste', onLinkPaste, true);
document.removeEventListener('copy', onLinkCopy, true);
document.removeEventListener('cut', onLinkCopy, true);
outsideDismiss.detach();
}
/** Toggles the link panel visibility. */
export function toggleLinkAction() {
if (isPanelVisible(PanelId.LINK)) {
hideLinkAction();
} else {
showLinkAction();
}
}
registerPanel({ id: PanelId.LINK, hide: hideLinkAction });
@@ -0,0 +1,120 @@
import { LINK_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, BOX_SHADOW_DROPDOWN, COLOR_WHITE, PLACEHOLDER_COLOR, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the link insertion panel. */
export const LINK_ACTION_STYLES = `
#${LINK_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
gap: 8px;
background: ${PANEL_BG};
padding: 8px;
border-radius: 12px;
box-sizing: border-box;
box-shadow: ${BOX_SHADOW_DROPDOWN};
user-select: none;
font-family: DM Sans, sans-serif;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${LINK_ACTION_ID}.tla-visible {
display: flex;
}
.tft-link-input {
background: transparent;
border: 1px solid ${BORDER_COLOR};
border-radius: 8px;
color: ${COLOR_WHITE};
font-family: DM Sans, sans-serif;
font-size: 12px;
line-height: 20px;
padding: 6px 8px;
width: 100%;
box-sizing: border-box;
height: 32px;
cursor: text;
position: relative;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
}
.tft-link-input.tla-focused {
border-color: ${BORDER_COLOR_FOCUS};
}
.tft-link-placeholder {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 8px;
color: ${PLACEHOLDER_COLOR};
pointer-events: none;
}
.tft-link-char {
white-space: pre;
}
.tft-link-csel {
background: #357DF966;
border-radius: 1px;
}
.tft-link-vcursor {
display: inline-block;
width: 1px;
height: 1em;
background: ${COLOR_WHITE};
margin-right: -1px;
vertical-align: text-bottom;
animation: tft-cursor-blink 1s step-end infinite;
}
@keyframes tft-cursor-blink {
50% { opacity: 0; }
}
.tft-link-newwindow {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
cursor: pointer;
}
.tft-link-checkbox {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
border: 1.5px solid ${COLOR_WHITE}4d;
border-radius: 3px;
background: ${COLOR_WHITE}08;
cursor: pointer;
flex-shrink: 0;
position: relative;
}
.tft-link-checkbox:checked {
background: ${COLOR_WHITE};
border-color: ${COLOR_WHITE};
}
.tft-link-checkbox:checked::after {
content: '';
position: absolute;
left: 4px;
top: 2px;
width: 4px;
height: 7px;
border: 2px solid ${PANEL_BG};
border-top: none;
border-left: none;
transform: rotate(45deg);
}
.tft-link-newwindow-label {
font-size: 12px;
color: ${COLOR_WHITE};
}
`;
@@ -0,0 +1,13 @@
/** DOM id for the link action panel root element. */
export const LINK_ACTION_ID = 'text-format-link-action';
/** HTML scaffold for the link panel (URL input and new-tab checkbox). */
export const LINK_ACTION_HTML = `
<div id="${LINK_ACTION_ID}">
<div id="tft-link-url" class="tft-link-input" role="textbox" aria-label="URL"></div>
<label class="tft-link-newwindow">
<input class="tft-link-checkbox" type="checkbox" id="tft-link-new-window" />
<span class="tft-link-newwindow-label">New tab</span>
</label>
</div>
`.trim();
@@ -0,0 +1,102 @@
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
import { getToolbarTarget, stopFontSizeEdit } from '../toolbar/toolbar.js';
import { wrapSelectionWithStyle } from '../style-actions.js';
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
import { SIZE_ACTION_HTML } from './template.js';
import { SIZE_ACTION_STYLES } from './styles.js';
let sizeActionElement = null;
let savedSizeRange = null;
const outsideDismiss = createOutsideDismiss({
getElement: () => sizeActionElement,
dismiss: () => {
stopFontSizeEdit();
hideSizeAction();
},
});
function initSizeAction() {
if (sizeActionElement) return;
sizeActionElement = createPanelElement({
styleId: 'text-format-size-action-styles',
styles: SIZE_ACTION_STYLES,
html: SIZE_ACTION_HTML,
});
sizeActionElement.addEventListener('mousedown', (event) => event.preventDefault());
sizeActionElement.addEventListener('click', (event) => {
const option = event.target.closest('.tft-size-option');
if (!option) return;
applySizeAction(parseInt(option.dataset.size, 10));
stopFontSizeEdit();
hideSizeAction();
});
}
export function positionSizeAction() {
if (!isPanelVisible(PanelId.SIZE) || !sizeActionElement) return;
positionDropdownPanel(sizeActionElement, { buttonAction: 'font-size' });
}
/** Saves the current selection range for a subsequent size apply. */
export function saveSizeRange() {
const range = captureSelectionRange();
if (range) savedSizeRange = range;
}
/**
* Applies font size in pixels to the saved selection.
* @param {number} pixels
*/
export function applySizeAction(pixels) {
if (!savedSizeRange || !getToolbarTarget() || !(pixels > 0)) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedSizeRange);
wrapSelectionWithStyle(getToolbarTarget(), 'fontSize', 'font-size', typeof pixels === 'number' ? `${pixels}px` : pixels);
onSelectionChange();
}
/** Opens the font size panel with the current size highlighted. */
export function showSizeAction() {
if (!sizeActionElement) initSizeAction();
savedSizeRange = captureSelectionRange();
const styles = getStylesAtCursor();
const currentSize = Math.round(parseFloat(
styles?.fontSize ?? getComputedStyle(getToolbarTarget()).fontSize
));
sizeActionElement.querySelectorAll('.tft-size-option').forEach(option => {
option.classList.toggle('tsa-active', Number(option.dataset.size) === currentSize);
});
sizeActionElement.classList.add('tsa-visible');
setPanelVisible(PanelId.SIZE, true);
positionSizeAction();
outsideDismiss.attach();
}
/** Closes the font size panel. */
export function hideSizeAction() {
if (!sizeActionElement) return;
sizeActionElement.classList.remove('tsa-visible');
setPanelVisible(PanelId.SIZE, false);
savedSizeRange = null;
outsideDismiss.detach();
}
/** Toggles the font size panel visibility. */
export function toggleSizeAction() {
if (isPanelVisible(PanelId.SIZE)) {
hideSizeAction();
} else {
showSizeAction();
}
}
registerPanel({ id: PanelId.SIZE, hide: hideSizeAction });
@@ -0,0 +1,42 @@
import { SIZE_ACTION_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the font size dropdown panel. */
export const SIZE_ACTION_STYLES = `
#${SIZE_ACTION_ID} {
position: fixed;
display: none;
flex-direction: column;
overflow-y: auto;
max-height: 240px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
padding: 4px;
box-shadow: ${BOX_SHADOW_DROPDOWN};
font-family: DM Sans, sans-serif;
user-select: none;
z-index: ${Z_INDEX_EDITOR_PANEL};
}
#${SIZE_ACTION_ID}.tsa-visible {
display: flex;
}
.tft-size-option {
padding: 6px 12px;
color: ${COLOR_WHITE};
font-size: 12px;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
}
.tft-size-option:hover {
background: ${HOVER_BG};
}
.tft-size-option.tsa-active {
background: ${ACTIVE_BG};
}
`;
@@ -0,0 +1,12 @@
/** DOM id for the font size action panel root element. */
export const SIZE_ACTION_ID = 'text-format-size-action';
/** Preset font sizes (px) listed in the size dropdown. */
export const TEXT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 80, 88, 96, 104, 120, 144];
/** HTML scaffold for the font size dropdown options. */
export const SIZE_ACTION_HTML = `
<div id="${SIZE_ACTION_ID}">
${TEXT_SIZES.map(size => `<div class="tft-size-option" data-size="${size}">${size}</div>`).join('')}
</div>
`.trim();
@@ -0,0 +1,502 @@
/**
* DOM range helpers for applying bold, italic, underline, and alignment inside
* the active contenteditable selection.
*/
import {
getTextNodesInRange,
isNodeBold,
isNodeItalic,
isNodeUnderlined,
} from '../../utils/inline-style-state.js';
function hasAncestorTag(textNode, tagNames, stopAt) {
let element = textNode.parentElement;
while (element && element !== stopAt) {
if (tagNames.includes(element.tagName.toLowerCase())) return true;
element = element.parentElement;
}
return false;
}
function isFullyWrapped(range, tagNames, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every(node => hasAncestorTag(node, tagNames, editTarget));
}
/**
* Wraps a range in an element, with optional inline styles.
* @param {Range} range
* @param {string} tagName
* @param {object} [styleProperties]
* @returns {HTMLElement}
*/
export function wrapRange(range, tagName, styleProperties) {
const wrapper = document.createElement(tagName);
if (styleProperties) Object.assign(wrapper.style, styleProperties);
try {
range.surroundContents(wrapper);
} catch {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
if (!styleProperties) {
wrapper.querySelectorAll(tagName).forEach(element => element.replaceWith(...element.childNodes));
wrapper.normalize();
}
return wrapper;
}
function restoreSelectionOver(firstNode, lastNode) {
if (!firstNode || !lastNode) return;
try {
const newRange = document.createRange();
if (firstNode.nodeType === Node.TEXT_NODE) {
newRange.setStart(firstNode, 0);
} else {
newRange.setStartBefore(firstNode);
}
if (lastNode.nodeType === Node.TEXT_NODE) {
newRange.setEnd(lastNode, lastNode.length);
} else {
newRange.setEndAfter(lastNode);
}
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
} catch { /* ignore invalid range errors */ }
}
function ancestorDepthWithin(element, editTarget) {
let depth = 0;
let current = element;
while (current && current !== editTarget) {
depth += 1;
current = current.parentElement;
}
return depth;
}
/**
* Removes a single inline wrapper element over the portion covered by `range`,
* keeping the parts outside the range wrapped and preserving every descendant
* (e.g. a nested `<strong>`) of the in-range portion. Splitting the element when
* the selection only partially covers it.
* @param {HTMLElement} element
* @param {Range} range
*/
function unwrapElementOverRange(element, range) {
const elementContents = document.createRange();
elementContents.selectNodeContents(element);
const selectionStartsInside = range.compareBoundaryPoints(Range.START_TO_START, elementContents) > 0;
const selectionEndsInside = range.compareBoundaryPoints(Range.END_TO_END, elementContents) < 0;
if (selectionEndsInside) {
const afterRange = document.createRange();
afterRange.setStart(range.endContainer, range.endOffset);
afterRange.setEnd(elementContents.endContainer, elementContents.endOffset);
const afterContent = afterRange.extractContents();
if (afterContent.textContent !== '') {
const afterClone = element.cloneNode(false);
afterClone.appendChild(afterContent);
element.after(afterClone);
}
}
if (selectionStartsInside) {
const beforeRange = document.createRange();
beforeRange.setStart(elementContents.startContainer, elementContents.startOffset);
beforeRange.setEnd(range.startContainer, range.startOffset);
const beforeContent = beforeRange.extractContents();
if (beforeContent.textContent !== '') {
const beforeClone = element.cloneNode(false);
beforeClone.appendChild(beforeContent);
element.before(beforeClone);
}
}
element.replaceWith(...element.childNodes);
}
function unwrapTagsInRange(range, tagNames, editTarget) {
const tagSet = tagNames.map(tag => tag.toLowerCase());
const textNodes = getTextNodesInRange(range);
const firstTextNode = textNodes[0] ?? null;
const lastTextNode = textNodes[textNodes.length - 1] ?? null;
const targetElements = [];
for (const textNode of textNodes) {
let element = textNode.parentElement;
while (element && element !== editTarget) {
if (tagSet.includes(element.tagName?.toLowerCase()) && !targetElements.includes(element)) {
targetElements.push(element);
}
element = element.parentElement;
}
}
// Unwrap the deepest wrappers first so re-parenting never shifts the
// boundaries of a shallower wrapper still waiting to be processed.
targetElements.sort((a, b) => ancestorDepthWithin(b, editTarget) - ancestorDepthWithin(a, editTarget));
for (const element of targetElements) {
unwrapElementOverRange(element, range);
}
restoreSelectionOver(firstTextNode, lastTextNode);
editTarget.normalize();
}
/**
* Returns the user's active text selection inside the edit target.
* When the caret is collapsed (no highlighted text), the entire element's
* contents are selected so that style actions apply to all text.
* @param {HTMLElement} editTarget
* @returns {Range|null} Selection range inside the edit target.
*/
export function getActiveRange(editTarget) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
if (selection.isCollapsed) {
if (!editTarget.contains(selection.anchorNode)) return null;
const range = document.createRange();
range.selectNodeContents(editTarget);
selection.removeAllRanges();
selection.addRange(range);
return range;
}
const range = selection.getRangeAt(0);
if (!editTarget.contains(range.commonAncestorContainer)) return null;
return range;
}
/** Tailwind utility classes cleared when toggling bold off on an edit target. */
const TAILWIND_BOLD_CLASSES = ['font-bold', 'font-semibold', 'font-extrabold', 'font-black'];
/** Tailwind utility classes cleared when toggling italic off on an edit target. */
const TAILWIND_ITALIC_CLASSES = ['italic'];
function isFullyBold(range, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every((node) => isNodeBold(node, editTarget));
}
function isFullyItalic(range, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every((node) => isNodeItalic(node, editTarget));
}
/**
* Toggles bold on the current selection within the edit target.
* @param {HTMLElement} editTarget
*/
export function applyBold(editTarget) {
const range = getActiveRange(editTarget);
if (!range) return;
if (isFullyBold(range, editTarget)) {
unwrapTagsInRange(range, ['strong', 'b'], editTarget);
TAILWIND_BOLD_CLASSES.forEach(className => editTarget.classList.remove(className));
if (editTarget.style.fontWeight) editTarget.style.removeProperty('font-weight');
const selection = window.getSelection();
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
const selectionRange = selection.getRangeAt(0);
if (isFullyBold(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontWeight: 'normal' });
}
} else {
wrapRange(range, 'strong');
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Toggles italic on the current selection within the edit target.
* @param {HTMLElement} editTarget
*/
export function applyItalic(editTarget) {
const range = getActiveRange(editTarget);
if (!range) return;
if (isFullyItalic(range, editTarget)) {
unwrapTagsInRange(range, ['em', 'i'], editTarget);
TAILWIND_ITALIC_CLASSES.forEach(className => editTarget.classList.remove(className));
if (editTarget.style.fontStyle) editTarget.style.removeProperty('font-style');
const selection = window.getSelection();
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
const selectionRange = selection.getRangeAt(0);
if (isFullyItalic(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontStyle: 'normal' });
}
} else {
wrapRange(range, 'em');
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/** Tailwind utility classes cleared when toggling underline off on an edit target. */
const TAILWIND_UNDERLINE_CLASSES = ['underline'];
function isRangeFullyUnderlined(range, editTarget) {
if (range.collapsed) return false;
const nodes = getTextNodesInRange(range);
return nodes.length > 0 && nodes.every((node) => isNodeUnderlined(node, editTarget));
}
function removeExplicitUnderlineInRange(range, editTarget) {
getTextNodesInRange(range).forEach(node => {
let element = node.parentElement;
while (element && element !== editTarget) {
if (element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline')) {
element.style.removeProperty('text-decoration');
element.style.removeProperty('text-decoration-line');
}
element = element.parentElement;
}
});
}
function getContextualDecorationStyle(range) {
// When text sits between CSS-class underline siblings (after a split), inherit
// their full decoration style so re-applied underline visually matches.
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return null;
for (const sibling of [node.previousSibling, node.nextSibling]) {
if (!sibling || sibling.nodeType !== Node.ELEMENT_NODE) continue;
const computedStyle = getComputedStyle(sibling);
const textDecorationLine = computedStyle.textDecorationLine || computedStyle.textDecoration || '';
if (!textDecorationLine.includes('underline')) continue;
const style = {};
if (computedStyle.textDecorationColor) {
style.textDecorationColor = computedStyle.textDecorationColor;
}
if (computedStyle.textDecorationThickness && computedStyle.textDecorationThickness !== 'auto') {
style.textDecorationThickness = computedStyle.textDecorationThickness;
}
if (computedStyle.textDecorationStyle && computedStyle.textDecorationStyle !== 'solid') {
style.textDecorationStyle = computedStyle.textDecorationStyle;
}
return Object.keys(style).length > 0 ? style : null;
}
return null;
}
function getEffectiveTextColor(range, editTarget) {
const nodes = getTextNodesInRange(range);
if (nodes.length === 0 && range.startContainer.nodeType === Node.TEXT_NODE) {
nodes.push(range.startContainer);
}
if (nodes.length === 0) return null;
const containerColor = getComputedStyle(editTarget).color;
for (const textNode of nodes) {
let element = textNode.parentElement;
while (element && element !== editTarget) {
const inlineColor = element.style?.color;
if (inlineColor && inlineColor !== 'transparent') return inlineColor;
element = element.parentElement;
}
const parent = textNode.parentElement;
if (!parent) continue;
const computed = getComputedStyle(parent).color;
if (computed && computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)') {
return computed;
}
}
return null;
}
function buildUnderlineWrapStyle(range, editTarget) {
const style = getContextualDecorationStyle(range) ?? {};
const textColor = getEffectiveTextColor(range, editTarget);
if (textColor) {
style.textDecorationColor = textColor;
}
return Object.keys(style).length > 0 ? style : null;
}
/** Keeps `<u>` decoration color in sync when nested text color changes. */
export function syncUnderlineColorFromElement(element, editTarget) {
if (!element || !editTarget) return;
const inlineColor = element.style?.color;
const containerColor = getComputedStyle(editTarget).color;
const decorationColor = inlineColor
|| (() => {
const computed = getComputedStyle(element).color;
return computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)' ? computed : null;
})();
if (!decorationColor) return;
let node = element.parentElement;
while (node && node !== editTarget) {
if (node.tagName?.toLowerCase() === 'u') {
node.style.textDecorationColor = decorationColor;
}
node = node.parentElement;
}
}
function findCssUnderlineAncestor(node, editTarget) {
// Find the closest ancestor that sources underline via a CSS class, not a <u> tag
// or inline style — those are handled by other steps.
let element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
while (element && element !== editTarget) {
if (element.tagName?.toLowerCase() === 'u') { element = element.parentElement; continue; }
const hasInline = element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline');
if (hasInline) { element = element.parentElement; continue; }
const textDecorationLine = getComputedStyle(element).textDecorationLine || getComputedStyle(element).textDecoration || '';
if (textDecorationLine.includes('underline')) return element;
element = element.parentElement;
}
return null;
}
function splitElementAroundRange(range, ancestor, editTarget) {
const fragment = range.extractContents();
const selectionFirst = fragment.firstChild;
const selectionLast = fragment.lastChild;
const parent = ancestor.parentNode;
const nextSibling = ancestor.nextSibling;
const afterRange = document.createRange();
afterRange.setStart(range.startContainer, range.startOffset);
if (ancestor.lastChild) afterRange.setEndAfter(ancestor.lastChild);
else afterRange.collapse(true);
const afterContent = ancestor.lastChild ? afterRange.extractContents() : null;
parent.insertBefore(fragment, nextSibling);
if (afterContent?.textContent) {
const afterClone = ancestor.cloneNode(false);
afterClone.appendChild(afterContent);
parent.insertBefore(afterClone, nextSibling);
}
if (!ancestor.textContent) ancestor.remove();
restoreSelectionOver(selectionFirst, selectionLast);
editTarget.normalize();
}
/**
* Toggles underline on the current selection within the edit target.
* @param {HTMLElement} editTarget
*/
export function applyUnderline(editTarget) {
const range = getActiveRange(editTarget);
if (!range) return;
if (isRangeFullyUnderlined(range, editTarget)) {
if (getTextNodesInRange(range).some(node => hasAncestorTag(node, ['u'], editTarget))) {
unwrapTagsInRange(range, ['u'], editTarget);
}
let selection = window.getSelection();
if (selection?.rangeCount > 0) removeExplicitUnderlineInRange(selection.getRangeAt(0), editTarget);
// Strip Tailwind underline classes from the editTarget itself
TAILWIND_UNDERLINE_CLASSES.forEach(className => editTarget.classList.remove(className));
selection = window.getSelection();
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
const selectionRange = selection.getRangeAt(0);
if (isRangeFullyUnderlined(selectionRange, editTarget)) {
const textNodes = getTextNodesInRange(selectionRange);
const cssAncestor = textNodes.length > 0 ? findCssUnderlineAncestor(textNodes[0], editTarget) : null;
if (cssAncestor) splitElementAroundRange(selectionRange, cssAncestor, editTarget);
else wrapRange(selectionRange, 'span', { textDecoration: 'none' });
}
}
} else {
const decorationStyle = buildUnderlineWrapStyle(range, editTarget);
wrapRange(range, 'u', decorationStyle ?? undefined);
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Finds the nearest ancestor `<span>` that already carries `styleProperty` and whose
* text content exactly matches the selection. Reusing it (instead of nesting a
* new inner span) keeps in-between elements like `<u>` at the same font-size as
* the text, so e.g. underline thickness stays proportional.
* @param {Range} range
* @param {HTMLElement} editTarget
* @param {string} styleProperty
* @returns {HTMLElement|null}
*/
function findStyledAncestorWrappingRange(range, editTarget, styleProperty) {
let element = range.commonAncestorContainer;
if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
const selectionText = range.toString();
if (!selectionText) return null;
let match = null;
while (element && element !== editTarget) {
if (element.nodeType === Node.ELEMENT_NODE && element.tagName === 'SPAN' && element.style[styleProperty]) {
const elementRange = document.createRange();
elementRange.selectNodeContents(element);
if (elementRange.toString() === selectionText) match = element;
}
element = element.parentElement;
}
return match;
}
/**
* Wraps the active selection in a span carrying one inline style prop,
* stripping the same prop from descendants (and a redundant single-child
* parent span) so values never nest.
* @param {HTMLElement} editTarget
* @param {string} styleProperty - camelCase property (e.g. 'fontSize').
* @param {string} cssProperty - kebab-case property (e.g. 'font-size').
* @param {string} value
*/
export function wrapSelectionWithStyle(editTarget, styleProperty, cssProperty, value) {
const range = getActiveRange(editTarget);
if (!range) return;
const reuse = findStyledAncestorWrappingRange(range, editTarget, styleProperty);
if (reuse) {
reuse.style[styleProperty] = value;
if (styleProperty === 'fontSize') reuse.style.lineHeight = 'normal';
reuse.querySelectorAll('*').forEach(element => {
if (element.style[styleProperty]) {
element.style.removeProperty(cssProperty);
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
}
});
reuse.normalize();
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const wrapperStyle = { [styleProperty]: value };
if (styleProperty === 'fontSize') wrapperStyle.lineHeight = 'normal';
const wrapper = wrapRange(range, 'span', wrapperStyle);
if (wrapper) {
wrapper.querySelectorAll('*').forEach(element => {
if (element.style[styleProperty]) {
element.style.removeProperty(cssProperty);
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
}
});
const parentElement = wrapper.parentElement;
if (parentElement && parentElement !== editTarget && parentElement.tagName === 'SPAN' && parentElement.style[styleProperty] && parentElement.childNodes.length === 1) {
parentElement.style.removeProperty(cssProperty);
if (!parentElement.style.cssText.trim()) parentElement.replaceWith(...parentElement.childNodes);
}
wrapper.normalize();
}
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Sets text-align on the edit target element.
* @param {HTMLElement} editTarget
* @param {'left'|'center'|'right'} alignment
*/
export function applyAlign(editTarget, alignment) {
const current = getComputedStyle(editTarget).textAlign;
if (current === alignment || (alignment === 'left' && (current === 'start' || current === 'left'))) return;
editTarget.style.textAlign = alignment;
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
}
@@ -0,0 +1,108 @@
import { PARENT_TOOLBAR_HEIGHT } from '../../../constants/layout.js';
import { isInFixedContext } from '../../../utils/dom-utils.js';
let _toolbarElement = null;
let _toolbarTarget = null;
let dragOffset = { x: 0, y: 0 };
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let toolbarSide = 'below';
/** @param {HTMLElement|null} element */
export function setPositioningEl(element) { _toolbarElement = element; }
/** @param {HTMLElement|null} element */
export function setPositioningTarget(element) { _toolbarTarget = element; }
/** Resets user drag offset to zero. */
export function resetDragOffset() { dragOffset = { x: 0, y: 0 }; }
/**
* Chooses above vs below placement based on viewport space.
* @returns {'above'|'below'}
*/
export function lockToolbarSide() {
const targetBounds = _toolbarTarget.getBoundingClientRect();
const toolbarHeight = _toolbarElement.offsetHeight;
const GAP = 10;
const fitsBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - targetBounds.bottom >= 88;
const fitsAbove = targetBounds.top - toolbarHeight - GAP >= 0;
toolbarSide = (!fitsBelow && fitsAbove) ? 'above' : 'below';
return toolbarSide;
}
/**
* @returns {boolean} True when the toolbar is anchored to a viewport-fixed
* element (fixed/sticky ancestor) and must therefore use `position: fixed`.
* Document-flow elements use `position: absolute` so the toolbar scrolls with
* the page natively (no per-scroll repositioning, no jitter).
*/
export function isToolbarFixedToViewport() {
return !!_toolbarTarget && isInFixedContext(_toolbarTarget);
}
/** Applies locked side and drag offset to position the toolbar. */
export function positionToolbar() {
if (!_toolbarTarget || !_toolbarElement) return;
const targetBounds = _toolbarTarget.getBoundingClientRect();
const toolbarWidth = _toolbarElement.offsetWidth;
const toolbarHeight = _toolbarElement.offsetHeight;
const GAP = 10;
const MARGIN = 8;
// Anchor in document coordinates (position:absolute) so the toolbar scrolls
// together with the element natively. Elements inside fixed/sticky ancestors
// stay in the viewport while the page scrolls, so they keep position:fixed.
const fixed = isInFixedContext(_toolbarTarget);
_toolbarElement.style.position = fixed ? 'fixed' : 'absolute';
const scrollX = fixed ? 0 : window.scrollX;
const scrollY = fixed ? 0 : window.scrollY;
// Use the side locked when the toolbar was shown — don't re-evaluate on scroll.
const baseTop = (toolbarSide === 'above'
? targetBounds.top - toolbarHeight - GAP
: targetBounds.bottom + GAP) + scrollY;
const baseLeft = Math.min(targetBounds.left - 4, window.innerWidth - toolbarWidth - MARGIN) + scrollX;
let left = baseLeft + dragOffset.x;
let top = baseTop + dragOffset.y;
if (isDragging) {
left = Math.max(MARGIN + scrollX, Math.min(left, scrollX + window.innerWidth - toolbarWidth - MARGIN));
top = Math.max(MARGIN + scrollY, Math.min(top, scrollY + window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarHeight - MARGIN));
}
_toolbarElement.style.left = `${left}px`;
_toolbarElement.style.top = `${top}px`;
}
function onDragMove(event) {
if (!isDragging) return;
dragOffset.x = event.clientX - dragStart.x;
dragOffset.y = event.clientY - dragStart.y;
positionToolbar();
}
function onDragEnd() {
isDragging = false;
_toolbarElement?.classList.remove('tft-dragging');
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
}
/**
* Begins toolbar drag tracking.
* @param {MouseEvent} event
*/
export function startDrag(event) {
isDragging = true;
dragStart = { x: event.clientX - dragOffset.x, y: event.clientY - dragOffset.y };
_toolbarElement.classList.add('tft-dragging');
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
}
/** Repositions the toolbar on scroll and closes the more-formatting panel. */
export function onToolbarScroll() {
_toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open');
positionToolbar();
}
@@ -0,0 +1,211 @@
import { TOOLBAR_ID } from './template.js';
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_TRANSLUCENT, HOVER_BG, ACTIVE_BG, BOX_SHADOW_TOOLBAR, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
/** Injected CSS for the floating text-format toolbar. */
export const TOOLBAR_STYLES = `
#${TOOLBAR_ID} {
position: fixed;
display: none;
align-items: center;
padding: 4px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
box-shadow: ${BOX_SHADOW_TOOLBAR};
user-select: none;
color: ${COLOR_WHITE};
font-family: DM Sans, sans-serif;
white-space: nowrap;
cursor: grab;
z-index: ${Z_INDEX_EDITOR_PANEL};
/* Never exceed the viewport — the inner row scrolls horizontally instead. */
max-width: calc(100vw - 16px);
}
#${TOOLBAR_ID}.tft-visible { display: flex; }
#${TOOLBAR_ID}.tft-dragging { cursor: grabbing; }
/* Horizontally scrollable control row. The more-panel is a sibling (not inside
this container) so it is never clipped by the scroll overflow. */
.tft-scroll {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
.tft-scroll::-webkit-scrollbar { display: none; }
.tft-text-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 12px;
background: transparent;
border: none;
border-radius: 8px;
color: ${COLOR_WHITE};
font-family: DM Sans, sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.tft-text-btn:hover { background: ${HOVER_BG}; }
.tft-text-btn svg { flex-shrink: 0; }
.tft-swatch {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
flex-shrink: 0;
}
.tft-swatch:hover { background: ${HOVER_BG}; }
.tft-swatch-preview {
width: 20px;
height: 20px;
border-radius: 6px;
background: ${COLOR_WHITE};
border: 1px solid ${BORDER_COLOR_TRANSLUCENT};
pointer-events: none;
}
.tft-divider {
width: 1px;
height: 24px;
background: ${COLOR_WHITE}26;
flex-shrink: 0;
}
.tft-select {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: transparent;
border: none;
border-radius: 8px;
color: ${COLOR_WHITE};
font-size: 14px;
font-weight: 400;
line-height: 20px;
min-width: 32px;
max-width: 120px;
cursor: pointer;
white-space: nowrap;
}
.tft-select:hover { background: ${HOVER_BG}; }
.tft-select--size { width: 64px; }
/* Fills the button so long font names ellipsis-truncate instead of widening it. */
.tft-label {
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.tft-font-size {
color: ${COLOR_WHITE};
font-size: 14px;
font-weight: 400;
line-height: 20px;
font-family: DM Sans, sans-serif;
cursor: text;
}
.tft-font-size--editing::after {
content: '';
display: inline-block;
width: 1px;
height: 0.9em;
background: ${COLOR_WHITE};
margin-left: 1px;
vertical-align: text-bottom;
animation: tft-cursor-blink 1s step-end infinite;
}
@keyframes tft-cursor-blink {
50% { opacity: 0; }
}
.tft-btn-group {
display: flex;
gap: 2px;
}
.tft-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 8px;
color: #9ca3af;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.tft-btn:hover {
background: ${ACTIVE_BG};
color: ${COLOR_WHITE};
}
.tft-btn.tft-active {
background: ${ACTIVE_BG};
color: ${COLOR_WHITE};
}
.tft-btn--italic {
font-style: italic;
font-weight: 400;
font-size: 15px;
}
.tft-btn--more { display: none; }
@media (pointer: coarse) {
.tft-btn-group .tft-btn[data-action="bold"],
.tft-btn-group .tft-btn[data-action="italic"],
.tft-btn-group .tft-btn[data-action="underline"],
.tft-btn-group .tft-btn[data-action="align"] { display: none; }
.tft-btn--more { display: flex; }
}
.tft-more-panel {
display: none;
position: absolute;
top: calc(100% + 8px);
right: 0;
align-items: center;
gap: 2px;
padding: 4px;
background: ${PANEL_BG};
border: 1px solid ${BORDER_COLOR};
border-radius: 12px;
box-shadow: ${BOX_SHADOW_TOOLBAR};
}
.tft-more-panel.tft-open {
display: flex;
}
`;
@@ -0,0 +1,52 @@
import { ICON_CHEVRON_DOWN, ICON_BOLD, ICON_ITALIC, ICON_UNDERLINE, ICON_ALIGN_LEFT, ICON_MORE, ICON_LINK, ICON_SPARKLES } from "../../../constants/icons.js";
/** DOM id for the floating text-format toolbar root element. */
export const TOOLBAR_ID = 'text-format-toolbar';
/** HTML scaffold for the inline text-format toolbar buttons. */
export const TOOLBAR_HTML = `
<div id="${TOOLBAR_ID}">
<div class="tft-scroll">
<button class="tft-text-btn" data-action="edit-with-ai" title="Edit with AI">
${ICON_SPARKLES}
<span class="tft-text-btn-label">Edit with AI</span>
</button>
<span class="tft-divider"></span>
<button class="tft-swatch" data-action="color" title="Text color">
<span class="tft-swatch-preview"></span>
</button>
<span class="tft-divider"></span>
<button class="tft-select" data-action="font-family" title="Font family">
<span class="tft-label tft-font-family">Inter</span>
${ICON_CHEVRON_DOWN}
</button>
<span class="tft-divider"></span>
<button class="tft-select tft-select--size" data-action="font-size" title="Font size">
<span class="tft-label tft-font-size">16</span>
${ICON_CHEVRON_DOWN}
</button>
<span class="tft-divider"></span>
<div class="tft-btn-group" role="group" aria-label="Text formatting options">
<button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button>
<button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button>
<button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button>
<button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button>
<button class="tft-btn tft-btn--more" data-action="more" title="More formatting">${ICON_MORE}</button>
<button class="tft-btn" data-action="link" title="Link">${ICON_LINK}</button>
</div>
</div>
<div class="tft-more-panel">
<button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button>
<button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button>
<button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button>
<button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button>
</div>
</div>
`.trim();
/** SVG icons for left, center, and right text alignment toolbar states. */
export const ALIGN_ICONS = {
left: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10.5771 11.2539C10.9551 11.2925 11.25 11.6118 11.25 12C11.25 12.3882 10.9551 12.7075 10.5771 12.7461L10.5 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H10.5L10.5771 11.2539Z" fill="white"/><path d="M7.5 7.25C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H3.5C3.08579 8.75 2.75 8.41421 2.75 8C2.75 7.58579 3.08579 7.25 3.5 7.25H7.5Z" fill="white"/><path d="M13.5 3.25C13.9142 3.25007 14.25 3.58583 14.25 4C14.25 4.41417 13.9142 4.74993 13.5 4.75H3.5C3.08579 4.75 2.75 4.41421 2.75 4C2.75 3.58579 3.08579 3.25 3.5 3.25H13.5Z" fill="white"/></svg>`,
center: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5771 11.2539C11.9551 11.2925 12.25 11.6118 12.25 12C12.25 12.3882 11.9551 12.7075 11.5771 12.7461L11.5 12.75H4.5C4.08579 12.75 3.75 12.4142 3.75 12C3.75 11.5858 4.08579 11.25 4.5 11.25H11.5L11.5771 11.2539Z" fill="white"/><path d="M10.0771 7.25391C10.4551 7.29253 10.75 7.61183 10.75 8C10.75 8.38817 10.4551 8.70747 10.0771 8.74609L10 8.75H6C5.58579 8.75 5.25 8.41421 5.25 8C5.25 7.58579 5.58579 7.25 6 7.25H10L10.0771 7.25391Z" fill="white"/><path d="M13 3.25C13.4142 3.25 13.75 3.58579 13.75 4C13.75 4.41421 13.4142 4.75 13 4.75H3C2.58579 4.75 2.25 4.41421 2.25 4C2.25 3.58579 2.58579 3.25 3 3.25H13Z" fill="white"/></svg>`,
right: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12.5 11.25C12.9142 11.25 13.25 11.5858 13.25 12C13.25 12.4142 12.9142 12.75 12.5 12.75H5.5L5.42285 12.7461C5.04488 12.7075 4.75 12.3882 4.75 12C4.75 11.6118 5.04488 11.2925 5.42285 11.2539L5.5 11.25H12.5Z" fill="white"/><path d="M12.5 7.25C12.9142 7.25 13.25 7.58579 13.25 8C13.25 8.41421 12.9142 8.75 12.5 8.75H8.5C8.08579 8.75 7.75 8.41421 7.75 8C7.75 7.58579 8.08579 7.25 8.5 7.25H12.5Z" fill="white"/><path d="M12.5 3.25C12.9142 3.25 13.25 3.58579 13.25 4C13.25 4.41421 12.9142 4.75 12.5 4.75H2.5C2.08579 4.75 1.75 4.41421 1.75 4C1.75 3.58579 2.08579 3.25 2.5 3.25H12.5Z" fill="white"/></svg>`,
};
@@ -0,0 +1,357 @@
import { isolateEditorUiEvents } from '../../../utils/dom-utils.js';
import { hideLinkAction, toggleLinkAction, positionLinkAction } from '../link-action/link-action.js';
import { hideFontAction, toggleFontAction, positionFontAction } from '../font-family-action/font-family-action.js';
import { isPanelVisible, PanelId } from '../../../state/panel-state.js';
import { hideSizeAction, showSizeAction, toggleSizeAction, saveSizeRange, applySizeAction, positionSizeAction } from '../size-action/size-action.js';
import { hideColorAction, toggleColorAction, positionColorAction } from '../color-action/color-action.js';
import { applyBold, applyItalic, applyUnderline, applyAlign } from '../style-actions.js';
import { onSelectionChange } from '../../inline-edit/cursor-styles.js';
import { placeCursorAtEnd } from '../../inline-edit/edit-action.js';
import { TOOLBAR_HTML, ALIGN_ICONS } from './template.js';
import { TOOLBAR_STYLES } from './styles.js';
import {
setPositioningEl, setPositioningTarget, resetDragOffset,
lockToolbarSide, positionToolbar, startDrag, isToolbarFixedToViewport,
} from './positioning.js';
import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel } from '../../annotation-panel/panel.js';
import { getEditorTranslations } from '../../../state/annotation-state.js';
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT } from '../../../constants/layout.js';
let toolbarElement = null;
let toolbarTarget = null;
let toolbarResizeObserver = null;
let toolbarMutationObserver = null;
let windowResizeListener = null;
let repositionAnimationFrameId = null;
let fontSizeEditing = false;
let fontSizeOriginal = '';
function onFontSizeKeydown(event) {
const span = toolbarElement?.querySelector('.tft-font-size');
if (!span) return;
if (/^\d$/.test(event.key)) {
event.preventDefault(); event.stopPropagation();
if (span.textContent.length < 3) span.textContent += event.key;
} else if (event.key === 'Backspace') {
event.preventDefault(); event.stopPropagation();
span.textContent = span.textContent.slice(0, -1);
} else if (event.key === 'Enter') {
event.preventDefault(); event.stopPropagation();
applySizeAction(parseInt(span.textContent, 10));
stopFontSizeEdit();
hideSizeAction();
} else if (event.key === 'Escape') {
event.preventDefault(); event.stopPropagation();
span.textContent = fontSizeOriginal;
stopFontSizeEdit();
hideSizeAction();
}
}
function startFontSizeEdit() {
if (fontSizeEditing) return;
const span = toolbarElement?.querySelector('.tft-font-size');
if (!span) return;
fontSizeEditing = true;
fontSizeOriginal = span.textContent;
span.textContent = '';
span.classList.add('tft-font-size--editing');
saveSizeRange();
document.addEventListener('keydown', onFontSizeKeydown, true);
}
/** Ends inline font-size keyboard editing on the toolbar label. */
export function stopFontSizeEdit() {
if (!fontSizeEditing) return;
fontSizeEditing = false;
const span = toolbarElement?.querySelector('.tft-font-size');
if (span) {
span.classList.remove('tft-font-size--editing');
if (!span.textContent) span.textContent = fontSizeOriginal;
}
document.removeEventListener('keydown', onFontSizeKeydown, true);
}
/** @returns {HTMLElement|null} */
export function getToolbarEl() { return toolbarElement; }
/** @returns {HTMLElement|null} The element currently being formatted. */
export function getToolbarTarget() { return toolbarTarget; }
function restoreSelectionAndCloseLinkPanel() {
if (!isPanelVisible(PanelId.LINK)) return;
hideLinkAction();
}
function repositionAttachedPanels() {
positionColorAction();
positionFontAction();
positionSizeAction();
positionLinkAction();
repositionAnnotationToToolbar();
}
function repositionAnnotationToToolbar() {
const panel = getAnnotationPanelEl();
if (!panel?.classList.contains('active') || !toolbarElement) return;
if (panel.style.position === 'absolute') return;
const rect = toolbarElement.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + PANEL_GAP;
if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) {
left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN;
}
if (left < PANEL_MARGIN) left = PANEL_MARGIN;
if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) {
top = rect.top - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP;
}
if (top < PANEL_MARGIN) top = PANEL_MARGIN;
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
}
function repositionEditOverlays() {
if (!toolbarTarget || !toolbarElement?.classList.contains('tft-visible')) return;
positionToolbar();
repositionAttachedPanels();
}
/** Repositions toolbar and open sub-panels after the edit target changes size. */
export function scheduleRepositionEditOverlays() {
if (repositionAnimationFrameId) cancelAnimationFrame(repositionAnimationFrameId);
repositionAnimationFrameId = requestAnimationFrame(() => {
repositionAnimationFrameId = null;
repositionEditOverlays();
});
}
function observeToolbarTarget() {
disconnectToolbarTargetObserver();
if (!toolbarTarget) return;
if (typeof ResizeObserver !== 'undefined') {
toolbarResizeObserver = new ResizeObserver(scheduleRepositionEditOverlays);
toolbarResizeObserver.observe(toolbarTarget);
}
if (typeof MutationObserver !== 'undefined') {
toolbarMutationObserver = new MutationObserver(scheduleRepositionEditOverlays);
toolbarMutationObserver.observe(toolbarTarget, {
childList: true,
subtree: true,
characterData: true,
});
}
windowResizeListener = () => scheduleRepositionEditOverlays();
window.addEventListener('resize', windowResizeListener, { passive: true });
}
function disconnectToolbarTargetObserver() {
toolbarResizeObserver?.disconnect();
toolbarResizeObserver = null;
toolbarMutationObserver?.disconnect();
toolbarMutationObserver = null;
if (windowResizeListener) {
window.removeEventListener('resize', windowResizeListener);
windowResizeListener = null;
}
if (repositionAnimationFrameId) {
cancelAnimationFrame(repositionAnimationFrameId);
repositionAnimationFrameId = null;
}
}
function onToolbarScroll() {
toolbarElement?.querySelector('.tft-more-panel')?.classList.remove('tft-open');
if (isToolbarFixedToViewport()) {
// The element lives in a fixed/sticky ancestor, so it stays in the
// viewport while the page scrolls; keep the viewport-anchored toolbar
// and its panels aligned to it.
repositionEditOverlays();
return;
}
// Document-flow element: the absolutely-positioned toolbar scrolls with the
// page natively (no reposition needed, so no bounce). Only realign open
// sub-panels, which are viewport-fixed and anchored to the toolbar.
repositionAttachedPanels();
}
function initToolbar() {
if (toolbarElement) return;
const style = document.createElement('style');
style.id = 'text-format-toolbar-styles';
style.textContent = TOOLBAR_STYLES;
document.head.appendChild(style);
const wrapper = document.createElement('div');
wrapper.innerHTML = TOOLBAR_HTML;
toolbarElement = wrapper.firstElementChild;
document.body.appendChild(toolbarElement);
isolateEditorUiEvents(toolbarElement);
const editWithAiLabel = getEditorTranslations().toolbarEditWithAi;
if (editWithAiLabel) {
const labelSpan = toolbarElement.querySelector('[data-action="edit-with-ai"] .tft-text-btn-label');
if (labelSpan) labelSpan.textContent = editWithAiLabel;
}
setPositioningEl(toolbarElement);
toolbarElement.addEventListener('mousedown', (event) => {
event.preventDefault();
if (event.target.closest('[data-action]')) return;
hideFontAction();
hideLinkAction();
hideSizeAction();
hideColorAction(true);
startDrag(event);
});
toolbarElement.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button || !toolbarTarget) return;
const action = button.dataset.action;
const opensDropdownPanel = action === 'color' || action === 'font-family' || action === 'font-size' || action === 'link';
if (action !== 'font-family') hideFontAction();
if (action !== 'font-size') { hideSizeAction(); stopFontSizeEdit(); }
if (action !== 'color') hideColorAction();
if (action !== 'link') restoreSelectionAndCloseLinkPanel();
if (action !== 'edit-with-ai') {
const annotationWasOpen = getAnnotationPanelEl()?.classList.contains('active');
// Skip the focus/caret restore when opening another dropdown panel —
// it fires a selectionchange event that would immediately close the
// panel this same click is about to open.
hideAnnotationPanel({ restoreFocus: !opensDropdownPanel });
if (annotationWasOpen && toolbarTarget && !opensDropdownPanel) {
toolbarTarget.focus();
placeCursorAtEnd(toolbarTarget);
}
}
switch (action) {
case 'color': toggleColorAction(); break;
case 'font-family': toggleFontAction(); break;
case 'more':
toolbarElement.querySelector('.tft-more-panel')?.classList.toggle('tft-open');
break;
case 'font-size':
if (event.target.closest('.tft-font-size')) {
showSizeAction();
startFontSizeEdit();
} else {
stopFontSizeEdit();
toggleSizeAction();
}
break;
case 'bold': applyBold(toolbarTarget); onSelectionChange(true); break;
case 'italic': applyItalic(toolbarTarget); onSelectionChange(true); break;
case 'underline': applyUnderline(toolbarTarget); onSelectionChange(true); break;
case 'align': {
const current = getComputedStyle(toolbarTarget).textAlign;
const next = current === 'left' || current === 'start' ? 'center'
: current === 'center' ? 'right'
: 'left';
applyAlign(toolbarTarget, next);
onSelectionChange(true);
break;
}
case 'link': toggleLinkAction(); break;
case 'edit-with-ai': {
const target = toolbarTarget;
if (!target) break;
if (getAnnotationPanelEl()?.classList.contains('active')) {
hideAnnotationPanel();
break;
}
const toolbarRect = toolbarElement.getBoundingClientRect();
openOrCreateAnnotation(target, toolbarRect.left, toolbarRect.bottom, toolbarRect.top);
break;
}
}
});
}
/**
* Shows and positions the toolbar for the given edit target.
* @param {HTMLElement} targetElement
*/
export function showTextFormatToolbar(targetElement) {
if (!toolbarElement) initToolbar();
else if (!toolbarElement.isConnected) document.body.appendChild(toolbarElement);
toolbarTarget = targetElement;
setPositioningTarget(targetElement);
resetDragOffset();
toolbarElement.classList.add('tft-visible');
lockToolbarSide();
positionToolbar();
observeToolbarTarget();
document.addEventListener('scroll', onToolbarScroll, true);
}
/** Hides the toolbar and all sub-panels. */
export function hideTextFormatToolbar() {
if (!toolbarElement) return;
toolbarElement.classList.remove('tft-visible');
toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open');
hideFontAction();
hideLinkAction();
hideSizeAction();
hideColorAction(true);
stopFontSizeEdit();
toolbarTarget = null;
setPositioningTarget(null);
disconnectToolbarTargetObserver();
document.removeEventListener('scroll', onToolbarScroll, true);
}
/**
* Syncs toolbar controls to styles reported at the caret.
* @param {object} styles - From getStylesAtCursor().
*/
export function updateTextFormatToolbar(styles) {
if (!toolbarElement?.classList.contains('tft-visible')) return;
const swatch = toolbarElement.querySelector('.tft-swatch-preview');
if (swatch) swatch.style.background = styles.color;
const fontFamilyLabel = toolbarElement.querySelector('.tft-font-family');
if (fontFamilyLabel) {
fontFamilyLabel.textContent = styles.fontFamily
.split(',')[0]
.replace(/['"]/g, '')
.trim();
}
const fontSizeLabel = toolbarElement.querySelector('.tft-font-size');
if (fontSizeLabel && !fontSizeEditing) {
fontSizeLabel.textContent = String(Math.round(parseFloat(styles.fontSize)));
}
const isBold = !!styles.isBold;
const isItalic = !!styles.isItalic;
const isUnderline = !!styles.isUnderline;
const align = styles.textAlign === 'center' ? 'center'
: styles.textAlign === 'right' ? 'right'
: 'left';
toolbarElement.querySelectorAll('[data-action="bold"]').forEach(button =>
button.classList.toggle('tft-active', isBold));
toolbarElement.querySelectorAll('[data-action="italic"]').forEach(button =>
button.classList.toggle('tft-active', isItalic));
toolbarElement.querySelectorAll('[data-action="underline"]').forEach(button =>
button.classList.toggle('tft-active', isUnderline));
toolbarElement.querySelectorAll('[data-action="align"]').forEach(button => {
button.innerHTML = ALIGN_ICONS[align];
});
}