482 lines
17 KiB
JavaScript
482 lines
17 KiB
JavaScript
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 } 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 } 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_CLOSE, ICON_PAPER_CLIP } 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 image',
|
|
annotationRemoveImage: 'Remove image',
|
|
};
|
|
|
|
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 _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.title = label('annotationAttachImage');
|
|
|
|
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);
|
|
});
|
|
|
|
_annotationPanelEl.addEventListener('keydown', stopHostPageKeyPropagation);
|
|
_annotationPanelEl.addEventListener('keyup', stopHostPageKeyPropagation);
|
|
|
|
return _annotationPanelEl;
|
|
}
|
|
|
|
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_PAPER_CLIP;
|
|
attachImageBtn.title = (isAtLimit || isUploading) ? '' : label('annotationAttachImage');
|
|
}
|
|
|
|
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 name = document.createElement('span');
|
|
name.className = 'selection-mode-annotation-attachment-name';
|
|
name.textContent = fileName;
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'selection-mode-annotation-attachment-remove';
|
|
removeBtn.title = label('annotationRemoveImage');
|
|
removeBtn.innerHTML = ICON_CLOSE;
|
|
removeBtn.addEventListener('click', () => removePendingAttachment(url));
|
|
|
|
chip.append(name, 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');
|
|
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 stopHostPageKeyPropagation(event) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
function attachPanelKeydown() {
|
|
detachPanelKeydown();
|
|
_panelKeydownHandler = (event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
hideAnnotationPanel();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', _panelKeydownHandler, true);
|
|
}
|
|
|
|
function detachPanelKeydown() {
|
|
if (_panelKeydownHandler) {
|
|
document.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);
|
|
}
|
|
}
|