mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(web): keep tweaks selection usable without annotations (#1268)
This commit is contained in:
parent
8962088c75
commit
1eb20e3807
2 changed files with 180 additions and 14 deletions
|
|
@ -448,9 +448,54 @@ function injectSelectionBridge(
|
|||
};
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
function targetFrom(el){
|
||||
function annotatedSelectorFor(el){
|
||||
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
|
||||
if (!id) return null;
|
||||
return el.hasAttribute('data-od-id') ? '[data-od-id="' + esc(id) + '"]' : '[data-screen-label="' + esc(id) + '"]';
|
||||
}
|
||||
function domSelectorFor(el){
|
||||
if (!el || !el.tagName || el === document.documentElement || el === document.body) return null;
|
||||
var parts = [];
|
||||
var node = el;
|
||||
while (node && node !== document.documentElement && node !== document.body) {
|
||||
var tag = node.tagName ? node.tagName.toLowerCase() : '';
|
||||
if (!tag || /^(script|style|template|meta|link|title|noscript)$/.test(tag)) return null;
|
||||
var parent = node.parentElement;
|
||||
if (!parent) return null;
|
||||
var index = 1;
|
||||
var sibling = node.previousElementSibling;
|
||||
while (sibling) {
|
||||
if (sibling.tagName && sibling.tagName.toLowerCase() === tag) index++;
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
parts.unshift(tag + ':nth-of-type(' + index + ')');
|
||||
node = parent;
|
||||
}
|
||||
if (!parts.length) return null;
|
||||
return 'body > ' + parts.join(' > ');
|
||||
}
|
||||
function visibleTarget(el){
|
||||
if (!el || !el.getBoundingClientRect) return false;
|
||||
if (el === document.documentElement || el === document.body) return false;
|
||||
if (/^(script|style|template|meta|link|title|noscript)$/.test(el.tagName ? el.tagName.toLowerCase() : '')) return false;
|
||||
try {
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) return false;
|
||||
var cs = window.getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.pointerEvents === 'none') return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function targetFrom(el, allowDomFallback){
|
||||
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
|
||||
var selector = annotatedSelectorFor(el);
|
||||
if (!id && allowDomFallback && visibleTarget(el)) {
|
||||
selector = domSelectorFor(el);
|
||||
if (selector) id = 'dom:' + selector;
|
||||
}
|
||||
if (!id || !selector) return null;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
|
||||
var cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
|
||||
|
|
@ -459,7 +504,7 @@ function injectSelectionBridge(
|
|||
return {
|
||||
type: 'od:comment-target',
|
||||
elementId: id,
|
||||
selector: el.hasAttribute('data-od-id') ? '[data-od-id="' + esc(id) + '"]' : '[data-screen-label="' + esc(id) + '"]',
|
||||
selector: selector,
|
||||
label: tag + cls,
|
||||
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 160),
|
||||
position: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||||
|
|
@ -468,11 +513,19 @@ function injectSelectionBridge(
|
|||
};
|
||||
}
|
||||
function allTargets(){
|
||||
var nodes = document.querySelectorAll('[data-od-id], [data-screen-label]');
|
||||
var annotatedNodes = document.querySelectorAll('[data-od-id], [data-screen-label]');
|
||||
var includeDomFallback = canUseDomFallback();
|
||||
var nodes = includeDomFallback
|
||||
? document.querySelectorAll('body *')
|
||||
: annotatedNodes;
|
||||
var items = [];
|
||||
var seen = Object.create(null);
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var item = targetFrom(nodes[i]);
|
||||
if (item) items.push(item);
|
||||
var item = targetFrom(nodes[i], includeDomFallback);
|
||||
if (item && !seen[item.elementId]) {
|
||||
seen[item.elementId] = true;
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
|
@ -499,18 +552,19 @@ function injectSelectionBridge(
|
|||
function postStroke(type){
|
||||
window.parent.postMessage({ type: type, points: stroke.slice() }, '*');
|
||||
}
|
||||
function canUseDomFallback(){
|
||||
return commentEnabled && !inspectEnabled && document.querySelectorAll('[data-od-id], [data-screen-label]').length === 0;
|
||||
}
|
||||
function closestTarget(event){
|
||||
var el = event.target;
|
||||
var fallback = null;
|
||||
var allowDomFallback = mode === 'picker' && canUseDomFallback();
|
||||
while (el && el !== document.documentElement) {
|
||||
if (el.getAttribute && (el.hasAttribute('data-od-id') || el.hasAttribute('data-screen-label'))) return el;
|
||||
if (!fallback && allowDomFallback && visibleTarget(el)) fallback = el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function selectorFor(el){
|
||||
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
|
||||
if (!id) return null;
|
||||
return el.hasAttribute('data-od-id') ? '[data-od-id="' + esc(id) + '"]' : '[data-screen-label="' + esc(id) + '"]';
|
||||
return fallback;
|
||||
}
|
||||
function applyOverride(elementId, selector, prop, value){
|
||||
if (!elementId || !prop) return;
|
||||
|
|
@ -615,7 +669,7 @@ function injectSelectionBridge(
|
|||
if (!pickerActive()) return;
|
||||
var el = closestTarget(ev);
|
||||
if (!el) return;
|
||||
var payload = targetFrom(el);
|
||||
var payload = targetFrom(el, commentEnabled && mode === 'picker' && !inspectEnabled);
|
||||
if (!payload || payload.elementId === hoveredId) return;
|
||||
hoveredId = payload.elementId;
|
||||
window.parent.postMessage(Object.assign({}, payload, { type: 'od:comment-hover' }), '*');
|
||||
|
|
@ -638,7 +692,7 @@ function injectSelectionBridge(
|
|||
if (!el) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var payload = targetFrom(el);
|
||||
var payload = targetFrom(el, commentEnabled && mode === 'picker' && !inspectEnabled);
|
||||
if (payload) window.parent.postMessage(payload, '*');
|
||||
}, true);
|
||||
// Pod drawing — only active in comment mode with the 'pod' tool.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,30 @@ function extractBridgeScript(srcdoc: string): string {
|
|||
return match[1];
|
||||
}
|
||||
|
||||
function setupBridgeDom(bodyHtml: string, mode: 'inspect' | 'comment') {
|
||||
function markVisible(win: { document: Document }, selector: string): void {
|
||||
const el = win.document.querySelector(selector);
|
||||
if (!el) throw new Error(`selector not found: ${selector}`);
|
||||
Object.defineProperty(el, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 120,
|
||||
height: 48,
|
||||
top: 20,
|
||||
right: 130,
|
||||
bottom: 68,
|
||||
left: 10,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function setupBridgeDom(
|
||||
bodyHtml: string,
|
||||
mode: 'inspect' | 'comment',
|
||||
visibleSelectors: string[] = [],
|
||||
) {
|
||||
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
|
||||
inspectBridge: mode === 'inspect',
|
||||
commentBridge: mode === 'comment',
|
||||
|
|
@ -61,6 +84,7 @@ function setupBridgeDom(bodyHtml: string, mode: 'inspect' | 'comment') {
|
|||
configurable: true,
|
||||
value: { postMessage: parentPostMessage },
|
||||
});
|
||||
visibleSelectors.forEach((selector) => markVisible(win, selector));
|
||||
|
||||
// Run the bridge IIFE inside the jsdom window so its `document` /
|
||||
// `window` refer to our DOM. We don't use `runScripts: 'dangerously'`
|
||||
|
|
@ -148,4 +172,92 @@ describe('selection bridge — empty annotation surface (#890)', () => {
|
|||
expect(clickMessages).toHaveLength(1);
|
||||
expect(clickMessages[0].elementId).toBe('hero');
|
||||
});
|
||||
|
||||
it('does not invent fallback targets in Inspect mode for unannotated elements', async () => {
|
||||
const { win, parentPostMessage } = setupBridgeDom(
|
||||
'<main><button id="cta">Launch</button></main>',
|
||||
'inspect',
|
||||
['#cta'],
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
|
||||
parentPostMessage.mockClear();
|
||||
|
||||
win.document.getElementById('cta')!.dispatchEvent(
|
||||
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
|
||||
const clickMessages = parentPostMessage.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((message) => message?.type === 'od:comment-target');
|
||||
expect(clickMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses a DOM selector fallback for Picker mode when elements are unannotated', async () => {
|
||||
const { win, parentPostMessage } = setupBridgeDom(
|
||||
'<main><button id="cta">Launch</button></main>',
|
||||
'comment',
|
||||
['#cta'],
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
|
||||
parentPostMessage.mockClear();
|
||||
|
||||
win.document.getElementById('cta')!.dispatchEvent(
|
||||
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
|
||||
const clickMessages = parentPostMessage.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((message) => message?.type === 'od:comment-target');
|
||||
expect(clickMessages).toHaveLength(1);
|
||||
expect(clickMessages[0].elementId).toBe('dom:body > main:nth-of-type(1) > button:nth-of-type(1)');
|
||||
expect(clickMessages[0].selector).toBe('body > main:nth-of-type(1) > button:nth-of-type(1)');
|
||||
expect(clickMessages[0].text).toBe('Launch');
|
||||
});
|
||||
|
||||
it('does not use Picker DOM fallback on mixed annotated and unannotated pages', async () => {
|
||||
const { win, parentPostMessage } = setupBridgeDom(
|
||||
'<main><section data-od-id="hero">Hero</section><button id="cta">Launch</button></main>',
|
||||
'comment',
|
||||
['#cta'],
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
|
||||
parentPostMessage.mockClear();
|
||||
|
||||
win.document.getElementById('cta')!.dispatchEvent(
|
||||
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
|
||||
const clickMessages = parentPostMessage.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((message) => message?.type === 'od:comment-target');
|
||||
expect(clickMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('broadcasts DOM fallback targets in comment mode so Pods can hit-test unannotated pages', async () => {
|
||||
const { win, parentPostMessage } = setupBridgeDom(
|
||||
'<main><section id="card"><h2>Card</h2></section></main>',
|
||||
'comment',
|
||||
['#card'],
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
|
||||
|
||||
const targetMessages = parentPostMessage.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((message) => message?.type === 'od:comment-targets');
|
||||
expect(targetMessages.length).toBeGreaterThan(0);
|
||||
const last = targetMessages.at(-1);
|
||||
expect(last.targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
elementId: 'dom:body > main:nth-of-type(1) > section:nth-of-type(1)',
|
||||
selector: 'body > main:nth-of-type(1) > section:nth-of-type(1)',
|
||||
text: 'Card',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue