chore(repository): reset repo to new version
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user