fix(web): keep tweaks selection usable without annotations (#1268)

This commit is contained in:
PerishFire 2026-05-11 20:06:49 +08:00 committed by GitHub
parent 8962088c75
commit 1eb20e3807
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 180 additions and 14 deletions

View file

@ -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.

View file

@ -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',
}),
]),
);
});
});