Initial commit for Coolify deploy

This commit is contained in:
2026-10-01 12:09:02 -04:00
commit bba8a44653
111 changed files with 23160 additions and 0 deletions
@@ -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);
}
});