chore(repository): reset repo to new version
This commit is contained in:
@@ -0,0 +1,812 @@
|
||||
import { startInlineEdit, commitCurrentEdit, placeCursorAtPoint } from "./ui/inline-edit/edit-action.js";
|
||||
import { getEditing } from "./state/editing-state.js";
|
||||
import { getElementType, isInFixedContext, isolateEditorUiEvents, getOpenModals } from "./utils/dom-utils.js";
|
||||
import { postToParent, ALLOWED_PARENT_ORIGINS } from "./utils/parent-frame.js";
|
||||
import { ParentMessage, ChildMessage } from "./constants/messages.js";
|
||||
import { isEditModeEnabled, isInsideEditorUi, ACTIONABLE_SELECTOR, EDIT_TARGET_SELECTOR, getEditId } from "./constants/selectors.js";
|
||||
import { recordEdit, reapplyCommittedEdits, getEditState } from "./state/history-state.js";
|
||||
import { applySessionUndo, applySessionRedo } from "./utils/session-history-command.js";
|
||||
import { handleDraftSave, handleDraftDiscard, discardAllEdits } from "./api/draft.js";
|
||||
import { applyDraftSnapshot, notifyDraftStateChanged } from "./api/draft-snapshot.js";
|
||||
import { patchReactDomMutations } from "./utils/html-utils.js";
|
||||
import { showTypeTooltip, hideTypeTooltip } from "./ui/overlays/type-tooltip.js";
|
||||
import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel, openPanelForEdit, addPendingAttachments } from "./ui/annotation-panel/panel.js";
|
||||
import { clearAllAnnotationMarkers, refreshAnnotationMarkers } from "./ui/annotation-panel/annotation-markers.js";
|
||||
import { setEditorTranslations, setComments, getPendingElements } from "./state/annotation-state.js";
|
||||
import { captureElementMetadata } from "./utils/selection-mode-metadata.js";
|
||||
import { hideTextFormatToolbar } from "./ui/text-format/toolbar/toolbar.js";
|
||||
import { hideAllPanels } from "./state/panel-state.js";
|
||||
import { lockHoverOutline, unlockHoverOutline } from "./ui/overlays/hover-outline.js";
|
||||
import {
|
||||
DRAG_SELECT_THRESHOLD, MAX_SELECTED_ELEMENTS,
|
||||
ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, PANEL_GAP,
|
||||
} from "./constants/layout.js";
|
||||
import {
|
||||
getSelectedElements, setSelectedElements, clearSelectedElements,
|
||||
getDragStartX, getDragStartY, setDragStart, clearDragStart,
|
||||
getIsDragging, setIsDragging,
|
||||
getHadDrag, setHadDrag,
|
||||
isDragPending, resetMultiSelectState, toggleSelectedElement, orderSelection,
|
||||
pruneDetachedSelection,
|
||||
} from "./state/multi-select-state.js";
|
||||
import {
|
||||
getSelectionRect, collectSelection, NO_TEXT_SELECT_CLASS,
|
||||
showSelectionRect, hideSelectionRect, destroySelectionRect,
|
||||
} from "./ui/overlays/selection-rect.js";
|
||||
|
||||
let globalClickHandler = null;
|
||||
|
||||
/** Tracks the element behind the parent image picker (back-compat `edit-save` flow). */
|
||||
let currentImageEdit = null;
|
||||
|
||||
let lastCursorClientX = 0;
|
||||
let lastCursorClientY = 0;
|
||||
let typeTooltipAnimationFrameId = null;
|
||||
/** True right after a mousedown until the next genuine mousemove, so a drag doesn't drag the tooltip too. */
|
||||
let suppressTypeTooltipUntilMove = false;
|
||||
/** Caches the last raw `elementFromPoint` hit to skip the pointer-events workaround below when unchanged. */
|
||||
let lastRawHitTestElement = null;
|
||||
let lastResolvedTypeTooltipTarget = null;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Editable target lookup
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function findEditableElementAtPoint(event) {
|
||||
const direct = event.target.closest(EDIT_TARGET_SELECTOR);
|
||||
if (direct) return direct;
|
||||
|
||||
if (isInFixedContext(event.target)) return null;
|
||||
|
||||
const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
const found = elementsAtPoint.find(
|
||||
(element) => element !== event.target && (element.hasAttribute("data-edit-id") || element.hasAttribute("data-edit-assisted-id"))
|
||||
);
|
||||
return found || null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Image edit (parent picker) — kept as the existing message contract
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function startImageEdit(targetElement, editId) {
|
||||
currentImageEdit = { editId, targetElement };
|
||||
lockHoverOutline(targetElement);
|
||||
postToParent(ParentMessage.IMAGE_EDIT_ENTER, {
|
||||
currentText: targetElement.getAttribute('src') || '',
|
||||
elementType: 'img',
|
||||
isDirectlyEditable: targetElement.hasAttribute('data-edit-id'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears the in-progress image edit and drops its locked outline. */
|
||||
function clearImageEdit() {
|
||||
if (!currentImageEdit) return;
|
||||
currentImageEdit = null;
|
||||
unlockHoverOutline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses an active image selection and tells the parent to close the image
|
||||
* picker popup (e.g. when the user clicks away to another element or empty space).
|
||||
*/
|
||||
function cancelImageEdit() {
|
||||
if (!currentImageEdit) return;
|
||||
clearImageEdit();
|
||||
postToParent(ParentMessage.EDIT_CANCEL, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent image picker drives image replacement via `edit-save`. The change
|
||||
* is applied to the live DOM and recorded in history; it is written to source
|
||||
* only on an explicit draft save.
|
||||
*/
|
||||
function handleImageEditSave(newText) {
|
||||
if (!currentImageEdit) return;
|
||||
const { editId, targetElement } = currentImageEdit;
|
||||
const beforeContent = targetElement.getAttribute('src');
|
||||
const afterContent = newText ?? "";
|
||||
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
|
||||
const selectionMode = captureElementMetadata(targetElement);
|
||||
targetElement.setAttribute('src', afterContent);
|
||||
recordEdit(editId, { beforeContent, afterContent }, {
|
||||
attribute: 'src',
|
||||
element: targetElement,
|
||||
isAssisted,
|
||||
selectionMode,
|
||||
});
|
||||
clearImageEdit();
|
||||
notifyDraftStateChanged();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Group selection (drag band + shift+click)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
let dragPreviewFrameId = null;
|
||||
|
||||
/** Drops the group and the outlines marking it. */
|
||||
function clearMultiSelection() {
|
||||
clearSelectedElements();
|
||||
unlockHoverOutline();
|
||||
}
|
||||
|
||||
/** Element under the cursor that a group may target, or null for empty space. */
|
||||
function resolveGroupTarget(event) {
|
||||
const rawTarget = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!rawTarget || isPageRoot(rawTarget) || isInsideEditorUi(rawTarget)) return null;
|
||||
|
||||
const resolved = resolveHoverTarget(rawTarget, event.clientX, event.clientY);
|
||||
return resolved && !isPageRoot(resolved) ? resolved : null;
|
||||
}
|
||||
|
||||
/** True when the click landed on a member of the group (or inside one). */
|
||||
function isGroupMember(group, target) {
|
||||
return !!target && group.some(member => member === target || member.contains(target));
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag takes over the whole surface, so every other floating affordance —
|
||||
* toolbar dropdowns, the text toolbar, an open annotation panel, the image
|
||||
* picker — gets out of the way before the band is drawn.
|
||||
*/
|
||||
function beginDragSelect(event) {
|
||||
setIsDragging(true);
|
||||
hideAllPanels();
|
||||
hideTextFormatToolbar();
|
||||
// A shift+drag extends the group, so the panel must not take it down with it.
|
||||
hideAnnotationPanel({ restoreFocus: false, keepSelection: event.shiftKey });
|
||||
if (getEditing()) commitCurrentEdit();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
if (!event.shiftKey) clearMultiSelection();
|
||||
}
|
||||
|
||||
/** Ends the gesture and tears the band down, whether it completed or was abandoned. */
|
||||
function endDragSelect() {
|
||||
clearDragStart();
|
||||
setIsDragging(false);
|
||||
if (dragPreviewFrameId !== null) {
|
||||
cancelAnimationFrame(dragPreviewFrameId);
|
||||
dragPreviewFrameId = null;
|
||||
}
|
||||
hideSelectionRect();
|
||||
document.body.classList.remove(NO_TEXT_SELECT_CLASS);
|
||||
}
|
||||
|
||||
function cancelDragSelect() {
|
||||
if (!isDragPending()) return;
|
||||
endDragSelect();
|
||||
clearMultiSelection();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden" && getEditing()) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectMouseDown(event) {
|
||||
setHadDrag(false);
|
||||
if (!isEditModeEnabled()) return;
|
||||
if (event.button !== 0 || isInsideEditorUi(event.target)) return;
|
||||
// Text selection inside the active inline edit stays native.
|
||||
if (getEditing()?.targetElement?.contains(event.target)) return;
|
||||
|
||||
// Suppress all native pointerdown side-effects (focus, text selection, form
|
||||
// validation) so the host app never reacts to clicks during edit mode.
|
||||
event.preventDefault();
|
||||
|
||||
document.body.classList.add(NO_TEXT_SELECT_CLASS);
|
||||
if (event.shiftKey) {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
setDragStart(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the previewed group at most once per frame — `collectSelection`
|
||||
* walks the page and measures every candidate, which is far too heavy to run on
|
||||
* every mousemove of a drag.
|
||||
*/
|
||||
function scheduleDragPreview(clientX, clientY, additive) {
|
||||
if (dragPreviewFrameId !== null) return;
|
||||
dragPreviewFrameId = requestAnimationFrame(() => {
|
||||
dragPreviewFrameId = null;
|
||||
if (!getIsDragging()) return;
|
||||
|
||||
const preview = collectSelection(getSelectionRect(getDragStartX(), getDragStartY(), clientX, clientY));
|
||||
lockHoverOutline(orderSelection(additive ? [...getSelectedElements(), ...preview] : preview));
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectMouseMove(event) {
|
||||
if (!isDragPending()) return;
|
||||
|
||||
const startX = getDragStartX();
|
||||
const startY = getDragStartY();
|
||||
|
||||
if (!getIsDragging()) {
|
||||
const movedFarEnough = Math.abs(event.clientX - startX) > DRAG_SELECT_THRESHOLD
|
||||
|| Math.abs(event.clientY - startY) > DRAG_SELECT_THRESHOLD;
|
||||
if (!movedFarEnough) return;
|
||||
beginDragSelect(event);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
showSelectionRect(startX, startY, event.clientX, event.clientY);
|
||||
scheduleDragPreview(event.clientX, event.clientY, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleSelectMouseUp(event) {
|
||||
if (!isDragPending()) return;
|
||||
|
||||
const startX = getDragStartX();
|
||||
const startY = getDragStartY();
|
||||
const wasDragging = getIsDragging();
|
||||
endDragSelect();
|
||||
if (!wasDragging) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// The browser still delivers a click for this gesture; handleClick drops it.
|
||||
setHadDrag(true);
|
||||
|
||||
const picked = collectSelection(getSelectionRect(startX, startY, event.clientX, event.clientY));
|
||||
const group = orderSelection(event.shiftKey ? [...getSelectedElements(), ...picked] : picked)
|
||||
.slice(0, MAX_SELECTED_ELEMENTS);
|
||||
setSelectedElements(group);
|
||||
|
||||
if (!group.length) {
|
||||
clearMultiSelection();
|
||||
return;
|
||||
}
|
||||
openOrCreateAnnotation(group, event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
/** Escape drops a group that no panel is holding; an open panel owns the key itself. */
|
||||
function handleSelectionKeydown(event) {
|
||||
if (event.key !== 'Escape' || !getSelectedElements().length) return;
|
||||
if (getAnnotationPanelEl()?.classList.contains('active')) return;
|
||||
clearMultiSelection();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Click routing
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function handleClick(event) {
|
||||
if (!isEditModeEnabled()) return;
|
||||
|
||||
if (event.target.closest?.("a[href]")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (isInsideEditorUi(event.target)) {
|
||||
cancelImageEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: drop the click a completed drag leaves behind.
|
||||
if (getHadDrag()) {
|
||||
setHadDrag(false);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: shift+click adds or removes one element from the group. The panel
|
||||
// stays shut — it opens on the following plain click on a member.
|
||||
if (event.shiftKey) {
|
||||
const target = resolveGroupTarget(event);
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
if (getEditing()) {
|
||||
hideTextFormatToolbar();
|
||||
commitCurrentEdit();
|
||||
}
|
||||
hideAnnotationPanel({ restoreFocus: false, keepSelection: true });
|
||||
|
||||
// At the cap, removing still works but adding is ignored — silently
|
||||
// dropping some other member to make room would be worse.
|
||||
const selected = getSelectedElements();
|
||||
if (selected.includes(target) || selected.length < MAX_SELECTED_ELEMENTS) {
|
||||
toggleSelectedElement(target);
|
||||
}
|
||||
lockHoverOutline(getSelectedElements());
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: open the group's panel when the click lands on one of its members;
|
||||
// anything else drops the group and falls through to single-element routing.
|
||||
const group = getSelectedElements();
|
||||
if (group.length) {
|
||||
if (isGroupMember(group, resolveGroupTarget(event))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
if (getEditing()) commitCurrentEdit();
|
||||
hideAnnotationPanel({ restoreFocus: false, keepSelection: true });
|
||||
|
||||
openOrCreateAnnotation(group, event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
clearMultiSelection();
|
||||
}
|
||||
|
||||
// Action: dismiss annotation panel on outside click.
|
||||
if (getAnnotationPanelEl()?.classList.contains('active')) {
|
||||
hideAnnotationPanel();
|
||||
hideTextFormatToolbar();
|
||||
if (getEditing()) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
if (!findEditableElementAtPoint(event)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const editing = getEditing();
|
||||
|
||||
// Action: place cursor inside the active contenteditable on click.
|
||||
if (editing?.targetElement?.hasAttribute("contenteditable") && editing.targetElement.contains(event.target)) {
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
const link = event.target.closest?.("a");
|
||||
if (link && editing.targetElement.contains(link)) {
|
||||
event.preventDefault();
|
||||
if (event.detail <= 1) {
|
||||
placeCursorAtPoint(event.clientX, event.clientY, editing.targetElement);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: start inline edit or image edit on an editable element.
|
||||
const editable = findEditableElementAtPoint(event);
|
||||
|
||||
if (editable) {
|
||||
if (editing && editing.targetElement === editable) {
|
||||
commitCurrentEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const editId = getEditId(editable);
|
||||
if (!editId) return;
|
||||
|
||||
hideTypeTooltipImmediately();
|
||||
|
||||
if (editable?.tagName?.toLowerCase() === 'img') {
|
||||
if (editing) commitCurrentEdit();
|
||||
startImageEdit(editable, editId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
clearImageEdit();
|
||||
startInlineEdit(editable, editId, editable.innerHTML || "");
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: commit any active inline edit and stop.
|
||||
if (editing) {
|
||||
commitCurrentEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: open annotation panel for non-editable elements (blocks, icon-only buttons/links, etc.).
|
||||
const rawTarget = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (rawTarget && !isPageRoot(rawTarget)) {
|
||||
const hoverTarget = resolveHoverTarget(rawTarget, event.clientX, event.clientY);
|
||||
const hoverType = hoverTarget && getElementType(hoverTarget);
|
||||
if (hoverTarget && !isPageRoot(hoverTarget) && (hoverType === 'block' || hoverType === 'button')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
|
||||
postToParent(ParentMessage.ANNOTATION_PANEL_OPENED, { elementType: hoverTarget.tagName.toLowerCase() });
|
||||
openOrCreateAnnotation(hoverTarget, event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Action: close the image picker when clicking empty space.
|
||||
cancelImageEdit();
|
||||
|
||||
if (event.target.closest("a[href], button, [role='button'], [type='submit']")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Hover type tooltip (element type near the cursor)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** Finds the deepest actionable descendant of `container` still under `(clientX, clientY)`, or `container` itself. */
|
||||
function resolveActionableDescendantAtPoint(container, clientX, clientY) {
|
||||
let deepest = container;
|
||||
for (const candidate of container.querySelectorAll(ACTIONABLE_SELECTOR)) {
|
||||
const bounds = candidate.getBoundingClientRect();
|
||||
if (clientX >= bounds.left && clientX <= bounds.right && clientY >= bounds.top && clientY <= bounds.bottom) {
|
||||
deepest = candidate;
|
||||
}
|
||||
}
|
||||
return deepest;
|
||||
}
|
||||
|
||||
/** Hides the type tooltip and drops any frame scheduled to redisplay it. */
|
||||
function hideTypeTooltipImmediately() {
|
||||
if (typeTooltipAnimationFrameId !== null) {
|
||||
cancelAnimationFrame(typeTooltipAnimationFrameId);
|
||||
typeTooltipAnimationFrameId = null;
|
||||
}
|
||||
hideTypeTooltip();
|
||||
}
|
||||
|
||||
/** Resolves the element under the cursor to highlight:
|
||||
* an editable or `<svg>` ancestor wins, else the raw target. */
|
||||
function resolveHoverTarget(rawTarget, clientX, clientY) {
|
||||
const editableAncestor = rawTarget.closest(EDIT_TARGET_SELECTOR);
|
||||
if (editableAncestor) return resolveActionableDescendantAtPoint(editableAncestor, clientX, clientY);
|
||||
|
||||
const svgAncestor = rawTarget.closest('svg');
|
||||
if (svgAncestor) return svgAncestor;
|
||||
|
||||
return resolveActionableDescendantAtPoint(rawTarget, clientX, clientY);
|
||||
}
|
||||
|
||||
/** True for the page's own root containers, which are too broad to be a meaningful hover target. */
|
||||
function isPageRoot(element) {
|
||||
return element === document.body || element === document.documentElement || element.id === 'root';
|
||||
}
|
||||
|
||||
function updateTypeTooltip() {
|
||||
// A drag keeps firing mousemove, which would otherwise clear the
|
||||
// suppress-until-move flag and flash the tooltip over the band.
|
||||
if (!isEditModeEnabled() || suppressTypeTooltipUntilMove || getIsDragging()) {
|
||||
hideTypeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const rawTarget = document.elementFromPoint(lastCursorClientX, lastCursorClientY);
|
||||
if (!rawTarget || isInsideEditorUi(rawTarget) || isPageRoot(rawTarget)) {
|
||||
hideTypeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const editing = getEditing();
|
||||
if (editing?.targetElement && (editing.targetElement === rawTarget || editing.targetElement.contains(rawTarget))) {
|
||||
hideTypeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawTarget !== lastRawHitTestElement) {
|
||||
lastRawHitTestElement = rawTarget;
|
||||
lastResolvedTypeTooltipTarget = resolveHoverTarget(rawTarget, lastCursorClientX, lastCursorClientY);
|
||||
}
|
||||
|
||||
showTypeTooltip(lastResolvedTypeTooltipTarget, lastCursorClientX, lastCursorClientY);
|
||||
}
|
||||
|
||||
/** Throttles type-tooltip recomputation to at most once per animation frame. */
|
||||
function scheduleTypeTooltipUpdate() {
|
||||
if (typeTooltipAnimationFrameId !== null) return;
|
||||
typeTooltipAnimationFrameId = requestAnimationFrame(() => {
|
||||
typeTooltipAnimationFrameId = null;
|
||||
updateTypeTooltip();
|
||||
});
|
||||
}
|
||||
|
||||
function handleTypeTooltipMouseMove(event) {
|
||||
lastCursorClientX = event.clientX;
|
||||
lastCursorClientY = event.clientY;
|
||||
suppressTypeTooltipUntilMove = false;
|
||||
scheduleTypeTooltipUpdate();
|
||||
}
|
||||
|
||||
/** Wheel-scroll fires no `mousemove`, so recompute from the last known cursor position. */
|
||||
function handleTypeTooltipScroll() {
|
||||
if (!isEditModeEnabled()) return;
|
||||
scheduleTypeTooltipUpdate();
|
||||
}
|
||||
|
||||
/** Suppresses the tooltip for the duration of a text-selection or element drag. */
|
||||
function handleTypeTooltipMouseDown() {
|
||||
suppressTypeTooltipUntilMove = true;
|
||||
hideTypeTooltipImmediately();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Annotation ↔ DOM sync (client-side route changes, re-renders)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
let annotationDomObserver = null;
|
||||
let annotationDomSyncFrameId = null;
|
||||
|
||||
/**
|
||||
* Re-anchors markers to their (re-mounted) elements and hides the ones whose
|
||||
* element left the page, so a comment made on one route stays on that route.
|
||||
* Also re-applies unsaved edits to any element a route change or React
|
||||
* re-render replaced, so they don't visually revert to the unedited source.
|
||||
*/
|
||||
function syncAnnotationsWithDom() {
|
||||
annotationDomSyncFrameId = null;
|
||||
reapplyCommittedEdits();
|
||||
refreshAnnotationMarkers();
|
||||
|
||||
// A group only ever spans one page: navigating away unmounts its members, and
|
||||
// an outline can't be drawn for an element that is no longer there.
|
||||
if (pruneDetachedSelection()) {
|
||||
const group = getSelectedElements();
|
||||
if (group.length) lockHoverOutline(group);
|
||||
else clearMultiSelection();
|
||||
}
|
||||
|
||||
const pendingElements = getPendingElements();
|
||||
if (pendingElements.length
|
||||
&& pendingElements.every(element => !element.isConnected)
|
||||
&& getAnnotationPanelEl()?.classList.contains('active')) {
|
||||
hideAnnotationPanel({ restoreFocus: false });
|
||||
hideTextFormatToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
/** Throttles the annotation sync to at most once per animation frame. */
|
||||
function scheduleAnnotationDomSync() {
|
||||
if (annotationDomSyncFrameId !== null) return;
|
||||
annotationDomSyncFrameId = requestAnimationFrame(syncAnnotationsWithDom);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Undo / redo keyboard shortcuts
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** True for native text-editing surfaces where the browser should own undo. */
|
||||
function isNativeEditingContext(target) {
|
||||
if (!target) return false;
|
||||
const tag = target.tagName?.toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || !!target.isContentEditable;
|
||||
}
|
||||
|
||||
function handleHistoryKeydown(event) {
|
||||
if (!isEditModeEnabled()) return;
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return;
|
||||
|
||||
// While an inline edit is active, let the browser handle native text undo
|
||||
// inside the contenteditable region instead of stepping the edit history.
|
||||
if (getEditing()?.targetElement?.hasAttribute("contenteditable") || isNativeEditingContext(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
applySessionRedo();
|
||||
} else {
|
||||
applySessionUndo();
|
||||
}
|
||||
}
|
||||
|
||||
/* Keeps host-page modals open and host-page key handlers quiet in edit mode. */
|
||||
|
||||
function handleModalPointerDownGuard(event) {
|
||||
if (isInsideEditorUi(event.target)) return;
|
||||
|
||||
const openModals = getOpenModals();
|
||||
if (!openModals.length) return;
|
||||
if (openModals.some((modal) => modal.contains(event.target))) return;
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered on window capture after the editor's own key handlers: `stopPropagation`
|
||||
* still lets those sibling window listeners run, but nothing on document or below
|
||||
* (host shortcuts, dismiss-on-Escape) ever sees the key.
|
||||
*/
|
||||
function handleHostKeyGuard(event) {
|
||||
if (isInsideEditorUi(event.target) || getEditing()?.targetElement?.contains(event.target)) {
|
||||
// Dismissable layers honour `defaultPrevented`, so an Escape aimed at the editor stays with it.
|
||||
if (event.key === "Escape") event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function handleEditorFocusGuard(event) {
|
||||
if (isInsideEditorUi(event.target) || isInsideEditorUi(event.relatedTarget)) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Enable / disable edit mode
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function enableEditMode() {
|
||||
// Guard React reconciliation before any inline edit rewrites DOM subtrees.
|
||||
patchReactDomMutations();
|
||||
|
||||
document.getElementById("root")?.setAttribute("data-edit-mode-enabled", "true");
|
||||
|
||||
if (!globalClickHandler) {
|
||||
globalClickHandler = handleClick;
|
||||
document.addEventListener("click", globalClickHandler, true);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleHistoryKeydown, true);
|
||||
window.addEventListener("keydown", handleSelectionKeydown, true);
|
||||
window.addEventListener("keydown", handleHostKeyGuard, true);
|
||||
window.addEventListener("keyup", handleHostKeyGuard, true);
|
||||
|
||||
document.addEventListener("mousemove", handleTypeTooltipMouseMove, true);
|
||||
document.addEventListener("mousedown", handleTypeTooltipMouseDown, true);
|
||||
document.addEventListener("dragstart", hideTypeTooltipImmediately, true);
|
||||
document.addEventListener("mouseleave", hideTypeTooltipImmediately);
|
||||
document.addEventListener("scroll", handleTypeTooltipScroll, true);
|
||||
window.addEventListener("blur", hideTypeTooltipImmediately);
|
||||
|
||||
document.addEventListener("mousedown", handleSelectMouseDown, true);
|
||||
document.addEventListener("mousemove", handleSelectMouseMove, true);
|
||||
document.addEventListener("mouseup", handleSelectMouseUp, true);
|
||||
// A mouseup outside the iframe never reaches us, so the band would stay up.
|
||||
window.addEventListener("blur", cancelDragSelect);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
window.addEventListener("pointerdown", handleModalPointerDownGuard, true);
|
||||
window.addEventListener("pointerup", handleModalPointerDownGuard, true);
|
||||
window.addEventListener("focusin", handleEditorFocusGuard, true);
|
||||
window.addEventListener("focusout", handleEditorFocusGuard, true);
|
||||
|
||||
if (!annotationDomObserver && typeof MutationObserver !== "undefined") {
|
||||
annotationDomObserver = new MutationObserver(scheduleAnnotationDomSync);
|
||||
annotationDomObserver.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() });
|
||||
}
|
||||
|
||||
function disableEditMode() {
|
||||
const editing = getEditing();
|
||||
if (editing?.targetElement?.hasAttribute("contenteditable")) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
|
||||
document.getElementById("root")?.removeAttribute("data-edit-mode-enabled");
|
||||
|
||||
currentImageEdit = null;
|
||||
hideAnnotationPanel();
|
||||
annotationDomObserver?.disconnect();
|
||||
annotationDomObserver = null;
|
||||
if (annotationDomSyncFrameId !== null) {
|
||||
cancelAnimationFrame(annotationDomSyncFrameId);
|
||||
annotationDomSyncFrameId = null;
|
||||
}
|
||||
clearAllAnnotationMarkers();
|
||||
setComments([]);
|
||||
|
||||
if (globalClickHandler) {
|
||||
document.removeEventListener("click", globalClickHandler, true);
|
||||
globalClickHandler = null;
|
||||
}
|
||||
|
||||
window.removeEventListener("keydown", handleHistoryKeydown, true);
|
||||
window.removeEventListener("keydown", handleSelectionKeydown, true);
|
||||
window.removeEventListener("keydown", handleHostKeyGuard, true);
|
||||
window.removeEventListener("keyup", handleHostKeyGuard, true);
|
||||
|
||||
document.removeEventListener("mousemove", handleTypeTooltipMouseMove, true);
|
||||
document.removeEventListener("mousedown", handleTypeTooltipMouseDown, true);
|
||||
document.removeEventListener("dragstart", hideTypeTooltipImmediately, true);
|
||||
document.removeEventListener("mouseleave", hideTypeTooltipImmediately);
|
||||
document.removeEventListener("scroll", handleTypeTooltipScroll, true);
|
||||
window.removeEventListener("blur", hideTypeTooltipImmediately);
|
||||
|
||||
document.removeEventListener("mousedown", handleSelectMouseDown, true);
|
||||
document.removeEventListener("mousemove", handleSelectMouseMove, true);
|
||||
document.removeEventListener("mouseup", handleSelectMouseUp, true);
|
||||
window.removeEventListener("blur", cancelDragSelect);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("pointerdown", handleModalPointerDownGuard, true);
|
||||
window.removeEventListener("pointerup", handleModalPointerDownGuard, true);
|
||||
window.removeEventListener("focusin", handleEditorFocusGuard, true);
|
||||
window.removeEventListener("focusout", handleEditorFocusGuard, true);
|
||||
endDragSelect();
|
||||
destroySelectionRect();
|
||||
resetMultiSelectState();
|
||||
unlockHoverOutline();
|
||||
hideTypeTooltipImmediately();
|
||||
suppressTypeTooltipUntilMove = false;
|
||||
lastRawHitTestElement = null;
|
||||
lastResolvedTypeTooltipTarget = null;
|
||||
|
||||
discardAllEdits();
|
||||
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Parent <-> iframe messaging
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
if (!ALLOWED_PARENT_ORIGINS.includes(event.origin)) return;
|
||||
|
||||
if (event.data?.type === ChildMessage.ENABLE_EDIT_MODE) {
|
||||
if (event.data?.translations) {
|
||||
setEditorTranslations(event.data.translations);
|
||||
}
|
||||
enableEditMode();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DISABLE_EDIT_MODE) {
|
||||
disableEditMode();
|
||||
}
|
||||
|
||||
// Text editing now happens in-iframe via contenteditable; `edit-save` only
|
||||
// remains to keep the existing parent-driven image picker working.
|
||||
if (event.data?.type === ChildMessage.EDIT_SAVE) {
|
||||
handleImageEditSave(event.data?.payload?.newText);
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.EDIT_CANCEL) {
|
||||
clearImageEdit();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_SAVE) {
|
||||
handleDraftSave();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_DISCARD) {
|
||||
handleDraftDiscard();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_RESTORE) {
|
||||
applyDraftSnapshot(event.data?.payload?.snapshot, { onMarkerClick: openPanelForEdit });
|
||||
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState(), isDraftRestored: true });
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_SNAPSHOT_REQUEST) {
|
||||
notifyDraftStateChanged();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.EDIT_UNDO) {
|
||||
applySessionUndo();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.EDIT_REDO) {
|
||||
applySessionRedo();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.ANNOTATION_IMAGE_ATTACHED) {
|
||||
addPendingAttachments(event.data?.payload?.attachments);
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.ANNOTATION_OPEN_FOR_IMAGE) {
|
||||
if (!currentImageEdit) return;
|
||||
const { targetElement } = currentImageEdit;
|
||||
clearImageEdit();
|
||||
|
||||
const clickX = (window.innerWidth - ANNOTATION_PANEL_WIDTH) / 2;
|
||||
const clickY = (window.innerHeight - ANNOTATION_PANEL_ESTIMATED_HEIGHT) / 2 - PANEL_GAP;
|
||||
openOrCreateAnnotation(targetElement, clickX, clickY);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user