Clean deploy: nueva versión
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { FIXED_CONTEXT_SELECTOR, ELEMENT_TYPE_MAP } from "../constants/selectors.js";
|
||||
|
||||
/**
|
||||
* Logical type of an element for the hover tooltip: `text`, `button`, `image`,
|
||||
* or `block` otherwise. Edit targets (`data-edit-id` / `data-edit-assisted-id`)
|
||||
* that are not button/image tags are `text`.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getElementType(element) {
|
||||
const tag = element?.tagName?.toLowerCase();
|
||||
const hasEditId = element?.hasAttribute?.('data-edit-id') || element?.hasAttribute?.('data-edit-assisted-id');
|
||||
|
||||
for (const [type, tags] of Object.entries(ELEMENT_TYPE_MAP)) {
|
||||
if (tags.some(candidate => candidate.toLowerCase() === tag)) {
|
||||
if (!hasEditId && type === 'text') return 'block';
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEditId) return 'text';
|
||||
|
||||
return 'block';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The app's mount point, which roots every structural walk so the editor's own
|
||||
* body-level surfaces (panels, markers, outlines) are never traversed.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function getAppRoot() {
|
||||
return document.getElementById('root') || document.body;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* True when the element sits inside a fixed/sticky ancestor, meaning overlays
|
||||
* must use viewport (fixed) positioning instead of document coordinates.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInFixedContext(element) {
|
||||
return !!element.closest(FIXED_CONTEXT_SELECTOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-page modal dialogs that are currently open and visible. Mounted-but-closed
|
||||
* dialogs are skipped so a dismissed modal never keeps blocking the page.
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
export function getOpenModals() {
|
||||
return Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||||
(modal) => modal.getAttribute('data-state') !== 'closed' && modal.checkVisibility?.() !== false,
|
||||
);
|
||||
}
|
||||
|
||||
const HOST_ISOLATED_EVENTS = ['pointerdown', 'pointerup', 'keydown', 'keyup'];
|
||||
|
||||
/**
|
||||
* Prevents host-page libraries (Radix DismissableLayer, FocusScope, etc.) from
|
||||
* seeing pointer and keyboard events that originate inside the editor's floating UI.
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
export function isolateEditorUiEvents(element) {
|
||||
for (const eventName of HOST_ISOLATED_EVENTS) {
|
||||
element.addEventListener(eventName, (event) => event.stopPropagation());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { IMPORTANT_STYLES } from '../constants/selectors.js';
|
||||
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
const OUTER_HTML_MAX_LENGTH = 5000;
|
||||
|
||||
/**
|
||||
* Decodes HTML entity-encoded characters back to their literal form.
|
||||
* @param {string} string
|
||||
* @returns {string}
|
||||
*/
|
||||
export function decodeHtmlEntities(string) {
|
||||
return string
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a kebab-case CSS property name to camelCase.
|
||||
* @param {string} property
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cssPropToCamel(property) {
|
||||
return property.replace(/-([a-z])/g, (_, character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
const HTML_TO_JSX_ATTRIBUTE_RENAME_MAP = { class: 'className', for: 'htmlFor' };
|
||||
|
||||
/**
|
||||
* Converts an HTML attribute name to its JSX/React equivalent (renames `class`/`for`,
|
||||
* camelCases hyphenated names except `data-*`/`aria-*`, e.g. `stroke-width` -> `strokeWidth`).
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toJsxAttributeName(name) {
|
||||
if (Object.prototype.hasOwnProperty.call(HTML_TO_JSX_ATTRIBUTE_RENAME_MAP, name)) return HTML_TO_JSX_ATTRIBUTE_RENAME_MAP[name];
|
||||
if (name.startsWith('data-') || name.startsWith('aria-')) return name;
|
||||
return name.replace(/-([a-z0-9])/g, (_, character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters in a plain string (used to build safe HTML markup).
|
||||
* @param {string} string
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(string) {
|
||||
return string.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips inline HTML tags from text, converting <br> to newlines.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function stripInlineTags(text) {
|
||||
if (typeof text !== "string") return "";
|
||||
return text
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/?(strong|em|b|i|u|a|span)\b[^>]*>/gi, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next node in document order within `root`, or null at the end.
|
||||
* @param {Node} node
|
||||
* @param {Node} root
|
||||
* @returns {Node|null}
|
||||
*/
|
||||
function nextNodeInFlow(node, root) {
|
||||
if (node.firstChild) return node.firstChild;
|
||||
let current = node;
|
||||
while (current && current !== root) {
|
||||
if (current.nextSibling) return current.nextSibling;
|
||||
current = current.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a text node sits at the end of a line: nothing follows it, or the next
|
||||
* node in flow is a <br>. Only such trailing spaces get collapsed by the browser
|
||||
* on re-parse and need preserving — spaces between inline siblings (e.g. coloured
|
||||
* <span> words) must stay normal breaking spaces or the line can't wrap.
|
||||
* @param {Node} node
|
||||
* @param {Node} root
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAtLineEnd(node, root) {
|
||||
const next = nextNodeInFlow(node, root);
|
||||
if (!next) return true;
|
||||
let candidate = next;
|
||||
while (candidate && candidate.nodeType === Node.ELEMENT_NODE) {
|
||||
if (candidate.tagName.toLowerCase() === 'br') return true;
|
||||
candidate = candidate.firstChild;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces line-end trailing keyboard spaces in text nodes with U+00A0 so innerHTML
|
||||
* round-trips preserve them (browsers drop trailing U+0020 on re-parse). Spaces
|
||||
* between inline siblings are left intact so the content can still wrap.
|
||||
* @param {Node} root
|
||||
*/
|
||||
function preserveTrailingSpacesInTextNodes(root) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const textNodes = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
for (const node of textNodes) {
|
||||
const text = node.nodeValue ?? '';
|
||||
if (!/ $/.test(text) || !isAtLineEnd(node, root)) continue;
|
||||
node.nodeValue = text.replace(/ +$/g, (spaces) => '\u00a0'.repeat(spaces.length));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads contenteditable markup from the live DOM after keyboard input, preserving
|
||||
* trailing spaces that `innerHTML` alone would drop on commit.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeContentEditableHtml(element) {
|
||||
if (!element) return '';
|
||||
const clone = element.cloneNode(true);
|
||||
preserveTrailingSpacesInTextNodes(clone);
|
||||
return normalizeContentEditableHtml(clone.innerHTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises contenteditable innerHTML into a canonical form by collapsing
|
||||
* Chrome's <div>-based line wrapping back into <br> separators.
|
||||
* Intentionally keeps ` ` entities so keyboard-typed trailing spaces persist.
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeContentEditableHtml(html) {
|
||||
if (typeof html !== "string") return "";
|
||||
return html
|
||||
.replace(/<div><br\s*\/?><\/div>/gi, "<br>")
|
||||
.replace(/<br\s*\/?>\s*(?=<div>)/gi, "")
|
||||
.replace(/<div>/gi, "<br>")
|
||||
.replace(/<\/div>/gi, "")
|
||||
.replace(/^<br>/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against "Failed to execute 'removeChild'" when innerHTML is set
|
||||
* while a browser async task still holds a reference to a child node.
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
export function patchRemoveChild(element) {
|
||||
if (element.__removeChildPatched) return;
|
||||
const original = element.removeChild;
|
||||
element.removeChild = function (child) {
|
||||
if (child.parentNode !== this) return child;
|
||||
return original.call(this, child);
|
||||
};
|
||||
element.__removeChildPatched = true;
|
||||
}
|
||||
|
||||
let reactDomMutationsPatched = false;
|
||||
|
||||
/**
|
||||
* Inline edits rewrite a target element's subtree via `innerHTML`, which orphans
|
||||
* the DOM nodes React still references in its fiber tree (including *nested*
|
||||
* nodes like <em>/<strong> inside an edited <h1>). On its next render React then
|
||||
* calls `removeChild`/`insertBefore` against nodes that are no longer where it
|
||||
* expects, throwing "The node to be removed is not a child of this node".
|
||||
*
|
||||
* Patching the per-element `removeChild` only covers the edit target, not its
|
||||
* descendants, so we guard the prototypes once instead: mismatched removals
|
||||
* become no-ops and mismatched insertions fall back to an append, letting React
|
||||
* reconcile to the correct state on the next render. Idempotent.
|
||||
*/
|
||||
export function patchReactDomMutations() {
|
||||
if (reactDomMutationsPatched) return;
|
||||
reactDomMutationsPatched = true;
|
||||
|
||||
const originalRemoveChild = Node.prototype.removeChild;
|
||||
Node.prototype.removeChild = function (child) {
|
||||
if (child && child.parentNode !== this) return child;
|
||||
return originalRemoveChild.call(this, child);
|
||||
};
|
||||
|
||||
const originalInsertBefore = Node.prototype.insertBefore;
|
||||
Node.prototype.insertBefore = function (newNode, referenceNode) {
|
||||
if (referenceNode && referenceNode.parentNode !== this) {
|
||||
return originalInsertBefore.call(this, newNode, null);
|
||||
}
|
||||
return originalInsertBefore.call(this, newNode, referenceNode);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes raw HTML characters while preserving allowed inline tags
|
||||
* (strong, em, a, span, br, etc.) so user content is safe to inject into the DOM.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitizeText(text) {
|
||||
if (typeof text !== "string") return "";
|
||||
const normalized = normalizeContentEditableHtml(text);
|
||||
const preserved = [];
|
||||
let result = normalized.replace(/<(\/?)(?:strong|em|b|i|u|a|span|br)\b[^>]*\/?>/gi, (match) => {
|
||||
preserved.push(match);
|
||||
return `\x00ITAG${preserved.length - 1}\x00`;
|
||||
});
|
||||
result = result
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/{/g, "{")
|
||||
.replace(/}/g, "}");
|
||||
result = result.replace(/\x00ITAG(\d+)\x00/g, (_, index) => preserved[parseInt(index)]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CSS selector path to uniquely identify the element.
|
||||
* Uses tag + classes + nth-of-type where IDs are unavailable.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildCssSelector(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20;
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
function getComputedStylesForElement(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
}).filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a rich DOM context payload for an element: selector, attributes,
|
||||
* computed styles, and truncated text content.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function extractDOMContext(element) {
|
||||
if (!element) return null;
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
|
||||
return {
|
||||
outerHTML: element.outerHTML.slice(0, OUTER_HTML_MAX_LENGTH),
|
||||
selector: buildCssSelector(element),
|
||||
attributes: (element.attributes && element.attributes.length > 0)
|
||||
? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value]))
|
||||
: {},
|
||||
computedStyles: getComputedStylesForElement(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? textContent
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the React fiber tree from a DOM node to find the source file path.
|
||||
* @param {HTMLElement} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getFilePathFromNode(node) {
|
||||
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
|
||||
if (!fiberKey) return null;
|
||||
|
||||
let currentFiber = node[fiberKey];
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) return source.fileName;
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the path prefix up to and including `public_html/`.
|
||||
* @param {string} filePath
|
||||
* @returns {string}
|
||||
*/
|
||||
export function stripFilePath(filePath) {
|
||||
if (!filePath) return filePath;
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
return filePath;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Minimum computed font-weight treated as bold (matches toolbar + toggle-off). */
|
||||
const BOLD_WEIGHT_THRESHOLD = 600;
|
||||
|
||||
/**
|
||||
* @param {Range} range
|
||||
* @returns {Text[]}
|
||||
*/
|
||||
export function getTextNodesInRange(range) {
|
||||
const root = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
|
||||
? range.commonAncestorContainer.parentElement
|
||||
: range.commonAncestorContainer;
|
||||
const nodes = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
if (range.intersectsNode(walker.currentNode)) nodes.push(walker.currentNode);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function elementFromNode(node) {
|
||||
if (!node) return null;
|
||||
return node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
||||
}
|
||||
|
||||
function walkAncestors(element, container, predicate) {
|
||||
let current = element;
|
||||
while (current && current !== container.parentElement) {
|
||||
if (predicate(current)) return true;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @returns {boolean} True when `node` sits inside bold markup or computed weight. */
|
||||
export function isNodeBold(node, container) {
|
||||
if (parseInt(getComputedStyle(container).fontWeight) >= BOLD_WEIGHT_THRESHOLD) return true;
|
||||
const element = elementFromNode(node);
|
||||
if (!element) return false;
|
||||
return walkAncestors(element, container, (current) => {
|
||||
// `<strong>`/`<b>` apply `font-weight: bolder`, which resolves to only 400
|
||||
// when the parent is a light weight (e.g. Tailwind `font-light`, 300), so a
|
||||
// computed-weight check alone misses them. Treat the tags as bold directly.
|
||||
const tag = current.tagName?.toLowerCase();
|
||||
if (tag === 'strong' || tag === 'b') return true;
|
||||
return parseInt(getComputedStyle(current).fontWeight) >= BOLD_WEIGHT_THRESHOLD;
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {boolean} True when `node` sits inside italic markup or computed style. */
|
||||
export function isNodeItalic(node, container) {
|
||||
if (getComputedStyle(container).fontStyle === 'italic') return true;
|
||||
const element = elementFromNode(node);
|
||||
if (!element) return false;
|
||||
return walkAncestors(element, container, (current) => getComputedStyle(current).fontStyle === 'italic');
|
||||
}
|
||||
|
||||
/** @returns {boolean} True when `node` sits inside underlined markup or decoration. */
|
||||
export function isNodeUnderlined(node, container) {
|
||||
const containerTextDecorationLine = getComputedStyle(container).textDecorationLine || getComputedStyle(container).textDecoration || '';
|
||||
if (containerTextDecorationLine.includes('underline')) return true;
|
||||
const element = elementFromNode(node);
|
||||
if (!element) return false;
|
||||
return walkAncestors(element, container, (current) => {
|
||||
const textDecorationLine = getComputedStyle(current).textDecorationLine || getComputedStyle(current).textDecoration || '';
|
||||
return textDecorationLine.includes('underline');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves coexisting bold / italic / underline at a single DOM position.
|
||||
* Each flag is independent — e.g. `<em><u><strong>t</strong></u></em>` is all three.
|
||||
* @param {Node} node
|
||||
* @param {HTMLElement} container
|
||||
* @returns {{ isBold: boolean, isItalic: boolean, isUnderline: boolean }}
|
||||
*/
|
||||
export function resolveInlineStylesAtNode(node, container) {
|
||||
return {
|
||||
isBold: isNodeBold(node, container),
|
||||
isItalic: isNodeItalic(node, container),
|
||||
isUnderline: isNodeUnderlined(node, container),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves toolbar toggle state for the current selection.
|
||||
* Collapsed → styles at caret. Range → active only when every covered text node has the style.
|
||||
* @param {Range} range
|
||||
* @param {HTMLElement} container
|
||||
* @returns {{ isBold: boolean, isItalic: boolean, isUnderline: boolean }}
|
||||
*/
|
||||
export function resolveInlineStylesAtRange(range, container) {
|
||||
if (range.collapsed) {
|
||||
return resolveInlineStylesAtNode(range.startContainer, container);
|
||||
}
|
||||
const nodes = getTextNodesInRange(range);
|
||||
if (nodes.length === 0) {
|
||||
return resolveInlineStylesAtNode(range.startContainer, container);
|
||||
}
|
||||
return {
|
||||
isBold: nodes.every((node) => isNodeBold(node, container)),
|
||||
isItalic: nodes.every((node) => isNodeItalic(node, container)),
|
||||
isUnderline: nodes.every((node) => isNodeUnderlined(node, container)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Origins allowed to receive messages from this iframe. */
|
||||
export const ALLOWED_PARENT_ORIGINS = [
|
||||
'https://horizons.hostinger.com',
|
||||
'https://horizons.hostinger.dev',
|
||||
'https://horizons-frontend-local.hostinger.dev',
|
||||
'http://localhost:4000',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves the parent frame origin from `ancestorOrigins` or `document.referrer`.
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function getParentOrigin() {
|
||||
if (window.location.ancestorOrigins && window.location.ancestorOrigins.length > 0) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch {
|
||||
console.warn('[plugin] Invalid referrer URL:', document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a typed message to the parent frame when the origin is allowlisted.
|
||||
* @param {string} type - A {@link ParentMessage} value.
|
||||
* @param {object} [payload]
|
||||
*/
|
||||
export function postToParent(type, payload = {}) {
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage({ type, payload }, parentOrigin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/** Shared DOM-context capture for the unified visual-edit contract. */
|
||||
|
||||
const IMPORTANT_STYLES = [
|
||||
'display',
|
||||
'position',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'background-color',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'border-radius',
|
||||
'box-shadow',
|
||||
'gap',
|
||||
'grid-template-columns',
|
||||
];
|
||||
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
|
||||
/** Backend DTO caps `selectionMode.selector` at this length; must match `src/constants/message.ts`. */
|
||||
const SELECTOR_MAX_LENGTH = 1_000;
|
||||
|
||||
/** Keeps generated context well below the backend's 50,000-character `outerHTML` cap. */
|
||||
const DOM_CONTEXT_MAX_LENGTH = 5_000;
|
||||
const DOM_CONTEXT_MAX_DEPTH = 3;
|
||||
const DOM_CONTEXT_MAX_CHILD_ELEMENTS = 5;
|
||||
const DOM_CONTEXT_EDGE_CHILD_ELEMENTS = 2;
|
||||
const DOM_CONTEXT_MAX_TEXT_LENGTH = 300;
|
||||
const DOM_CONTEXT_TEXT_EDGE_LENGTH = 140;
|
||||
const DOM_CONTEXT_MAX_ATTRIBUTE_LENGTH = 300;
|
||||
const DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH = 140;
|
||||
|
||||
const OMITTED_ELEMENT_NAMES = new Set([
|
||||
'base',
|
||||
'head',
|
||||
'link',
|
||||
'meta',
|
||||
'noscript',
|
||||
'script',
|
||||
'style',
|
||||
'template',
|
||||
]);
|
||||
|
||||
function contextAttributeEntries(element) {
|
||||
if (!element.attributes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(element.attributes).flatMap((attribute) => {
|
||||
const name = attribute.name.toLowerCase();
|
||||
const value = attribute.value;
|
||||
if (
|
||||
name.startsWith('data-edit-')
|
||||
|| name === 'data-selection-mode-enabled'
|
||||
|| name.startsWith('on')
|
||||
|| name === 'srcdoc'
|
||||
|| value.trimStart().toLowerCase().startsWith('data:')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value.length <= DOM_CONTEXT_MAX_ATTRIBUTE_LENGTH) {
|
||||
return [[attribute.name, value]];
|
||||
}
|
||||
|
||||
const omittedLength = value.length - (DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH * 2);
|
||||
return [[
|
||||
attribute.name,
|
||||
`${value.slice(0, DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH)}[${omittedLength} chars omitted]${value.slice(-DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH)}`,
|
||||
]];
|
||||
});
|
||||
}
|
||||
|
||||
function cloneElementShell(element) {
|
||||
const clone = element.cloneNode(false);
|
||||
Array.from(clone.attributes ?? []).forEach(attribute => clone.removeAttribute(attribute.name));
|
||||
contextAttributeEntries(element).forEach(([name, value]) => clone.setAttribute(name, value));
|
||||
return clone;
|
||||
}
|
||||
|
||||
function appendTextContext(parent, value) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (value.length <= DOM_CONTEXT_MAX_TEXT_LENGTH) {
|
||||
parent.appendChild(parent.ownerDocument.createTextNode(value));
|
||||
return;
|
||||
}
|
||||
|
||||
const omittedLength = value.length - (DOM_CONTEXT_TEXT_EDGE_LENGTH * 2);
|
||||
parent.appendChild(parent.ownerDocument.createTextNode(value.slice(0, DOM_CONTEXT_TEXT_EDGE_LENGTH)));
|
||||
parent.appendChild(parent.ownerDocument.createComment(`${omittedLength} text chars omitted`));
|
||||
parent.appendChild(parent.ownerDocument.createTextNode(value.slice(-DOM_CONTEXT_TEXT_EDGE_LENGTH)));
|
||||
}
|
||||
|
||||
function contextElementChildren(element) {
|
||||
return Array.from(element.children)
|
||||
.filter(child => !OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase()));
|
||||
}
|
||||
|
||||
function collectVisibleText(element) {
|
||||
if (element.tagName.toLowerCase() === 'svg') {
|
||||
return Array.from(element.children)
|
||||
.filter(child => ['title', 'desc'].includes(child.tagName.toLowerCase()))
|
||||
.map(child => child.textContent ?? '')
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return Array.from(element.childNodes).map((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
return child.textContent ?? '';
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
if (OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase())) {
|
||||
return '';
|
||||
}
|
||||
return collectVisibleText(child);
|
||||
})
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function appendSvgContext(source, clone) {
|
||||
Array.from(source.children)
|
||||
.filter(child => ['title', 'desc'].includes(child.tagName.toLowerCase()))
|
||||
.forEach((child) => {
|
||||
const semanticClone = cloneElementShell(child);
|
||||
appendTextContext(semanticClone, collectVisibleText(child));
|
||||
clone.appendChild(semanticClone);
|
||||
});
|
||||
clone.appendChild(clone.ownerDocument.createComment('SVG content omitted'));
|
||||
}
|
||||
|
||||
function cloneElementContext(element, depth) {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (OMITTED_ELEMENT_NAMES.has(tagName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clone = cloneElementShell(element);
|
||||
if (tagName === 'svg') {
|
||||
appendSvgContext(element, clone);
|
||||
return clone;
|
||||
}
|
||||
if (tagName === 'canvas') {
|
||||
appendTextContext(clone, collectVisibleText(element).trim());
|
||||
clone.appendChild(clone.ownerDocument.createComment('canvas bitmap not captured'));
|
||||
return clone;
|
||||
}
|
||||
|
||||
const elementChildren = contextElementChildren(element);
|
||||
if (depth >= DOM_CONTEXT_MAX_DEPTH && elementChildren.length > 0) {
|
||||
appendTextContext(clone, collectVisibleText(element).trim());
|
||||
clone.appendChild(clone.ownerDocument.createComment('descendants omitted'));
|
||||
return clone;
|
||||
}
|
||||
|
||||
const keptChildren = elementChildren.length > DOM_CONTEXT_MAX_CHILD_ELEMENTS
|
||||
? new Set([
|
||||
...elementChildren.slice(0, DOM_CONTEXT_EDGE_CHILD_ELEMENTS),
|
||||
...elementChildren.slice(-DOM_CONTEXT_EDGE_CHILD_ELEMENTS),
|
||||
])
|
||||
: new Set(elementChildren);
|
||||
const omittedChildrenCount = elementChildren.length - keptChildren.size;
|
||||
let omissionMarkerAdded = false;
|
||||
|
||||
Array.from(element.childNodes).forEach((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
appendTextContext(clone, child.textContent ?? '');
|
||||
return;
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE || OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
if (!keptChildren.has(child)) {
|
||||
if (!omissionMarkerAdded) {
|
||||
clone.appendChild(clone.ownerDocument.createComment(`${omittedChildrenCount} elements omitted`));
|
||||
omissionMarkerAdded = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const childClone = cloneElementContext(child, depth + 1);
|
||||
if (childClone) {
|
||||
clone.appendChild(childClone);
|
||||
}
|
||||
});
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds compact, valid HTML context while retaining the selected element's
|
||||
* identifying structure and visible text.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeElementContext(element) {
|
||||
const clone = cloneElementContext(element, 0);
|
||||
if (!clone) {
|
||||
return '<!-- context omitted -->';
|
||||
}
|
||||
|
||||
const serialized = clone.outerHTML;
|
||||
if (serialized.length <= DOM_CONTEXT_MAX_LENGTH) {
|
||||
return serialized;
|
||||
}
|
||||
|
||||
const fallback = cloneElementShell(element);
|
||||
fallback.appendChild(fallback.ownerDocument.createComment('context omitted: size limit'));
|
||||
while (fallback.outerHTML.length > DOM_CONTEXT_MAX_LENGTH && fallback.attributes.length > 0) {
|
||||
fallback.removeAttribute(fallback.attributes.item(fallback.attributes.length - 1).name);
|
||||
}
|
||||
return fallback.outerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CSS selector path to uniquely identify the element.
|
||||
* Uses tag + classes + nth-of-type where IDs are unavailable. Ancestors with many
|
||||
* utility classes (e.g. Tailwind) can make the full path exceed the backend's length
|
||||
* cap, so distant ancestors are dropped first since the leaf end is more identifying.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string} CSS selector path, truncated to fit {@link SELECTOR_MAX_LENGTH}
|
||||
*/
|
||||
export function buildCssSelector(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20; // Prevent infinite loops
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
while (path.length > 1 && path.join(' > ').length > SELECTOR_MAX_LENGTH) {
|
||||
path.shift();
|
||||
}
|
||||
|
||||
return path.join(' > ').slice(0, SELECTOR_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function getComputedStylesSnapshot(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips everything up to and including `public_html/` so the path matches
|
||||
* the workspace-relative form the backend/AI expect.
|
||||
* @param {string|null} filePath
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stripPublicHtmlPrefix(filePath) {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) {
|
||||
return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts source location from a `filePath:line:column` edit id (the
|
||||
* format the visual-editor transform writes into `data-edit-id` /
|
||||
* `data-edit-assisted-id` — see `transform/inject-attributes.js` and the
|
||||
* matching parser in `server/source-writer.js`).
|
||||
* @param {string} editId
|
||||
* @returns {{filePath: string, line: number, column: number}|null}
|
||||
*/
|
||||
export function sourceLocationFromEditId(editId) {
|
||||
const segments = editId.split(':');
|
||||
if (segments.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const line = Number(segments.at(-2));
|
||||
const column = Number(segments.at(-1));
|
||||
const filePath = segments.slice(0, -2).join(':');
|
||||
if (!filePath || !Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
|
||||
return null;
|
||||
}
|
||||
return { filePath, line, column };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the React Fiber tree to find the source file path of an element with
|
||||
* no visual-editor edit-id attribute (plain selection-mode clicks).
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function filePathFromReactFiber(element) {
|
||||
const fiberKey = Object.keys(element).find(key => key.startsWith('__reactFiber'));
|
||||
if (!fiberKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentFiber = element[fiberKey];
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) {
|
||||
return source.fileName;
|
||||
}
|
||||
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the source file path for `element`. Prefers the visual editor's
|
||||
* own `data-edit-id` / `data-edit-assisted-id` attribute — already
|
||||
* `filePath:line:col`, no extra work — falling back to walking the React
|
||||
* Fiber tree for elements the editor hasn't tagged (plain selection-mode
|
||||
* clicks).
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string} File path, or "" if it could not be resolved.
|
||||
*/
|
||||
export function resolveElementFilePath(element) {
|
||||
const editId = element.getAttribute?.('data-edit-id') || element.getAttribute?.('data-edit-assisted-id');
|
||||
if (editId) {
|
||||
const sourceLocation = sourceLocationFromEditId(editId);
|
||||
if (sourceLocation) {
|
||||
return stripPublicHtmlPrefix(sourceLocation.filePath) ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return stripPublicHtmlPrefix(filePathFromReactFiber(element)) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the full `selectionMode` context for `element`: source file
|
||||
* path and optional source position, outer HTML, a CSS selector path, its
|
||||
* attributes, a snapshot of the important computed styles, and trimmed text
|
||||
* content (when short enough to be useful). Shared by selection mode and the
|
||||
* visual editor so both emit the exact same shape the backend validates and
|
||||
* the AI consumes.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {{filePath: string, sourceLine?: number, sourceColumn?: number, outerHTML: string, selector: string, attributes: Record<string, string>, computedStyles: Record<string, string>, textContent: string|null}|null}
|
||||
*/
|
||||
export function captureElementMetadata(element) {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
const editId = element.getAttribute?.('data-edit-id') || element.getAttribute?.('data-edit-assisted-id');
|
||||
const sourceLocation = editId ? sourceLocationFromEditId(editId) : null;
|
||||
|
||||
return {
|
||||
filePath: resolveElementFilePath(element),
|
||||
...(sourceLocation && {
|
||||
sourceLine: sourceLocation.line,
|
||||
sourceColumn: sourceLocation.column,
|
||||
}),
|
||||
outerHTML: serializeElementContext(element),
|
||||
selector: buildCssSelector(element),
|
||||
attributes: Object.fromEntries(contextAttributeEntries(element)),
|
||||
computedStyles: getComputedStylesSnapshot(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? textContent
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ParentMessage } from '../constants/messages.js';
|
||||
import { undo, redo } from '../state/history-state.js';
|
||||
import { notifyDraftStateChanged } from '../api/draft-snapshot.js';
|
||||
|
||||
/**
|
||||
* Applies one undo/redo step and notifies the parent frame when an action ran.
|
||||
* @param {'undo'|'redo'} direction
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function applySessionHistoryStep(direction) {
|
||||
const action = direction === 'undo' ? undo() : redo();
|
||||
if (!action) return null;
|
||||
|
||||
notifyDraftStateChanged(
|
||||
direction === 'undo' ? ParentMessage.EDIT_UNDO_APPLIED : ParentMessage.EDIT_REDO_APPLIED,
|
||||
{
|
||||
editId: action.editId,
|
||||
value: direction === 'undo' ? action.instruction.beforeContent : action.instruction.afterContent,
|
||||
},
|
||||
);
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/** @returns {object|null} */
|
||||
export function applySessionUndo() {
|
||||
return applySessionHistoryStep('undo');
|
||||
}
|
||||
|
||||
/** @returns {object|null} */
|
||||
export function applySessionRedo() {
|
||||
return applySessionHistoryStep('redo');
|
||||
}
|
||||
Reference in New Issue
Block a user