chore(repository): reset repo to new version

This commit is contained in:
Andres Santana
2026-09-25 10:00:06 -04:00
commit 8e93ca8b2c
115 changed files with 22408 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>
`;