open-design/apps/web/src/edit-mode/bridge.ts
Caprika 6736310a01
Implement manual edit inspector (#1448)
* feat(web): tweaks palette popover with HSL hue-shift recoloring

Adds a Tweaks color-palette popover to the HTML preview toolbar.
Selecting a palette re-skins the iframe in place via a srcDoc-side
bridge that walks the DOM and shifts every chromatic paint to the
target hue while preserving each color's saturation and lightness —
pale tints stay pale, bold CTAs stay bold, just in the new color
family. Mono-noir desaturates instead of shifting.

- runtime/srcdoc: new injectPaletteBridge + paletteBridge / initialPalette options
- file-viewer-render-mode: paletteActive flips URL-load back to srcDoc so the bridge can be injected
- FileViewer: state, popover, postMessage wiring, srcDoc + useUrlLoadPreview integration
- PaletteTweaks: popover UI with Original + Coral / Electric / Acid forest / Risograph / Mono noir
- PreviewDrawOverlay: stub pass-through until the draw branch lands

* feat(web): hide finalize-design toolbar from project header

* test(e2e): skip project actions toolbar flow after toolbar removal

* Polish manual edit inspector

* Implement manual edit inspector

* Fix manual edit review regressions

* Fix FileViewer CI regressions

* Fix remaining manual edit review issues

* Flush manual edit styles before draw exit

* Restore Critique Theater styles

* Accept pixel line-height manual edits

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-05-13 13:25:58 +08:00

279 lines
12 KiB
TypeScript

export const MANUAL_EDIT_DISCOVERY_SELECTOR = 'main, nav, section, article, header, footer, div, h1, h2, h3, p, a, button, img, strong, span';
export const MANUAL_EDIT_SOURCE_PATH_ATTR = 'data-od-source-path';
export const MANUAL_EDIT_HOST_NODE_SELECTOR = [
'[data-od-sandbox-shim]',
'[data-od-deck-bridge]',
'[data-od-comment-bridge]',
'[data-od-edit-bridge]',
'[data-od-comment-bridge-style]',
'[data-od-edit-bridge-style]',
'[data-od-deck-fix]',
].join(',');
export function manualEditDomPathForElement(el: Element): string {
const parts: number[] = [];
let node: Element | null = el;
while (node && node !== node.ownerDocument.body) {
const parentEl: Element | null = node.parentElement;
if (!parentEl) break;
const children = Array.from(parentEl.children).filter((child) => !isManualEditHostNode(child));
parts.unshift(children.indexOf(node));
node = parentEl;
}
return parts.length ? `path-${parts.join('-')}` : '';
}
export function isManualEditHostNode(el: Element): boolean {
return el.matches(MANUAL_EDIT_HOST_NODE_SELECTOR);
}
export function manualEditStableIdForElement(el: Element): string {
const explicit = el.getAttribute('data-od-id');
if (explicit) return explicit;
const generated = el.getAttribute(MANUAL_EDIT_SOURCE_PATH_ATTR) || el.getAttribute('data-od-runtime-id') || manualEditDomPathForElement(el);
if (generated) el.setAttribute('data-od-runtime-id', generated);
return generated || 'unknown';
}
export function isMeaningfulManualEditElement(el: Element, rect: Pick<DOMRect, 'width' | 'height'>): boolean {
return isSourceMappableManualEditElement(el) && el.matches(MANUAL_EDIT_DISCOVERY_SELECTOR) && rect.width >= 4 && rect.height >= 4;
}
export function isSourceMappableManualEditElement(el: Element): boolean {
return el.hasAttribute('data-od-id') || el.hasAttribute(MANUAL_EDIT_SOURCE_PATH_ATTR);
}
export function buildManualEditBridge(enabled: boolean): string {
return `<script data-od-edit-bridge>(function(){
var enabled = ${JSON.stringify(enabled)};
var discoverySelector = ${JSON.stringify(MANUAL_EDIT_DISCOVERY_SELECTOR)};
var hostNodeSelector = ${JSON.stringify(MANUAL_EDIT_HOST_NODE_SELECTOR)};
var sourcePathAttr = ${JSON.stringify(MANUAL_EDIT_SOURCE_PATH_ATTR)};
var styleProps = ['fontFamily','fontSize','fontWeight','color','textAlign','lineHeight','letterSpacing','width','height','minHeight','gap','flexDirection','justifyContent','alignItems','backgroundColor','opacity','padding','paddingTop','paddingRight','paddingBottom','paddingLeft','margin','marginTop','marginRight','marginBottom','marginLeft','border','borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth','borderStyle','borderColor','borderRadius'];
function isHostNode(el){
return !!(el && el.matches && el.matches(hostNodeSelector));
}
function domPath(el){
var parts = [];
var node = el;
while (node && node !== document.body) {
var parent = node.parentElement;
if (!parent) break;
var children = Array.prototype.slice.call(parent.children).filter(function(child){ return !isHostNode(child); });
parts.unshift(children.indexOf(node));
node = parent;
}
return parts.length ? 'path-' + parts.join('-') : '';
}
function stableId(el){
var explicit = el.getAttribute('data-od-id');
if (explicit) return explicit;
var generated = el.getAttribute(sourcePathAttr) || el.getAttribute('data-od-runtime-id') || domPath(el);
if (generated) el.setAttribute('data-od-runtime-id', generated);
return generated || 'unknown';
}
function isSourceMappable(el){
return !!(el && el.hasAttribute && (el.hasAttribute('data-od-id') || el.hasAttribute(sourcePathAttr)));
}
function isDiscoveryTarget(el){
return !!(el && el.matches && el.matches(discoverySelector));
}
function isPrimaryTarget(el){
if (!el || !el.hasAttribute) return false;
if (el.hasAttribute('data-od-id') || el.hasAttribute('data-od-edit')) return true;
var tag = el.tagName ? el.tagName.toLowerCase() : '';
return tag === 'a' || tag === 'button';
}
function inferKind(el){
var explicit = el.getAttribute('data-od-edit');
if (explicit) return explicit;
var tag = el.tagName ? el.tagName.toLowerCase() : '';
if (tag === 'a') return 'link';
if (tag === 'img') return 'image';
if (['section','main','nav','div','article','header','footer'].indexOf(tag) >= 0) return 'container';
return 'text';
}
function labelFor(el, id, kind){
var explicit = el.getAttribute('data-od-label');
if (explicit) return explicit;
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
var text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
if (text) return text.slice(0, 42);
if (kind === 'image') return el.getAttribute('alt') || id;
return tag + ' #' + id;
}
function attrsFor(el){
var attrs = {};
for (var i = 0; i < el.attributes.length; i++) {
var attr = el.attributes[i];
if (!attr || attr.name.indexOf('data-od-runtime') === 0 || attr.name === 'data-od-edit-selected') continue;
attrs[attr.name] = attr.value;
}
return attrs;
}
function stylesFor(el){
var computed = window.getComputedStyle(el);
var styles = {};
styleProps.forEach(function(prop){ styles[prop] = el.style[prop] || computed[prop] || ''; });
return styles;
}
function isLayoutContainer(el){
var display = window.getComputedStyle(el).display || '';
return display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0;
}
function targetFrom(el, includeOuterHtml){
var rect = el.getBoundingClientRect();
var kind = inferKind(el);
var id = stableId(el);
var fields = {};
if (kind === 'link') {
fields.text = (el.textContent || '').trim();
fields.href = el.getAttribute('href') || '';
} else if (kind === 'image') {
fields.src = el.getAttribute('src') || '';
fields.alt = el.getAttribute('alt') || '';
} else {
fields.text = (el.textContent || '').trim();
}
return {
id: id,
kind: kind,
label: labelFor(el, id, kind),
tagName: el.tagName ? el.tagName.toLowerCase() : 'element',
className: typeof el.className === 'string' ? el.className : '',
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 180),
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
fields: fields,
attributes: attrsFor(el),
styles: stylesFor(el),
isLayoutContainer: isLayoutContainer(el),
outerHtml: includeOuterHtml ? (el.outerHTML || '').replace(/\\sdata-od-runtime-id="[^"]*"/g, '').replace(/\\sdata-od-source-path="[^"]*"/g, '').replace(/\\sdata-od-edit-selected="[^"]*"/g, '') : ''
};
}
function allTargets(){
var nodes = document.body ? document.body.querySelectorAll(discoverySelector) : [];
var targets = [];
for (var i = 0; i < nodes.length; i++) {
var rect = nodes[i].getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) continue;
if (!isSourceMappable(nodes[i])) continue;
targets.push(targetFrom(nodes[i], false));
}
return targets;
}
function postTargets(){
if (!enabled) return;
window.parent.postMessage({ type: 'od-edit-targets', targets: allTargets() }, '*');
}
function clearSelectedTarget(){
var selected = document.querySelectorAll('[data-od-edit-selected]');
for (var i = 0; i < selected.length; i++) selected[i].removeAttribute('data-od-edit-selected');
}
function setSelectedTarget(id){
clearSelectedTarget();
if (!id) return;
var el = findById(id);
if (el) el.setAttribute('data-od-edit-selected', 'true');
}
function closestTarget(event){
var el = event.target;
var fallback = null;
while (el && el !== document.documentElement) {
if (el !== document.body && el !== document.documentElement && isSourceMappable(el) && isDiscoveryTarget(el)) {
if (isPrimaryTarget(el)) return el;
if (!fallback) fallback = el;
}
el = el.parentElement;
}
return fallback;
}
function camelToKebab(name){ return String(name).replace(/[A-Z]/g, function(m){ return '-' + m.toLowerCase(); }); }
function cssEscapeId(value){ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value); return String(value).replace(/"/g, '\\\\"'); }
function findById(id){
if (!id) return null;
if (id === '__body__') return document.body;
var el = document.querySelector('[data-od-id="' + cssEscapeId(id) + '"]')
|| document.querySelector('[data-od-runtime-id="' + cssEscapeId(id) + '"]')
|| document.querySelector('[' + sourcePathAttr + '="' + cssEscapeId(id) + '"]');
if (el) return el;
if (typeof id === 'string' && id.indexOf('path-') === 0) {
var parts = id.slice('path-'.length).split('-').map(function(s){ return Number(s); });
var node = document.body;
for (var i = 0; i < parts.length; i++) {
if (!node) return null;
var idx = parts[i];
if (!Number.isInteger(idx) || idx < 0) return null;
var children = Array.prototype.slice.call(node.children).filter(function(c){ return !isHostNode(c); });
node = children[idx] || null;
}
return node;
}
return null;
}
function applyPreviewStyles(id, styles, version){
var el = findById(id);
if (!el) {
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id || '', version: Number(version) || 0, ok: false, error: 'Target not found' }, '*');
return;
}
var keys = Object.keys(styles || {});
try {
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var value = styles[key];
var cssName = camelToKebab(key);
if (typeof value !== 'string' || value.trim() === '') el.style.removeProperty(cssName);
else el.style.setProperty(cssName, value.trim());
}
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id, version: Number(version) || 0, ok: true }, '*');
} catch (e) {
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id, version: Number(version) || 0, ok: false, error: e && e.message ? String(e.message) : 'Could not apply preview styles' }, '*');
}
}
window.addEventListener('message', function(ev){
if (!ev.data) return;
if (ev.data.type === 'od-edit-mode') {
enabled = !!ev.data.enabled;
document.documentElement.toggleAttribute('data-od-edit-mode', enabled);
if (!enabled) clearSelectedTarget();
if (enabled) setTimeout(postTargets, 0);
return;
}
if (ev.data.type === 'od-edit-selected-target') {
setSelectedTarget(ev.data.id || null);
return;
}
if (ev.data.type === 'od-edit-preview-style') {
applyPreviewStyles(ev.data.id, ev.data.styles || {}, ev.data.version);
return;
}
});
document.addEventListener('click', function(ev){
if (!enabled) return;
var el = closestTarget(ev);
if (!el) return;
ev.preventDefault();
ev.stopPropagation();
window.parent.postMessage({ type: 'od-edit-select', target: targetFrom(el, true) }, '*');
}, true);
window.addEventListener('resize', postTargets);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
else setTimeout(postTargets, 0);
document.documentElement.toggleAttribute('data-od-edit-mode', enabled);
})();</script>`;
}
export function buildManualEditBridgeStyle(): string {
return `<style data-od-edit-bridge-style>
html[data-od-edit-mode] body * { cursor: pointer !important; }
html[data-od-edit-mode] [data-od-id],
html[data-od-edit-mode] [data-od-runtime-id] { outline: 1px dashed rgba(37, 99, 235, 0.35); outline-offset: 3px; }
html[data-od-edit-mode] [data-od-id]:hover,
html[data-od-edit-mode] [data-od-runtime-id]:hover { outline: 2px solid #2563eb; }
html[data-od-edit-mode] [data-od-edit-selected] {
outline: 2px solid #2563eb !important;
outline-offset: 4px;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.16);
}
</style>`;
}