chore(repository): reset repo to new version

This commit is contained in:
Andres Santana
2026-09-25 10:00:06 -04:00
commit 8e93ca8b2c
115 changed files with 22408 additions and 0 deletions
@@ -0,0 +1,93 @@
import { getEditing } from "../../state/editing-state.js";
import { stopFontSizeEdit, updateTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js";
import { hideAllPanels } from "../../state/panel-state.js";
import { resolveInlineStylesAtRange } from "../../utils/inline-style-state.js";
import { EDITOR_UI_SELECTOR } from "../../constants/selectors.js";
/**
* @param {HTMLElement} element
* @param {HTMLElement} container
* @returns {{ value: string, isGradient: boolean }}
*/
function resolveTextColor(element, container) {
const color = getComputedStyle(element).color;
if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
return { value: color, isGradient: false };
}
let current = element;
while (current && current !== container.parentElement) {
const computedStyle = getComputedStyle(current);
const backgroundImage = computedStyle.backgroundImage;
const backgroundClip = computedStyle.backgroundClip || computedStyle['webkitBackgroundClip'];
if (backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('gradient') && backgroundClip === 'text') {
return { value: backgroundImage, isGradient: true };
}
current = current.parentElement;
}
return { value: color, isGradient: false };
}
/**
* Resolves caret/selection presentation state for the formatting toolbar.
* Bold, italic, and underline are detected independently via ancestor walks
* so nested markup (`<em><u><strong>…</strong></u></em>`) reports all active styles.
* @returns {object|null}
*/
export function getStylesAtCursor() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
let node = range.startContainer;
if (node.nodeType === Node.ELEMENT_NODE) {
const child = node.childNodes[range.startOffset];
if (child) node = child;
}
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement;
}
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
node = getEditing()?.targetElement;
}
if (!node) return null;
const container = getEditing()?.targetElement || node;
const presentation = resolveInlineStylesAtRange(range, container);
const styles = getComputedStyle(node);
const containerStyles = getComputedStyle(container);
const resolvedColor = resolveTextColor(node, container);
return {
color: resolvedColor.value,
isGradient: resolvedColor.isGradient,
fontFamily: styles.fontFamily,
fontSize: styles.fontSize,
textAlign: containerStyles.textAlign,
...presentation,
};
}
/** Updates toolbar and parent when the selection moves during inline edit. */
export function onSelectionChange(force = false) {
const editing = getEditing();
if (!editing?.targetElement?.hasAttribute("contenteditable")) return;
if (!force && document.activeElement?.closest(EDITOR_UI_SELECTOR)) return;
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && !editing.targetElement.contains(selection.getRangeAt(0).commonAncestorContainer)) {
return;
}
stopFontSizeEdit();
hideAllPanels();
const styles = getStylesAtCursor();
if (!styles) return;
updateTextFormatToolbar(styles);
scheduleRepositionEditOverlays();
}
@@ -0,0 +1,368 @@
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<string, string>} 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<string, string>} original
* @returns {{ changed: Record<string, string>, previous: Record<string, string> }|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 <button> treats Space as activation, not text input, so the
// browser never inserts a character. Prevent the activation and insert
// the space manually (execCommand keeps the input event).
event.preventDefault();
document.execCommand("insertText", false, " ");
return;
}
event.stopPropagation();
}
@@ -0,0 +1,85 @@
import { EDIT_BORDER_COLOR, EDIT_BACKGROUND_COLOR, PANEL_BG, BORDER_COLOR, COLOR_WHITE, Z_INDEX_EDITOR_OVERLAY } from '../../constants/theme.js';
import { ICON_TEXT_EDIT_CURSOR } from '../../constants/icons.js';
import { HOVER_OUTLINE_STROKE_WIDTH } from '../../constants/layout.js';
import { EDITOR_UI_SELECTOR } from '../../constants/selectors.js';
/** Injected CSS for edit-mode hover outlines, contenteditable affordances, and the element-type tooltip. */
export const EDIT_MODE_STYLES = `
#root[data-edit-mode-enabled="true"] [data-edit-id],
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id] {
cursor: default !important;
overflow-wrap: anywhere;
}
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"],
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] {
outline: 1px solid ${EDIT_BORDER_COLOR};
caret-color: currentColor;
user-select: text;
-webkit-user-select: text;
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
}
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"] *,
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] * {
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
}
/* Suppress native cursor on actionable elements while editing. */
#root[data-edit-mode-enabled="true"] a,
#root[data-edit-mode-enabled="true"] button,
#root[data-edit-mode-enabled="true"] [role="button"],
#root[data-edit-mode-enabled="true"] [type="submit"] {
cursor: default !important;
}
.inline-editor-hover-outline,
.inline-editor-locked-outline {
outline-offset: -${HOVER_OUTLINE_STROKE_WIDTH}px;
box-shadow: inset 0 0 0 999px ${EDIT_BACKGROUND_COLOR};
}
.inline-editor-hover-outline {
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px dashed ${EDIT_BORDER_COLOR};
}
.inline-editor-locked-outline {
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px solid ${EDIT_BORDER_COLOR};
}
@keyframes element-type-tooltip-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.element-type-tooltip {
display: none;
opacity: 0;
position: fixed;
top: 0;
left: 0;
background-color: ${PANEL_BG};
color: ${COLOR_WHITE};
padding: 0 4px;
border-radius: 6px;
font-family: DM Sans, sans-serif;
font-size: 12px;
font-weight: 400;
line-height: 20px;
border: 1px solid ${BORDER_COLOR};
pointer-events: none;
white-space: nowrap;
z-index: ${Z_INDEX_EDITOR_OVERLAY};
}
.element-type-tooltip.active {
display: block;
animation: element-type-tooltip-fade-in 0.2s ease-out forwards;
}
${EDITOR_UI_SELECTOR} {
pointer-events: auto;
}
`;