import { getEditing, setEditing, clearEditing } from "../../state/editing-state.js"; import { normalizeContentEditableHtml, patchRemoveChild, serializeContentEditableHtml } from "../../utils/html-utils.js"; import { postToParent } from "../../utils/parent-frame.js"; import { ParentMessage } from "../../constants/messages.js"; import { beginEditSession, recordEdit } from "../../state/history-state.js"; import { notifyDraftStateChanged } from "../../api/draft-snapshot.js"; import { showTextFormatToolbar, hideTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js"; import { onSelectionChange } from "./cursor-styles.js"; import { captureElementMetadata } from "../../utils/selection-mode-metadata.js"; /** * Converts a CSS property name to its camelCase JS form * (e.g. `background-clip` → `backgroundClip`), matching the keys used by the * JSX style objects in the source write-back. */ function cssPropertyToCamelCase(cssProperty) { return cssProperty.replace(/^-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); } const TRANSPARENT_TEXT_CARET_COLOR = "#6B7280"; function isTransparentColor(color) { if (!color || color === "transparent") return true; const match = color.replace(/\s+/g, "").match(/^rgba?\(([^)]+)\)$/i); if (!match) return false; const segments = match[1].split(","); return segments.length === 4 && parseFloat(segments[3]) === 0; } /** * Reports whether the element or any descendant renders transparent text * (e.g. gradient text via `background-clip: text` + `color: transparent`), * where the caret would otherwise be invisible. * @param {HTMLElement} element * @returns {boolean} */ function hasTransparentText(element) { if (isTransparentColor(getComputedStyle(element).color)) return true; return Array.from(element.querySelectorAll("*")).some( descendant => isTransparentColor(getComputedStyle(descendant).color) ); } /** `caret-color: currentColor` is invisible on transparent/gradient text, so use a visible caret there. */ function applyEditingCaretColor(element) { if (hasTransparentText(element)) { element.style.caretColor = TRANSPARENT_TEXT_CARET_COLOR; } else { element.style.removeProperty("caret-color"); } } /** * Snapshots every style property set inline on the element itself. * @param {HTMLElement} element * @returns {Record} camelCase property → value */ function captureInlineStyles(element) { const styles = {}; for (let index = 0; index < element.style.length; index++) { const cssProperty = element.style.item(index); styles[cssPropertyToCamelCase(cssProperty)] = element.style.getPropertyValue(cssProperty); } return styles; } /** * Diffs the element's current inline styles against a snapshot, covering any * panel action that styles the element itself ('' marks a removed property). * `textAlign` is excluded here — it is compared against computed style instead, * so re-applying an alignment inherited from CSS is not logged as a change. * @param {HTMLElement} element * @param {Record} original * @returns {{ changed: Record, previous: Record }|null} */ function getInlineStyleDiff(element, original) { const current = captureInlineStyles(element); const changed = {}; const previous = {}; for (const key of new Set([...Object.keys(current), ...Object.keys(original)])) { if (key === 'textAlign') continue; // Transient editing caret-color must never be written back to source. if (key === 'caretColor') continue; if ((current[key] ?? '') !== (original[key] ?? '')) { changed[key] = current[key] ?? ''; previous[key] = original[key] ?? ''; } } return Object.keys(changed).length > 0 ? { changed, previous } : null; } /** * Computes element-level style changes (alignment plus any other inline style * the panel set on the element) since the edit session's last snapshot. * @param {object} editing - Current editing state. * @returns {{ style: object|null }} */ function buildStyleDiff(editing) { const { targetElement, originalTextAlign, originalInlineTextAlign, originalInlineStyles } = editing; const alignChanged = getComputedStyle(targetElement).textAlign !== originalTextAlign; const inlineDiff = getInlineStyleDiff(targetElement, originalInlineStyles); if (!alignChanged && !inlineDiff) return { style: null, oldStyle: null }; const style = {}; const oldStyle = {}; if (alignChanged) { style.textAlign = targetElement.style.textAlign; // Keep previous inline value around so re-applying the inherited default still serializes. if (!style.textAlign) style.textAlign = originalInlineTextAlign ?? ''; oldStyle.textAlign = originalInlineTextAlign ?? ''; } if (inlineDiff) { Object.assign(style, inlineDiff.changed); Object.assign(oldStyle, inlineDiff.previous); } return { style, oldStyle }; } /** Re-snapshots the editing state's style baselines after a soft-save. */ function refreshStyleSnapshots(editing) { const { targetElement } = editing; editing.originalTextAlign = getComputedStyle(targetElement).textAlign; editing.originalInlineTextAlign = targetElement.style.textAlign || ''; editing.originalInlineStyles = captureInlineStyles(targetElement); } /** * Snapshots child nodes so DOM mutations during edit can be restored if needed. * @param {HTMLElement} element * @returns {Array<{ node: Node, value: string|null }>} */ export function saveChildNodes(element) { const saved = []; for (const child of element.childNodes) { saved.push({ node: child, value: child.nodeType === Node.TEXT_NODE ? child.nodeValue : null, }); } return saved; } /** * Places a collapsed caret at the end of the editable container's contents. * @param {HTMLElement} container */ export function placeCursorAtEnd(container) { if (!container.childNodes.length) { container.appendChild(document.createTextNode('')); } let target = container; while (target.lastChild) { target = target.lastChild; } const range = document.createRange(); if (target.nodeType === Node.TEXT_NODE) { range.setStart(target, target.textContent.length); } else { range.selectNodeContents(target); } range.collapse(false); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } /** * Places the text caret at viewport coordinates, clamped to the editable container. * @param {number} x * @param {number} y * @param {HTMLElement} [container] */ export function placeCursorAtPoint(x, y, container) { let range = null; if (document.caretPositionFromPoint) { const position = document.caretPositionFromPoint(x, y); if (position) { range = document.createRange(); range.setStart(position.offsetNode, position.offset); range.collapse(true); } } else if (document.caretRangeFromPoint) { range = document.caretRangeFromPoint(x, y); } // If the caret resolved outside our editable target (e.g. an absolute overlay // sat on top of the click point), fall back to placing it at the end of the // target so typing actually goes into the contenteditable. if (container && (!range || !container.contains(range.startContainer))) { range = document.createRange(); range.selectNodeContents(container); range.collapse(false); } if (!range) return; const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } /** * Enables contenteditable on the target and wires edit listeners and toolbar. * @param {HTMLElement} targetElement * @param {string} editId * @param {string} currentContent - Initial HTML content. * @param {{ focusTarget?: boolean }} [options] - Pass `focusTarget: false` when the caller focuses its own UI instead. */ export function startInlineEdit(targetElement, editId, currentContent, { focusTarget = true } = {}) { if (getEditing()?.targetElement === targetElement) return; const savedChildren = saveChildNodes(targetElement); const normalizedOriginal = normalizeContentEditableHtml(currentContent); const sessionId = beginEditSession(); const selectionMode = captureElementMetadata(targetElement); setEditing({ editId, targetElement, sessionId, originalContent: normalizedOriginal, originalTextAlign: getComputedStyle(targetElement).textAlign, originalInlineTextAlign: targetElement.style.textAlign || '', originalInlineStyles: captureInlineStyles(targetElement), savedChildren, selectionMode, }); document.getElementById("root")?.setAttribute("data-inline-editing", "true"); targetElement.setAttribute("data-editing", "true"); patchRemoveChild(targetElement); targetElement.setAttribute("contenteditable", "true"); applyEditingCaretColor(targetElement); if (focusTarget) { targetElement.focus(); placeCursorAtEnd(targetElement); requestAnimationFrame(() => { if (getEditing()?.targetElement !== targetElement) return; placeCursorAtEnd(targetElement); targetElement.focus(); }); } postToParent(ParentMessage.EDIT_ENTER, { currentText: currentContent, elementType: targetElement.tagName.toLowerCase(), isDirectlyEditable: targetElement.hasAttribute('data-edit-id'), }); showTextFormatToolbar(targetElement); targetElement.addEventListener("keydown", onInlineEditKeyDown); targetElement.addEventListener("beforeinput", onInlineEditBeforeInput); targetElement.addEventListener("input", onInlineEditInput); targetElement.addEventListener("tft-color-commit", saveCurrentEdit); document.addEventListener("selectionchange", onSelectionChange); setTimeout(onSelectionChange, 0); } /** * Soft-saves the current edit without tearing down contenteditable (e.g. color commit), * recording it into the in-iframe edit history so it can be undone/redone and * later batch-persisted on draft save. */ export function saveCurrentEdit() { const editing = getEditing(); if (!editing) return; const { editId, targetElement, originalContent, sessionId, selectionMode } = editing; const newHTML = serializeContentEditableHtml(targetElement); const { style, oldStyle } = buildStyleDiff(editing); const isAssisted = targetElement.hasAttribute('data-edit-assisted-id'); if (newHTML !== originalContent || style) { recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode }); notifyDraftStateChanged(); editing.originalContent = newHTML; editing.hasSoftSaved = true; refreshStyleSnapshots(editing); } } /** Finalizes the active inline edit, records it into history, and clears editing state. */ export function commitCurrentEdit() { const editing = getEditing(); if (!editing) return; const { editId, targetElement, originalContent, sessionId, selectionMode } = editing; const newHTML = serializeContentEditableHtml(targetElement); const { style, oldStyle } = buildStyleDiff(editing); const isAssisted = targetElement.hasAttribute('data-edit-assisted-id'); targetElement.removeAttribute("contenteditable"); targetElement.removeAttribute("data-editing"); targetElement.style.removeProperty("caret-color"); document.getElementById("root")?.removeAttribute("data-inline-editing"); targetElement.removeEventListener("keydown", onInlineEditKeyDown); targetElement.removeEventListener("beforeinput", onInlineEditBeforeInput); targetElement.removeEventListener("input", onInlineEditInput); targetElement.removeEventListener("tft-color-commit", saveCurrentEdit); document.removeEventListener("selectionchange", onSelectionChange); hideTextFormatToolbar(); patchRemoveChild(targetElement); targetElement.innerHTML = newHTML; const { hasSoftSaved } = editing; if (newHTML !== originalContent || style) { recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode }); notifyDraftStateChanged(); } else if (!hasSoftSaved) { postToParent(ParentMessage.EDIT_CANCEL, {}); } clearEditing(); } /** * Blocks browser replacement-text input during inline edit. * @param {InputEvent} e */ export function onInlineEditBeforeInput(event) { if (event.inputType === "insertReplacementText") { event.preventDefault(); return; } scheduleRepositionEditOverlays(); } /** Repositions the toolbar as the editable content grows/shrinks. */ export function onInlineEditInput() { if (!getEditing()) return; saveCurrentEdit(); scheduleRepositionEditOverlays(); } /** * Handles Escape/Enter to commit and stops propagation for other keys. * @param {KeyboardEvent} e */ export function onInlineEditKeyDown(event) { if (event.key === "Escape") { event.preventDefault(); commitCurrentEdit(); return; } if (event.key === "Enter") { if (event.shiftKey) { event.stopPropagation(); return; } event.preventDefault(); commitCurrentEdit(); return; } if (event.key === " " && event.currentTarget.tagName.toLowerCase() === "button") { // Native