Clean deploy: nueva versión
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* A reference attachment the user added to a comment.
|
||||
* @typedef {{ url: string, fileName: string, size: number, type: 'image' }} AnnotationAttachment
|
||||
*/
|
||||
|
||||
/**
|
||||
* A comment always targets a group: `elements` and `selections` are parallel,
|
||||
* one entry per annotated element. A single annotation is just a group of one.
|
||||
* @typedef {{
|
||||
* elements: HTMLElement[],
|
||||
* selections: object[],
|
||||
* text: string,
|
||||
* attachments: AnnotationAttachment[],
|
||||
* clickX: number,
|
||||
* clickY: number,
|
||||
* fixed: boolean,
|
||||
* marker?: HTMLElement,
|
||||
* }} AnnotationComment
|
||||
*/
|
||||
|
||||
let _editorTranslations = {};
|
||||
/** @type {AnnotationComment[]} */
|
||||
let _comments = [];
|
||||
let _panelMode = 'create';
|
||||
let _editingComment = null;
|
||||
/** @type {HTMLElement[]} */
|
||||
let _pendingElements = [];
|
||||
/** @type {object[]} */
|
||||
let _pendingSelections = [];
|
||||
/** @type {AnnotationAttachment[]} */
|
||||
let _pendingAttachments = [];
|
||||
let _annotationClickX = 0;
|
||||
let _annotationClickY = 0;
|
||||
|
||||
export function getComments() { return _comments; }
|
||||
export function setComments(value) { _comments = value; }
|
||||
|
||||
export function getPanelMode() { return _panelMode; }
|
||||
export function setPanelMode(value) { _panelMode = value; }
|
||||
|
||||
export function getEditingComment() { return _editingComment; }
|
||||
export function setEditingComment(value) { _editingComment = value; }
|
||||
|
||||
export function getPendingElements() { return _pendingElements; }
|
||||
export function setPendingElements(value) { _pendingElements = value ?? []; }
|
||||
|
||||
export function getPendingSelections() { return _pendingSelections; }
|
||||
export function setPendingSelections(value) { _pendingSelections = value ?? []; }
|
||||
|
||||
export function getPendingAttachments() { return _pendingAttachments; }
|
||||
export function setPendingAttachments(value) { _pendingAttachments = value ?? []; }
|
||||
|
||||
export function getAnnotationClickX() { return _annotationClickX; }
|
||||
export function setAnnotationClickX(value) { _annotationClickX = value; }
|
||||
|
||||
export function getAnnotationClickY() { return _annotationClickY; }
|
||||
export function setAnnotationClickY(value) { _annotationClickY = value; }
|
||||
|
||||
export function getEditorTranslations() { return _editorTranslations; }
|
||||
export function setEditorTranslations(value) { _editorTranslations = value; }
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Active inline edit session; `null` when no element is being edited. */
|
||||
let current = null;
|
||||
|
||||
/**
|
||||
* @returns {{ editId: string, targetElement: HTMLElement, sessionId: number, originalContent: string,
|
||||
* originalTextAlign: string, originalInlineStyles: object,
|
||||
* savedChildren: Array, hasSoftSaved?: boolean }|null}
|
||||
*/
|
||||
export function getEditing() {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ editId: string, targetElement: HTMLElement, originalContent: string,
|
||||
* originalTextAlign: string, originalInlineStyles: object,
|
||||
* savedChildren: Array }} info
|
||||
*/
|
||||
export function setEditing(info) {
|
||||
current = info;
|
||||
}
|
||||
|
||||
/** Clears the active edit session (called on commit or discard). */
|
||||
export function clearEditing() {
|
||||
current = null;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { sanitizeText, patchRemoveChild } from "../utils/html-utils.js";
|
||||
import { getComments } from "./annotation-state.js";
|
||||
import { getEditId } from "../constants/selectors.js";
|
||||
|
||||
/** Maximum undo/redo entries; oldest entry is dropped when the log exceeds this. */
|
||||
const MAX_HISTORY = 25;
|
||||
|
||||
/** Committed and redoable edit actions for the current session. */
|
||||
const actionLog = [];
|
||||
|
||||
/** Index of the last committed entry in `actionLog`; -1 when nothing is committed. */
|
||||
let actionPointer = -1;
|
||||
|
||||
/** Monotonic id assigned at the start of each inline edit session. */
|
||||
let nextEditSessionId = 1;
|
||||
|
||||
/** @returns {number} A new session id for the next inline edit burst. */
|
||||
export function beginEditSession() {
|
||||
return nextEditSessionId++;
|
||||
}
|
||||
|
||||
/** @returns {number} index of the last committed entry; -1 if nothing committed */
|
||||
export function getActionPointer() {
|
||||
return actionPointer;
|
||||
}
|
||||
|
||||
/** @returns {number} total entries in the log, including undone future entries */
|
||||
export function getActionLogLength() {
|
||||
return actionLog.length;
|
||||
}
|
||||
|
||||
/** Wipes all undo/redo history (e.g. on discard or new session). */
|
||||
export function clearHistory() {
|
||||
actionLog.length = 0;
|
||||
actionPointer = -1;
|
||||
nextEditSessionId = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable deduplication key for an action.
|
||||
* Direct edits target one source location (AST writer), so they key by editId only.
|
||||
* Assisted edits can target separate rendered instances of the same JSX expression,
|
||||
* so they also include the captured selector.
|
||||
* @param {{ editId: string, attribute?: string|null, isAssisted?: boolean, selectionMode?: object|null }} action
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getActionKey(action) {
|
||||
const attributeSuffix = action.attribute ? `@${action.attribute}` : '';
|
||||
const selector = action.isAssisted ? action.selectionMode.selector : null;
|
||||
const instanceSuffix = selector ? `@selector:${selector}` : '';
|
||||
return `${action.editId}${attributeSuffix}${instanceSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain text from a stored HTML snapshot (beforeContent), for matching mapped
|
||||
* siblings that share one edit id.
|
||||
* @param {string|null|undefined} html
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function plainTextFromStoredHtml(html) {
|
||||
if (typeof html !== "string" || !html) return null;
|
||||
const text = html.replace(/<[^>]*>/g, "").trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the DOM element for an action. Prefers the stored element reference
|
||||
* (survives HMR position shifts). When several nodes share one JSX edit id
|
||||
* (e.g. letter spans from `.map()`), disambiguates with selectionMode text /
|
||||
* beforeContent — same approach as annotation `resolveByEditId`. Never falls
|
||||
* back to the first querySelector match when duplicates exist.
|
||||
* @param {object} action
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function resolveElement(action) {
|
||||
if (action.element?.isConnected) return action.element;
|
||||
|
||||
const matches = Array.from(document.querySelectorAll(
|
||||
`[data-edit-id="${action.editId}"], [data-edit-assisted-id="${action.editId}"]`,
|
||||
));
|
||||
if (matches.length <= 1) return matches[0] ?? null;
|
||||
|
||||
const text = action.selectionMode?.textContent?.trim()
|
||||
|| plainTextFromStoredHtml(action.instruction?.beforeContent);
|
||||
if (!text) return null;
|
||||
|
||||
return matches.find((match) => match.textContent?.trim() === text) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the DOM element for an edit and writes a value to it (used by undo/redo).
|
||||
* @param {object} action - The history action entry (has element ref + editId).
|
||||
* @param {string} value
|
||||
*/
|
||||
function applyValueToElement(action, value) {
|
||||
const element = resolveElement(action);
|
||||
if (!element) return;
|
||||
|
||||
if (action.attribute) {
|
||||
const domAttribute = action.attribute === 'to' ? 'href' : action.attribute;
|
||||
if (value === null) {
|
||||
element.removeAttribute(domAttribute);
|
||||
} else {
|
||||
element.setAttribute(domAttribute, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.tagName.toLowerCase() === "img") {
|
||||
element.setAttribute("src", value);
|
||||
} else {
|
||||
patchRemoveChild(element);
|
||||
element.innerHTML = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies element-level inline style props for undo/redo. Setting a prop to ''
|
||||
* removes the inline value so CSS-class styling takes over again.
|
||||
* @param {object} action - The history action entry.
|
||||
* @param {object|null} styleProperties - camelCase property → value (or '' to clear)
|
||||
*/
|
||||
function applyStyleToElement(action, styleProperties) {
|
||||
if (!styleProperties) return;
|
||||
const element = resolveElement(action);
|
||||
if (!element) return;
|
||||
for (const [key, value] of Object.entries(styleProperties)) {
|
||||
element.style[key] = value || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an edit, coalescing same-session changes to the same element into one
|
||||
* undo/redo step. Attribute edits (e.g. href) are keyed separately via {@link getActionKey}.
|
||||
*
|
||||
* @param {string} editId
|
||||
* @param {{ beforeContent: string|null, afterContent: string|null }} instruction - Internal undo/redo snapshot.
|
||||
* @param {object} [options]
|
||||
* @param {object|null} [options.style] - Net element-level style props applied.
|
||||
* @param {object|null} [options.oldStyle] - Previous inline values of those props ('' = unset).
|
||||
* @param {string|null} [options.attribute]
|
||||
* @param {HTMLElement|null} [options.element]
|
||||
* @param {number|null} [options.sessionId] - Inline edit session; coalesces only when equal.
|
||||
* @param {boolean} [options.isAssisted] - True when the source-writer can't safely apply this edit and the AI must.
|
||||
* @param {object|null} [options.selectionMode] - Rich DOM context captured before mutation (see `captureElementMetadata`).
|
||||
*/
|
||||
export function recordEdit(editId, instruction, { style = null, oldStyle = null, attribute = null, element = null, sessionId = null, isAssisted = false, selectionMode = null } = {}) {
|
||||
const composite = getActionKey({ editId, attribute, isAssisted, selectionMode });
|
||||
const lastForEdit = [...actionLog].slice(0, actionPointer + 1).findLast(action => getActionKey(action) === composite);
|
||||
if (lastForEdit && lastForEdit.instruction.afterContent === instruction.afterContent && !style) return;
|
||||
|
||||
if (lastForEdit
|
||||
&& actionLog.lastIndexOf(lastForEdit) === actionPointer
|
||||
&& lastForEdit.sessionId === sessionId) {
|
||||
lastForEdit.instruction = { ...lastForEdit.instruction, afterContent: instruction.afterContent };
|
||||
if (style) {
|
||||
lastForEdit.style = { ...lastForEdit.style, ...style };
|
||||
lastForEdit.oldStyle = { ...oldStyle, ...lastForEdit.oldStyle };
|
||||
}
|
||||
if (element) lastForEdit.element = element;
|
||||
return;
|
||||
}
|
||||
|
||||
actionLog.length = actionPointer + 1;
|
||||
actionLog.push({ editId, instruction, style, oldStyle, attribute, element, sessionId, isAssisted, selectionMode });
|
||||
|
||||
if (actionLog.length > MAX_HISTORY) {
|
||||
actionLog.shift();
|
||||
}
|
||||
|
||||
actionPointer = actionLog.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies every committed edit's content/attribute/style to its element,
|
||||
* for entries whose stored element reference went stale (a route change or
|
||||
* React re-render replaced it with a freshly-mounted, un-edited node).
|
||||
*/
|
||||
export function reapplyCommittedEdits() {
|
||||
for (let index = 0; index <= actionPointer; index++) {
|
||||
const action = actionLog[index];
|
||||
if (action.element?.isConnected) continue;
|
||||
|
||||
const element = resolveElement(action);
|
||||
if (!element) continue;
|
||||
|
||||
action.element = element;
|
||||
applyValueToElement(action, action.instruction.afterContent);
|
||||
if (action.style) applyStyleToElement(action, action.style);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts the most recent committed edit (content and element-level styles)
|
||||
* and moves the pointer back.
|
||||
* @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null}
|
||||
*/
|
||||
export function undo() {
|
||||
if (actionPointer < 0) return null;
|
||||
const action = actionLog[actionPointer];
|
||||
applyValueToElement(action, action.instruction.beforeContent);
|
||||
if (action.style) {
|
||||
const revert = {};
|
||||
for (const key of Object.keys(action.style)) {
|
||||
revert[key] = action.oldStyle?.[key] ?? '';
|
||||
}
|
||||
applyStyleToElement(action, revert);
|
||||
}
|
||||
actionPointer--;
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies the next undone edit (content and element-level styles) and
|
||||
* advances the pointer.
|
||||
* @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null}
|
||||
*/
|
||||
export function redo() {
|
||||
if (actionPointer >= actionLog.length - 1) return null;
|
||||
actionPointer++;
|
||||
const action = actionLog[actionPointer];
|
||||
applyValueToElement(action, action.instruction.afterContent);
|
||||
applyStyleToElement(action, action.style);
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the committed log into one draft per target: the first entry for a key
|
||||
* captures the original instruction/style, later entries update `afterContent`/style.
|
||||
* @returns {Map<string, { editId: string, attribute: string|null, instruction: { beforeContent: string, afterContent: string }, originalStyle: Record<string, string>, currentStyle: Record<string, string>, isAssisted: boolean, selectionMode: object|null }>}
|
||||
*/
|
||||
function collectEditDrafts() {
|
||||
const draftsByKey = new Map();
|
||||
for (let index = 0; index <= actionPointer; index++) {
|
||||
const action = actionLog[index];
|
||||
const key = getActionKey(action);
|
||||
let draft = draftsByKey.get(key);
|
||||
if (!draft) {
|
||||
draft = {
|
||||
editId: action.editId,
|
||||
attribute: action.attribute ?? null,
|
||||
instruction: {
|
||||
beforeContent: action.instruction.beforeContent,
|
||||
afterContent: action.instruction.afterContent,
|
||||
},
|
||||
originalStyle: {},
|
||||
currentStyle: {},
|
||||
isAssisted: action.isAssisted ?? false,
|
||||
selectionMode: action.selectionMode ?? null,
|
||||
};
|
||||
draftsByKey.set(key, draft);
|
||||
}
|
||||
draft.instruction.afterContent = action.instruction.afterContent;
|
||||
for (const [property, value] of Object.entries(action.style ?? {})) {
|
||||
if (!(property in draft.originalStyle)) {
|
||||
draft.originalStyle[property] = action.oldStyle?.[property] ?? '';
|
||||
}
|
||||
draft.currentStyle[property] = value;
|
||||
}
|
||||
}
|
||||
return draftsByKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses the committed log into one net edit per target, shaped like the wire
|
||||
* `VisualEditItem`, keyed separately for source persistence and history aggregation.
|
||||
* Content, attribute, and individual style-property changes are emitted atomically
|
||||
* so downstream consumers do not have to infer what a before/after pair represents.
|
||||
* @returns {Record<string, { editId: string, instruction: { changes: Array<{ kind: string, target: string|null, before: string|null, after: string|null }> }, isAssisted: boolean, selectionMode: object|null }>}
|
||||
*/
|
||||
export function getCurrentEdits() {
|
||||
const edits = {};
|
||||
for (const [key, draft] of collectEditDrafts()) {
|
||||
const changes = [];
|
||||
const beforeContent = draft.attribute
|
||||
? draft.instruction.beforeContent ?? null
|
||||
: sanitizeText(draft.instruction.beforeContent);
|
||||
const afterContent = draft.attribute
|
||||
? draft.instruction.afterContent ?? null
|
||||
: sanitizeText(draft.instruction.afterContent);
|
||||
|
||||
if (beforeContent !== afterContent) {
|
||||
changes.push({
|
||||
kind: draft.attribute ? 'attribute' : 'content',
|
||||
target: draft.attribute,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
});
|
||||
}
|
||||
|
||||
for (const property of Object.keys(draft.currentStyle)) {
|
||||
if ((draft.currentStyle[property] ?? '') !== (draft.originalStyle[property] ?? '')) {
|
||||
changes.push({
|
||||
kind: 'style',
|
||||
target: property,
|
||||
before: draft.originalStyle[property] || null,
|
||||
after: draft.currentStyle[property] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!changes.length) continue;
|
||||
|
||||
const edit = {
|
||||
editId: draft.editId,
|
||||
instruction: { changes },
|
||||
isAssisted: !!draft.isAssisted,
|
||||
selectionMode: draft.selectionMode,
|
||||
};
|
||||
edits[key] = edit;
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes the current, uncommitted edit set — used to report which kinds of
|
||||
* changes a save actually carries, as opposed to which edit surfaces were merely
|
||||
* opened during the session (a text/image edit entered and then reverted leaves
|
||||
* no trace here).
|
||||
* @param {ReturnType<typeof getCurrentEdits>} edits
|
||||
* @param {ReturnType<typeof getComments>} annotations
|
||||
* @returns {string[]} distinct categories: "text" | "image" | "annotation"
|
||||
*/
|
||||
function getEditTypes(edits, annotations) {
|
||||
const editTypes = new Set();
|
||||
for (const edit of Object.values(edits)) {
|
||||
const isImageEdit = edit.instruction.changes.some(change => change.kind === 'attribute' && change.target === 'src');
|
||||
editTypes.add(isImageEdit ? 'image' : 'text');
|
||||
}
|
||||
if (annotations.length) editTypes.add('annotation');
|
||||
return [...editTypes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo availability plus the count of distinct elements that differ from their original,
|
||||
* including elements that only carry an annotation (no history edit).
|
||||
* @returns {{ canUndo: boolean, canRedo: boolean, editedElementsCount: number, editTypes: string[] }}
|
||||
*/
|
||||
export function getEditState() {
|
||||
const edits = getCurrentEdits();
|
||||
const annotations = getComments();
|
||||
|
||||
const changedElementIds = new Set(
|
||||
Object.values(edits).map(edit => getActionKey({ ...edit, attribute: null })),
|
||||
);
|
||||
|
||||
annotations.forEach((annotation, annotationIndex) => {
|
||||
if (!annotation.elements.length) {
|
||||
changedElementIds.add(`annotation-${annotationIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
annotation.elements.forEach((element, elementIndex) => {
|
||||
changedElementIds.add(getEditId(element) || `annotation-${annotationIndex}-${elementIndex}`);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
canUndo: actionPointer >= 0,
|
||||
canRedo: actionPointer < actionLog.length - 1,
|
||||
editedElementsCount: changedElementIds.size,
|
||||
editTypes: getEditTypes(edits, annotations),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the full undo/redo stack for persistence across reloads.
|
||||
* Strips non-serializable DOM element refs.
|
||||
* @returns {{ actionLog: object[], actionPointer: number, nextEditSessionId: number }}
|
||||
*/
|
||||
export function exportDraftSnapshot() {
|
||||
return {
|
||||
actionLog: actionLog.map(({ element: _element, ...action }) => ({
|
||||
...action,
|
||||
instruction: { ...action.instruction },
|
||||
style: action.style ? { ...action.style } : null,
|
||||
oldStyle: action.oldStyle ? { ...action.oldStyle } : null,
|
||||
selectionMode: action.selectionMode ? structuredClone(action.selectionMode) : null,
|
||||
})),
|
||||
actionPointer,
|
||||
nextEditSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the undo/redo stack from a persisted snapshot and re-applies committed
|
||||
* edits to the current DOM. Skips actions whose elements cannot be resolved.
|
||||
* @param {{ actionLog?: object[], actionPointer?: number, nextEditSessionId?: number }|null|undefined} snapshot
|
||||
* @returns {{ restoredCount: number, skippedCount: number }}
|
||||
*/
|
||||
export function importDraftSnapshot(snapshot) {
|
||||
clearHistory();
|
||||
|
||||
if (!snapshot || !Array.isArray(snapshot.actionLog)) {
|
||||
return { restoredCount: 0, skippedCount: 0 };
|
||||
}
|
||||
|
||||
let restoredCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const action of snapshot.actionLog) {
|
||||
if (!action?.editId || !action?.instruction) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
actionLog.push({
|
||||
editId: action.editId,
|
||||
instruction: {
|
||||
beforeContent: action.instruction.beforeContent ?? null,
|
||||
afterContent: action.instruction.afterContent ?? null,
|
||||
},
|
||||
style: action.style ?? null,
|
||||
oldStyle: action.oldStyle ?? null,
|
||||
attribute: action.attribute ?? null,
|
||||
element: null,
|
||||
sessionId: action.sessionId ?? null,
|
||||
isAssisted: !!action.isAssisted,
|
||||
selectionMode: action.selectionMode ?? null,
|
||||
});
|
||||
restoredCount++;
|
||||
}
|
||||
|
||||
const maxPointer = actionLog.length - 1;
|
||||
const requestedPointer = typeof snapshot.actionPointer === 'number' ? snapshot.actionPointer : maxPointer;
|
||||
actionPointer = Math.min(Math.max(requestedPointer, -1), maxPointer);
|
||||
nextEditSessionId = typeof snapshot.nextEditSessionId === 'number' && snapshot.nextEditSessionId > 0
|
||||
? snapshot.nextEditSessionId
|
||||
: 1;
|
||||
|
||||
reapplyCommittedEdits();
|
||||
|
||||
for (let index = 0; index <= actionPointer; index++) {
|
||||
if (!actionLog[index].element) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { restoredCount, skippedCount };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Group selection built by a rubber-band drag or shift+click. The committed
|
||||
* group is document-ordered and never nested; `_drag*` lives for one gesture.
|
||||
*/
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
let _selectedElements = [];
|
||||
let _dragStartX = null;
|
||||
let _dragStartY = null;
|
||||
let _isDragging = false;
|
||||
/** Set on mouseup so the synthetic click a drag leaves behind is swallowed. */
|
||||
let _hadDrag = false;
|
||||
|
||||
export function getSelectedElements() { return _selectedElements; }
|
||||
export function setSelectedElements(value) { _selectedElements = value; }
|
||||
|
||||
export function getDragStartX() { return _dragStartX; }
|
||||
export function getDragStartY() { return _dragStartY; }
|
||||
export function setDragStart(x, y) { _dragStartX = x; _dragStartY = y; }
|
||||
export function clearDragStart() { _dragStartX = null; _dragStartY = null; }
|
||||
|
||||
export function getIsDragging() { return _isDragging; }
|
||||
export function setIsDragging(value) { _isDragging = value; }
|
||||
|
||||
export function getHadDrag() { return _hadDrag; }
|
||||
export function setHadDrag(value) { _hadDrag = value; }
|
||||
|
||||
/** True when a mousedown is pending and could still become a drag. */
|
||||
export function isDragPending() { return _dragStartX !== null; }
|
||||
|
||||
/** Drops the current group without touching an in-flight gesture. */
|
||||
export function clearSelectedElements() { _selectedElements = []; }
|
||||
|
||||
/**
|
||||
* Drops members that have left the page, so a group never outlives the route it
|
||||
* was made on or holds nodes a re-render replaced.
|
||||
* @returns {boolean} True when the group changed.
|
||||
*/
|
||||
export function pruneDetachedSelection() {
|
||||
const connected = _selectedElements.filter(element => element.isConnected);
|
||||
if (connected.length === _selectedElements.length) return false;
|
||||
|
||||
_selectedElements = connected;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Resets everything, including any gesture in progress. */
|
||||
export function resetMultiSelectState() {
|
||||
_selectedElements = [];
|
||||
_dragStartX = null;
|
||||
_dragStartY = null;
|
||||
_isDragging = false;
|
||||
_hadDrag = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes and puts the group in document order, which fixes the marker's anchor
|
||||
* element and the order selections reach the AI.
|
||||
* @param {HTMLElement[]} elements
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
export function orderSelection(elements) {
|
||||
return [...new Set(elements)]
|
||||
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the element to the group, or removes it when already a member. Nesting is
|
||||
* allowed: picking a parent on top of its children is an explicit choice.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {HTMLElement[]} The new group.
|
||||
*/
|
||||
export function toggleSelectedElement(element) {
|
||||
_selectedElements = _selectedElements.includes(element)
|
||||
? _selectedElements.filter(member => member !== element)
|
||||
: orderSelection([..._selectedElements, element]);
|
||||
return _selectedElements;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** @enum {string} Unique identifier for each action panel. */
|
||||
export const PanelId = Object.freeze({
|
||||
COLOR: 'color',
|
||||
FONT: 'font',
|
||||
SIZE: 'size',
|
||||
LINK: 'link',
|
||||
});
|
||||
|
||||
/**
|
||||
* Tracks whether each panel is currently open.
|
||||
* @type {Map<string, boolean>}
|
||||
*/
|
||||
const visibility = new Map();
|
||||
|
||||
/**
|
||||
* The hide function supplied by each panel at registration time.
|
||||
* @type {Map<string, () => void>}
|
||||
*/
|
||||
const hideFunctions = new Map();
|
||||
|
||||
/**
|
||||
* Register an action panel so it participates in bulk show/hide operations.
|
||||
* Called once at module load time by each panel.
|
||||
* @param {{ id: string, hide: () => void }} panel
|
||||
*/
|
||||
export function registerPanel({ id, hide }) {
|
||||
hideFunctions.set(id, hide);
|
||||
visibility.set(id, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the visibility state for a panel.
|
||||
* Called by the panel's own show/hide functions.
|
||||
* @param {string} id - A {@link PanelId} value.
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
export function setPanelVisible(id, visible) {
|
||||
visibility.set(id, visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id - A {@link PanelId} value.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPanelVisible(id) {
|
||||
return visibility.get(id) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a single panel by ID.
|
||||
* @param {string} id - A {@link PanelId} value.
|
||||
*/
|
||||
export function hidePanelById(id) {
|
||||
hideFunctions.get(id)?.();
|
||||
}
|
||||
|
||||
/** Hide every registered panel. */
|
||||
export function hideAllPanels() {
|
||||
for (const id of hideFunctions.keys()) hidePanelById(id);
|
||||
}
|
||||
|
||||
/** @returns {boolean} True if at least one panel is currently visible. */
|
||||
export function anyPanelVisible() {
|
||||
return [...visibility.values()].some(Boolean);
|
||||
}
|
||||
Reference in New Issue
Block a user