mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(extension): hover chain node
This commit is contained in:
@@ -25,13 +25,13 @@ function mockRect(
|
||||
});
|
||||
}
|
||||
|
||||
function mockHoverStyle(pointerElements: Element[]): void {
|
||||
function mockHoverStyle(pointerElements: Element[], positionedElements: Element[] = []): void {
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
|
||||
const style = {
|
||||
cursor: pointerElements.includes(el) ? "pointer" : "",
|
||||
display: "block",
|
||||
pointerEvents: "auto",
|
||||
position: "static",
|
||||
position: positionedElements.includes(el) ? "absolute" : "static",
|
||||
visibility: "visible",
|
||||
} as CSSStyleDeclaration;
|
||||
return style;
|
||||
@@ -204,6 +204,7 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-menu", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".user-menu")!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
@@ -216,6 +217,7 @@ describe("record-capture semantic", () => {
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
mockRect(menu, { left: 820, top: 48, width: 160, height: 80 });
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
@@ -244,6 +246,7 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-topbar", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".user-menu")!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
@@ -256,6 +259,7 @@ describe("record-capture semantic", () => {
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
mockRect(menu, { left: 820, top: 48, width: 160, height: 80 });
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
@@ -280,18 +284,10 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-contained-menu", (step) => steps.push(step));
|
||||
const trigger = document.querySelector('[role="button"]')!;
|
||||
const menu = document.querySelector("ul")!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
y: 8,
|
||||
top: 8,
|
||||
left: 900,
|
||||
right: 932,
|
||||
bottom: 40,
|
||||
width: 32,
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
mockRect(trigger, { left: 900, top: 8, width: 32, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 160, height: 80 });
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
@@ -320,6 +316,7 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-topbar-link", (step) => steps.push(step));
|
||||
const trigger = document.querySelector('a[aria-label="image"]')!;
|
||||
const menu = document.querySelector(".user-menu")!;
|
||||
const item = document.querySelector("ul a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
@@ -343,6 +340,7 @@ describe("record-capture semantic", () => {
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
mockRect(menu, { left: 820, top: 48, width: 160, height: 80 });
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
|
||||
const style = {
|
||||
cursor: el === item ? "pointer" : "",
|
||||
@@ -428,8 +426,10 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-menu-opener", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const item = document.querySelector("li")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(item, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockHoverStyle([item]);
|
||||
|
||||
@@ -458,8 +458,10 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-menu-div-action", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const item = document.querySelector(".tg-menu-item")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(item, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockHoverStyle([item]);
|
||||
|
||||
@@ -493,8 +495,10 @@ describe("record-capture semantic", () => {
|
||||
steps.push(step),
|
||||
);
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const item = document.querySelector(".tg-menu-item")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(item, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockHoverStyle([item]);
|
||||
|
||||
@@ -527,8 +531,10 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-infer-opener", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const item = document.querySelector(".tg-menu-item")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(item, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockHoverStyle([trigger, item]);
|
||||
|
||||
@@ -557,9 +563,11 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-false-submenu", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const item = document.querySelector("li")!;
|
||||
const itemInner = document.querySelector("li div")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(item, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockHoverStyle([item, itemInner]);
|
||||
|
||||
@@ -591,14 +599,18 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-cascade", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button[aria-label]")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const submenu = document.querySelector(".template-submenu")!;
|
||||
const nestedTrigger = document.querySelector("li")!;
|
||||
const finalAction = document.querySelector(".template-submenu button")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(nestedTrigger, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 });
|
||||
mockHoverStyle([nestedTrigger, finalAction]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mockRect(submenu, { left: 984, top: 48, width: 160, height: 80 });
|
||||
mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 });
|
||||
mouseOver(nestedTrigger);
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
@@ -618,6 +630,468 @@ describe("record-capture semantic", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("infers the full hover opener chain for nested menu actions", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li tabindex="0" aria-expanded="false">文档C+D</li>
|
||||
</div>
|
||||
<div role="menu" class="format-submenu">
|
||||
<span tabindex="0">Markdown</span>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-nested-infer", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const submenu = document.querySelector(".format-submenu")!;
|
||||
const nestedTrigger = document.querySelector("li")!;
|
||||
const finalAction = document.querySelector("span")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(nestedTrigger, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockHoverStyle([trigger, nestedTrigger, finalAction]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mockRect(submenu, { left: 984, top: 48, width: 160, height: 80 });
|
||||
mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 });
|
||||
mouseOver(nestedTrigger);
|
||||
mouseOver(finalAction);
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { tag: "li", name: "文档C+D" },
|
||||
});
|
||||
expect(steps[2]).toMatchObject({
|
||||
op: "click",
|
||||
target: { tag: "span", name: "Markdown" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the nested hover trigger label without descendant submenu text", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li tabindex="0" aria-haspopup="menu">
|
||||
<span>多维表格</span>
|
||||
<div>
|
||||
<span tabindex="0">表格视图</span>
|
||||
<div tabindex="0">看板视图</div>
|
||||
<span>甘特视图</span>
|
||||
<span>日历视图</span>
|
||||
<span>相册视图</span>
|
||||
<span>架构视图</span>
|
||||
<span>神奇表单</span>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-nested-compact-label", (step) =>
|
||||
steps.push(step),
|
||||
);
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const nestedTrigger = document.querySelector("li")!;
|
||||
const submenu = document.querySelector("li > div")!;
|
||||
const finalAction = document.querySelector("li > div div")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 80 });
|
||||
mockRect(nestedTrigger, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockHoverStyle([trigger, nestedTrigger, finalAction], [submenu]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mockRect(submenu, { left: 984, top: 48, width: 180, height: 220 });
|
||||
mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 });
|
||||
mouseOver(nestedTrigger);
|
||||
mouseOver(finalAction);
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { tag: "li", name: "多维表格" },
|
||||
});
|
||||
expect(JSON.stringify(steps[1]?.target)).not.toContain("看板视图");
|
||||
expect(steps[2]).toMatchObject({
|
||||
op: "click",
|
||||
target: { tag: "div", name: "看板视图" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a stale pass-through shortcut hover claim a later submenu", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-07T06:39:40.000Z"));
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li class="create-menu-item-doc" tabindex="0">
|
||||
<span class="menu-item-label">文档</span>
|
||||
<span class="text-font-tips">C+D</span>
|
||||
</li>
|
||||
<li class="create-menu-item-vika" tabindex="0" aria-haspopup="menu">
|
||||
<div class="t-dropdown__item-content">
|
||||
<span class="menu-item-label">多维表格</span>
|
||||
</div>
|
||||
<div class="t-dropdown__submenu-wrapper">
|
||||
<span tabindex="0">看板视图</span>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-stale-shortcut", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const shortcut = document.querySelector(".text-font-tips")!;
|
||||
const nestedTrigger = document.querySelector(".create-menu-item-vika")!;
|
||||
const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!;
|
||||
const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 160 });
|
||||
mockRect(shortcut, { left: 950, top: 56, width: 29, height: 22 });
|
||||
mockRect(nestedTrigger, { left: 820, top: 92, width: 160, height: 32 });
|
||||
mockHoverStyle([trigger, shortcut, nestedTrigger, finalAction], [submenu]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mouseOver(shortcut);
|
||||
mockRect(submenu, { left: 984, top: 92, width: 160, height: 80 });
|
||||
mockRect(finalAction, { left: 984, top: 92, width: 136, height: 32 });
|
||||
mouseOver(nestedTrigger);
|
||||
vi.runOnlyPendingTimers();
|
||||
mouseOver(finalAction);
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { tag: "li", name: "多维表格" },
|
||||
});
|
||||
expect(JSON.stringify(steps)).not.toContain("C+D");
|
||||
expect(steps[2]).toMatchObject({
|
||||
op: "click",
|
||||
target: { tag: "span", name: "看板视图" },
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let an item inside a newly opened menu claim the parent surface", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-07T07:12:08.000Z"));
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li class="create-menu-item-doc" tabindex="0">文档C+D</li>
|
||||
<li class="create-menu-item-vika" tabindex="0" aria-haspopup="menu">
|
||||
<span>多维表格</span>
|
||||
</li>
|
||||
</div>
|
||||
<div class="t-dropdown__submenu-wrapper">
|
||||
<span tabindex="0">看板视图</span>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-parent-surface-owner", (step) =>
|
||||
steps.push(step),
|
||||
);
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const passThrough = document.querySelector(".create-menu-item-doc")!;
|
||||
const nestedTrigger = document.querySelector(".create-menu-item-vika")!;
|
||||
const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!;
|
||||
const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!;
|
||||
mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 });
|
||||
mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 });
|
||||
mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 });
|
||||
mockHoverStyle([trigger, passThrough, nestedTrigger, finalAction]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mockRect(menu, { left: 8, top: 104, width: 201, height: 439 });
|
||||
mouseOver(passThrough);
|
||||
vi.runOnlyPendingTimers();
|
||||
mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 });
|
||||
mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 });
|
||||
mouseOver(nestedTrigger);
|
||||
vi.runOnlyPendingTimers();
|
||||
mouseOver(finalAction);
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { tag: "li", name: "多维表格" },
|
||||
});
|
||||
expect(JSON.stringify(steps)).not.toContain("文档C+D");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("infers an unowned existing parent surface when a nested submenu is opened", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-07T07:28:10.000Z"));
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li class="create-menu-item-doc" tabindex="0">文档C+D</li>
|
||||
<li class="create-menu-item-vika" tabindex="0" aria-haspopup="menu">
|
||||
<div class="t-dropdown__item-content">
|
||||
<span class="menu-item-label">多维表格</span>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
<div class="t-dropdown__submenu-wrapper">
|
||||
<span tabindex="0">看板视图</span>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-existing-parent-surface", (step) =>
|
||||
steps.push(step),
|
||||
);
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const passThrough = document.querySelector(".create-menu-item-doc")!;
|
||||
const nestedTrigger = document.querySelector(".create-menu-item-vika")!;
|
||||
const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!;
|
||||
const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!;
|
||||
mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 8, top: 104, width: 201, height: 439 });
|
||||
mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 });
|
||||
mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 });
|
||||
mockHoverStyle([trigger, passThrough, nestedTrigger, finalAction]);
|
||||
|
||||
mouseOver(passThrough);
|
||||
mouseOver(trigger);
|
||||
vi.runOnlyPendingTimers();
|
||||
mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 });
|
||||
mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 });
|
||||
mouseOver(nestedTrigger);
|
||||
vi.runOnlyPendingTimers();
|
||||
mouseOver(finalAction);
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { tag: "li", name: "多维表格" },
|
||||
});
|
||||
expect(JSON.stringify(steps)).not.toContain("文档C+D");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns a side submenu to the vertically aligned hover trigger", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-07T06:55:38.000Z"));
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li class="create-menu-item-doc" tabindex="0">
|
||||
<span class="menu-item-label">文档</span>
|
||||
<span class="text-font-tips">C+D</span>
|
||||
</li>
|
||||
<li class="create-menu-item-vika" tabindex="0" aria-haspopup="menu">
|
||||
<div class="t-dropdown__item-content">
|
||||
<span class="menu-item-label">多维表格</span>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
<div class="t-dropdown__submenu-wrapper">
|
||||
<li class="create-submenu-item-vika-kanban" tabindex="0">
|
||||
<span>看板视图</span>
|
||||
</li>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-side-submenu-alignment", (step) =>
|
||||
steps.push(step),
|
||||
);
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const passThrough = document.querySelector(".create-menu-item-doc")!;
|
||||
const nestedTrigger = document.querySelector(".create-menu-item-vika")!;
|
||||
const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!;
|
||||
const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!;
|
||||
mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 8, top: 104, width: 201, height: 439 });
|
||||
mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 });
|
||||
mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 });
|
||||
mockHoverStyle([trigger, passThrough, nestedTrigger, finalAction]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mouseOver(passThrough);
|
||||
mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 });
|
||||
mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 });
|
||||
mouseOver(nestedTrigger);
|
||||
mouseOver(finalAction);
|
||||
vi.runOnlyPendingTimers();
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { tag: "li", name: "多维表格" },
|
||||
});
|
||||
expect(JSON.stringify(steps)).not.toContain("文档C+D");
|
||||
expect(steps[2]).toMatchObject({
|
||||
op: "click",
|
||||
target: { tag: "span", name: "看板视图" },
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("outputs only one parent path for noisy nested menu hover movement", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-07T07:02:36.000Z"));
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li class="create-menu-item-doc" tabindex="0">
|
||||
<span class="menu-item-label">文档</span>
|
||||
<span class="text-font-tips">C+D</span>
|
||||
</li>
|
||||
<li class="create-menu-item-vika" tabindex="0" aria-haspopup="menu">
|
||||
<div class="t-dropdown__item-content" tabindex="0">
|
||||
<span class="menu-item-label">多维表格</span>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
<div class="t-dropdown__submenu-wrapper">
|
||||
<div class="create-submenu-item-vika-kanban" tabindex="0">
|
||||
<div class="create-submenu-item-label" tabindex="0">
|
||||
<span>表格视图</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-noisy-nested-path", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const passThrough = document.querySelector(".create-menu-item-doc")!;
|
||||
const nestedTrigger = document.querySelector(".create-menu-item-vika")!;
|
||||
const nestedInner = document.querySelector(".t-dropdown__item-content")!;
|
||||
const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!;
|
||||
const finalItem = document.querySelector(".create-submenu-item-vika-kanban")!;
|
||||
const finalHoverItem = document.querySelector(".create-submenu-item-label")!;
|
||||
const finalAction = document.querySelector(".create-submenu-item-vika-kanban")!;
|
||||
mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 8, top: 104, width: 201, height: 439 });
|
||||
mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 });
|
||||
mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 });
|
||||
mockRect(nestedInner, { left: 25, top: 234, width: 168, height: 22 });
|
||||
mockHoverStyle([
|
||||
trigger,
|
||||
passThrough,
|
||||
nestedTrigger,
|
||||
nestedInner,
|
||||
finalItem,
|
||||
finalHoverItem,
|
||||
finalAction,
|
||||
]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mouseOver(nestedTrigger);
|
||||
mouseOver(nestedInner);
|
||||
mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 });
|
||||
mockRect(finalItem, { left: 209, top: 257, width: 97, height: 34 });
|
||||
mockRect(finalHoverItem, { left: 209, top: 257, width: 97, height: 34 });
|
||||
mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 });
|
||||
mouseOver(passThrough);
|
||||
mouseOver(nestedInner);
|
||||
mouseOver(finalHoverItem);
|
||||
vi.runOnlyPendingTimers();
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { name: "多维表格" },
|
||||
});
|
||||
expect(JSON.stringify(steps)).not.toContain("文档C+D");
|
||||
expect(JSON.stringify(steps)).not.toContain('"表格视图","tag":"div"');
|
||||
expect(steps.filter((step) => step.op === "hover")).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not infer sibling items in the same surface as hover openers", () => {
|
||||
document.body.innerHTML = `
|
||||
<button type="button">新建</button>
|
||||
<div class="create-menu dropdown-menu">
|
||||
<li tabindex="0" aria-expanded="false">文档C+D</li>
|
||||
<span tabindex="0">Markdown</span>
|
||||
</div>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-same-surface-action", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".create-menu")!;
|
||||
const category = document.querySelector("li")!;
|
||||
const finalAction = document.querySelector("span")!;
|
||||
mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 });
|
||||
mockRect(menu, { left: 820, top: 48, width: 320, height: 80 });
|
||||
mockRect(category, { left: 820, top: 48, width: 160, height: 32 });
|
||||
mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 });
|
||||
mockHoverStyle([trigger, category, finalAction]);
|
||||
|
||||
mouseOver(trigger);
|
||||
mouseOver(category);
|
||||
mouseOver(finalAction);
|
||||
click(finalAction);
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
target: { role: "button", name: "新建" },
|
||||
});
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "click",
|
||||
target: { tag: "span", name: "Markdown" },
|
||||
});
|
||||
});
|
||||
|
||||
it("records avatar div hover with a semantic image target", () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="tg-avatar tg-avatar--img tg-avatar--shape-hexagon">
|
||||
@@ -630,6 +1104,7 @@ describe("record-capture semantic", () => {
|
||||
const capture = startRecordCapture("rec-hover-avatar-div", (step) => steps.push(step));
|
||||
const trigger = document.querySelector(".tg-avatar")!;
|
||||
const image = document.querySelector("img")!;
|
||||
const menu = document.querySelector(".user-menu")!;
|
||||
const item = document.querySelector("a")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
@@ -653,6 +1128,7 @@ describe("record-capture semantic", () => {
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
mockRect(menu, { left: 820, top: 48, width: 160, height: 80 });
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
@@ -717,6 +1193,7 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-fill-surface", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".user-menu")!;
|
||||
const input = document.querySelector("input")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
@@ -729,6 +1206,7 @@ describe("record-capture semantic", () => {
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 96 });
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
input.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
@@ -762,6 +1240,7 @@ describe("record-capture semantic", () => {
|
||||
`;
|
||||
const capture = startRecordCapture("rec-hover-select-surface", (step) => steps.push(step));
|
||||
const trigger = document.querySelector("button")!;
|
||||
const menu = document.querySelector(".user-menu")!;
|
||||
const select = document.querySelector("select")!;
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 900,
|
||||
@@ -774,6 +1253,7 @@ describe("record-capture semantic", () => {
|
||||
height: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
mockRect(menu, { left: 820, top: 48, width: 180, height: 96 });
|
||||
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
select.value = "away";
|
||||
|
||||
@@ -28,7 +28,13 @@ import {
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import { shouldRecordPress } from "@/lib/trace-reducer";
|
||||
import { closestRecognizedHoverSurface, decideHoverSurfaceRelation } from "./record-hover-surface";
|
||||
import {
|
||||
closestHoverSurfaceCandidate,
|
||||
collectHoverSurfaceStates,
|
||||
decideHoverSurfaceRelation,
|
||||
type HoverSurfaceState,
|
||||
isHoverSurfaceCandidateElement,
|
||||
} from "./record-hover-surface";
|
||||
|
||||
const pendingStepSends = new Map<string, Set<Promise<boolean>>>();
|
||||
const failedStepDeliveries = new Set<string>();
|
||||
@@ -68,16 +74,23 @@ interface HoverCandidate {
|
||||
target: TargetDescriptor;
|
||||
recordedAt: number;
|
||||
score: number;
|
||||
hasExpansionSignal: boolean;
|
||||
eligible: boolean;
|
||||
}
|
||||
|
||||
interface HoverSurfaceNode {
|
||||
element: Element;
|
||||
signature: string;
|
||||
owner: HoverCandidate;
|
||||
parent?: HoverSurfaceNode;
|
||||
}
|
||||
|
||||
type FillableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
|
||||
|
||||
const HOVER_BEFORE_CLICK_MAX_MS = 10_000;
|
||||
const HOVER_SURFACE_CONTEXT_MAX_MS = 30_000;
|
||||
const HOVER_REPLACE_SCORE_MARGIN = 50;
|
||||
const HOVER_CANDIDATE_LIMIT = 24;
|
||||
const HOVER_TRIGGER_LABEL_MAX = 48;
|
||||
|
||||
function eventTarget(event: Event): EventTarget | null {
|
||||
return event.composedPath()[0] ?? event.target;
|
||||
@@ -191,6 +204,51 @@ function hasHoverGraphicDescendant(el: Element): boolean {
|
||||
return el.querySelector("img,svg,use,path,i") !== null;
|
||||
}
|
||||
|
||||
function normalizeLabelText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncateLabel(value: string, max = HOVER_TRIGGER_LABEL_MAX): string {
|
||||
const normalized = normalizeLabelText(value);
|
||||
if (normalized.length <= max) return normalized;
|
||||
return `${normalized.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
function collectHoverTriggerLabelText(root: Element): string {
|
||||
let text = "";
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += ` ${node.textContent ?? ""}`;
|
||||
return;
|
||||
}
|
||||
if (!(node instanceof Element)) return;
|
||||
if (node !== root && isHoverSurfaceCandidateElement(node)) {
|
||||
return;
|
||||
}
|
||||
for (const child of node.childNodes) visit(child);
|
||||
};
|
||||
for (const child of root.childNodes) visit(child);
|
||||
return normalizeLabelText(text);
|
||||
}
|
||||
|
||||
function compactHoverTargetName(el: Element, desc: TargetDescriptor): TargetDescriptor {
|
||||
if (!desc.name) return desc;
|
||||
const fullText = normalizeLabelText(el.textContent ?? "");
|
||||
const compactName = collectHoverTriggerLabelText(el);
|
||||
if (!compactName || compactName === desc.name) return desc;
|
||||
const compactComparable = compactName.replace(/\s+/g, "");
|
||||
const descComparable = desc.name.replace(/…$/, "").replace(/\s+/g, "");
|
||||
const fullTextComparable = fullText.replace(/\s+/g, "");
|
||||
if (
|
||||
descComparable.startsWith(compactComparable) &&
|
||||
fullTextComparable.startsWith(descComparable) &&
|
||||
compactName.length < desc.name.length
|
||||
) {
|
||||
return { ...desc, name: truncateLabel(compactName) };
|
||||
}
|
||||
return desc;
|
||||
}
|
||||
|
||||
function isWeakHoverTarget(target: TargetDescriptor): boolean {
|
||||
return !target.role && !target.name && target.tag === "div";
|
||||
}
|
||||
@@ -245,13 +303,17 @@ function hoverCandidateFromEvent(target: EventTarget | null): HoverCandidate | n
|
||||
const hasDirectSignal = hasDirectHoverInteractiveSignal(signals);
|
||||
if (!decision.eligible && !hasExpansionSignal && !hasDirectSignal) return null;
|
||||
if (!decision.eligible && !desc.name && !desc.role) return null;
|
||||
const normalizedTarget = normalizeHoverTarget(element, desc, decision);
|
||||
const normalizedTarget = normalizeHoverTarget(
|
||||
element,
|
||||
compactHoverTargetName(element, desc),
|
||||
decision,
|
||||
);
|
||||
const now = Date.now();
|
||||
return {
|
||||
element,
|
||||
target: normalizedTarget,
|
||||
recordedAt: Date.now(),
|
||||
recordedAt: now,
|
||||
score: decision.score,
|
||||
hasExpansionSignal,
|
||||
eligible: decision.eligible,
|
||||
};
|
||||
}
|
||||
@@ -321,6 +383,8 @@ export function startRecordCapture(
|
||||
let pendingHover: HoverCandidate | null = null;
|
||||
const recentHoverCandidates: HoverCandidate[] = [];
|
||||
const emittedHoverElements = new WeakSet<Element>();
|
||||
let hoverSurfaceStates = new Map<Element, string>();
|
||||
const hoverSurfaceNodes = new Map<Element, HoverSurfaceNode>();
|
||||
let generatedControlClick: Element | null = null;
|
||||
let navigationActionPending = false;
|
||||
let navigationActionVersion = 0;
|
||||
@@ -397,25 +461,126 @@ export function startRecordCapture(
|
||||
}
|
||||
};
|
||||
|
||||
const actionIsOnHoverTriggerItself = (
|
||||
hover: HoverCandidate,
|
||||
actionElement: Element | null,
|
||||
relationTarget: Element,
|
||||
): boolean => {
|
||||
const target = actionElement ?? relationTarget;
|
||||
if (target === hover.element) return true;
|
||||
if (!hover.element.contains(target)) return false;
|
||||
const surface = closestRecognizedHoverSurface(target);
|
||||
return !surface || surface === hover.element || !hover.element.contains(surface);
|
||||
const surfaceArea = (el: Element): number => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width * rect.height;
|
||||
};
|
||||
|
||||
const hoverRelatedToAction = (
|
||||
hover: HoverCandidate,
|
||||
relationTarget: Element,
|
||||
const smallestContaining = <T>(
|
||||
target: Element,
|
||||
items: Iterable<T>,
|
||||
elementFor: (item: T) => Element,
|
||||
): T | undefined => {
|
||||
let best: T | undefined;
|
||||
for (const item of items) {
|
||||
const element = elementFor(item);
|
||||
if (!element.contains(target)) continue;
|
||||
if (!best || surfaceArea(element) < surfaceArea(elementFor(best))) {
|
||||
best = item;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const ownedSurfaceContaining = (target: Element): HoverSurfaceNode | undefined =>
|
||||
smallestContaining(target, hoverSurfaceNodes.values(), (node) => node.element);
|
||||
|
||||
const surfaceStateContaining = (
|
||||
target: Element,
|
||||
states: HoverSurfaceState[],
|
||||
): HoverSurfaceState | undefined => smallestContaining(target, states, (state) => state.element);
|
||||
|
||||
const ownedSurfaceForAction = (target: Element): HoverSurfaceNode | undefined => {
|
||||
const closestSurface = closestHoverSurfaceCandidate(target);
|
||||
if (closestSurface) {
|
||||
const owned = hoverSurfaceNodes.get(closestSurface);
|
||||
if (owned) return owned;
|
||||
}
|
||||
return ownedSurfaceContaining(target);
|
||||
};
|
||||
|
||||
const pruneGoneHoverSurfaces = (currentStates: HoverSurfaceState[]) => {
|
||||
const current = new Set(currentStates.map((state) => state.element));
|
||||
for (const element of hoverSurfaceNodes.keys()) {
|
||||
if (!current.has(element)) hoverSurfaceNodes.delete(element);
|
||||
}
|
||||
};
|
||||
|
||||
const inferOwnerForParentSurface = (
|
||||
surface: Element,
|
||||
childOwner: HoverCandidate,
|
||||
now: number,
|
||||
): boolean => {
|
||||
if (now - hover.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) return false;
|
||||
return decideHoverSurfaceRelation({ triggerElement: hover.element }, relationTarget).related;
|
||||
): HoverCandidate | undefined => {
|
||||
for (let index = recentHoverCandidates.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = recentHoverCandidates[index];
|
||||
if (!candidate || candidate.element === childOwner.element) continue;
|
||||
if (surface.contains(candidate.element)) continue;
|
||||
if (candidate.recordedAt > childOwner.recordedAt) continue;
|
||||
if (now - candidate.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue;
|
||||
const relation = decideHoverSurfaceRelation({ triggerElement: candidate.element }, surface);
|
||||
if (relation.related) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parentSurfaceNodeForOwner = (
|
||||
owner: HoverCandidate,
|
||||
currentStates: HoverSurfaceState[],
|
||||
now: number,
|
||||
): HoverSurfaceNode | undefined => {
|
||||
const ownedParent = ownedSurfaceContaining(owner.element);
|
||||
if (ownedParent) return ownedParent;
|
||||
const parentState = surfaceStateContaining(owner.element, currentStates);
|
||||
if (!parentState) return undefined;
|
||||
const parentOwner = inferOwnerForParentSurface(parentState.element, owner, now);
|
||||
if (!parentOwner) return undefined;
|
||||
const parentNode: HoverSurfaceNode = {
|
||||
element: parentState.element,
|
||||
signature: parentState.signature,
|
||||
owner: parentOwner,
|
||||
};
|
||||
hoverSurfaceNodes.set(parentState.element, parentNode);
|
||||
return parentNode;
|
||||
};
|
||||
|
||||
const bindOwnedHoverSurfaces = (
|
||||
owner: HoverCandidate,
|
||||
previousStates: Map<Element, string>,
|
||||
currentStates: HoverSurfaceState[],
|
||||
now: number,
|
||||
) => {
|
||||
const parent = parentSurfaceNodeForOwner(owner, currentStates, now);
|
||||
for (const state of currentStates) {
|
||||
if (previousStates.get(state.element) === state.signature) continue;
|
||||
if (state.element.contains(owner.element)) continue;
|
||||
if (parent?.element === state.element) continue;
|
||||
const relation = decideHoverSurfaceRelation({ triggerElement: owner.element }, state.element);
|
||||
if (!relation.related || now - owner.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue;
|
||||
hoverSurfaceNodes.set(state.element, {
|
||||
element: state.element,
|
||||
signature: state.signature,
|
||||
owner,
|
||||
...(parent ? { parent } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCausedHoverSurfaces = (
|
||||
owner: HoverCandidate,
|
||||
previousStates = hoverSurfaceStates,
|
||||
) => {
|
||||
const now = Date.now();
|
||||
const currentStates = collectHoverSurfaceStates();
|
||||
pruneGoneHoverSurfaces(currentStates);
|
||||
bindOwnedHoverSurfaces(owner, previousStates, currentStates, now);
|
||||
hoverSurfaceStates = new Map(currentStates.map((state) => [state.element, state.signature]));
|
||||
};
|
||||
|
||||
const scheduleCausedHoverSurfaceRefresh = (
|
||||
hover: HoverCandidate,
|
||||
previousStates: Map<Element, string>,
|
||||
) => {
|
||||
setTimeout(() => refreshCausedHoverSurfaces(hover, previousStates), 0);
|
||||
};
|
||||
|
||||
const emitHoverStep = (hover: HoverCandidate) => {
|
||||
@@ -427,25 +592,31 @@ export function startRecordCapture(
|
||||
emittedHoverElements.add(hover.element);
|
||||
};
|
||||
|
||||
const findHoverForAction = (
|
||||
actionElement: Element | null,
|
||||
relationTarget: Element,
|
||||
const surfaceOwnerChain = (
|
||||
surface: HoverSurfaceNode,
|
||||
actionElement: Element,
|
||||
now: number,
|
||||
): HoverCandidate | null => {
|
||||
const candidates = pendingHover
|
||||
? [
|
||||
...recentHoverCandidates.filter((hover) => hover.element !== pendingHover?.element),
|
||||
pendingHover,
|
||||
]
|
||||
: recentHoverCandidates;
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const hover = candidates[index];
|
||||
if (!hover) continue;
|
||||
if (emittedHoverElements.has(hover.element)) continue;
|
||||
if (actionIsOnHoverTriggerItself(hover, actionElement, relationTarget)) continue;
|
||||
if (hoverRelatedToAction(hover, relationTarget, now)) return hover;
|
||||
): HoverCandidate[] => {
|
||||
const chain: HoverCandidate[] = [];
|
||||
const selected = new WeakSet<Element>();
|
||||
const surfaces: HoverSurfaceNode[] = [];
|
||||
let node: HoverSurfaceNode | undefined = surface;
|
||||
while (node && surfaces.length < 4) {
|
||||
surfaces.unshift(node);
|
||||
node = node.parent;
|
||||
}
|
||||
return null;
|
||||
for (const owned of surfaces) {
|
||||
const owner = owned.owner;
|
||||
if (owner.element === actionElement || actionElement.contains(owner.element)) {
|
||||
continue;
|
||||
}
|
||||
if (selected.has(owner.element)) continue;
|
||||
if (emittedHoverElements.has(owner.element)) continue;
|
||||
if (now - owner.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue;
|
||||
selected.add(owner.element);
|
||||
chain.push(owner);
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
const emitClick = (event: MouseEvent) => {
|
||||
@@ -462,14 +633,14 @@ export function startRecordCapture(
|
||||
|
||||
const emitHoverCandidateBeforeAction = (actionTarget: EventTarget | null) => {
|
||||
if (!(actionTarget instanceof Element)) return;
|
||||
const actionElement = resolveClickableElement(actionTarget);
|
||||
const relationTarget = actionElement ?? actionTarget;
|
||||
const hover = findHoverForAction(actionElement, relationTarget, Date.now());
|
||||
if (!hover) {
|
||||
const actionElement = resolveClickableElement(actionTarget) ?? actionTarget;
|
||||
const surface = ownedSurfaceForAction(actionElement);
|
||||
const hoverChain = surface ? surfaceOwnerChain(surface, actionElement, Date.now()) : [];
|
||||
if (hoverChain.length === 0) {
|
||||
pendingHover = null;
|
||||
return;
|
||||
}
|
||||
emitHoverStep(hover);
|
||||
for (const hover of hoverChain) emitHoverStep(hover);
|
||||
pendingHover = null;
|
||||
};
|
||||
|
||||
@@ -482,21 +653,16 @@ export function startRecordCapture(
|
||||
pendingHover = null;
|
||||
}
|
||||
const candidate = hoverCandidateFromEvent(target);
|
||||
if (candidate) rememberHoverCandidate(candidate);
|
||||
if (candidate) {
|
||||
const previousSurfaceStates = new Map(hoverSurfaceStates);
|
||||
rememberHoverCandidate(candidate);
|
||||
refreshCausedHoverSurfaces(candidate, previousSurfaceStates);
|
||||
scheduleCausedHoverSurfaceRefresh(candidate, previousSurfaceStates);
|
||||
}
|
||||
const hover = candidate?.eligible ? candidate : null;
|
||||
if (pendingHover && target instanceof Element) {
|
||||
const relation = decideHoverSurfaceRelation({ triggerElement: pendingHover.element }, target);
|
||||
if (relation.related && now - pendingHover.recordedAt <= HOVER_BEFORE_CLICK_MAX_MS) {
|
||||
if (!hover || !hover.hasExpansionSignal) return;
|
||||
if (
|
||||
hover.element === pendingHover.element ||
|
||||
pendingHover.element.contains(hover.element)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
emitHoverStep(pendingHover);
|
||||
pendingHover = hover;
|
||||
rememberHoverCandidate(hover);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -505,7 +671,6 @@ export function startRecordCapture(
|
||||
return;
|
||||
}
|
||||
pendingHover = hover;
|
||||
rememberHoverCandidate(hover);
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface HoverSurfaceContext {
|
||||
triggerElement: Element;
|
||||
}
|
||||
|
||||
export interface HoverSurfaceState {
|
||||
element: Element;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const HOVER_SURFACE_SELECTOR = [
|
||||
'[role="menu"]',
|
||||
'[role="menubar"]',
|
||||
@@ -22,8 +27,11 @@ const HOVER_SURFACE_SELECTOR = [
|
||||
].join(",");
|
||||
|
||||
const HOVER_SURFACE_WORD_RE =
|
||||
/\b(menu|dropdown|drop-down|popover|popper|popup|tooltip|flyout|submenu|context-menu|account-menu|user-menu|avatar-menu)\b/i;
|
||||
/(^|[^a-z0-9])(menu|dropdown|drop-down|popover|popper|popup|tooltip|flyout|submenu|context-menu|account-menu|user-menu|avatar-menu)(?=[^a-z0-9]|$)/i;
|
||||
const HOVER_SURFACE_ITEM_RE =
|
||||
/(^|[^a-z0-9])((dropdown|menu|submenu|context-menu)-?item|item-(text|label|content)|item__?(text|label|content))(?=[^a-z0-9]|$)/i;
|
||||
const HOVER_SURFACE_MAX_AXIS_GAP = 96;
|
||||
const HOVER_SURFACE_MAX_DIAGONAL_GAP = 16;
|
||||
const HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO = 0.5;
|
||||
|
||||
function elementTokenText(el: Element): string {
|
||||
@@ -40,9 +48,12 @@ function elementTokenText(el: Element): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function isHoverSurfaceElement(el: Element): boolean {
|
||||
export function isRecognizedHoverSurfaceElement(el: Element): boolean {
|
||||
if (el.matches(HOVER_SURFACE_SELECTOR)) return true;
|
||||
if (HOVER_SURFACE_WORD_RE.test(elementTokenText(el))) return true;
|
||||
const tokenText = elementTokenText(el);
|
||||
if (HOVER_SURFACE_WORD_RE.test(tokenText) && !HOVER_SURFACE_ITEM_RE.test(tokenText)) {
|
||||
return true;
|
||||
}
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if ((tag === "ul" || tag === "ol") && el.querySelector("a,button,[role='menuitem']")) {
|
||||
return HOVER_SURFACE_WORD_RE.test(elementTokenText(el.parentElement ?? el));
|
||||
@@ -50,18 +61,27 @@ function isHoverSurfaceElement(el: Element): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVisibleSurfaceElement(el: Element): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") {
|
||||
return false;
|
||||
}
|
||||
return rectFor(el) !== null;
|
||||
}
|
||||
|
||||
export function closestRecognizedHoverSurface(element: Element): Element | null {
|
||||
let node: Element | null = element;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
|
||||
if (isHoverSurfaceElement(node)) return node;
|
||||
if (isRecognizedHoverSurfaceElement(node)) return node;
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPositionedFloatingElement(el: Element): boolean {
|
||||
export function isPositionedFloatingElement(el: Element): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = getComputedStyle(el);
|
||||
if (!["absolute", "fixed"].includes(style.position)) return false;
|
||||
@@ -77,6 +97,21 @@ function isPositionedFloatingElement(el: Element): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isHoverSurfaceCandidateElement(el: Element): boolean {
|
||||
return isRecognizedHoverSurfaceElement(el) || isPositionedFloatingElement(el);
|
||||
}
|
||||
|
||||
export function closestHoverSurfaceCandidate(element: Element): Element | null {
|
||||
let node: Element | null = element;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
|
||||
if (isHoverSurfaceCandidateElement(node)) return node;
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function closestFloatingSurface(clickElement: Element): Element | null {
|
||||
let node: Element | null = clickElement;
|
||||
let depth = 0;
|
||||
@@ -88,6 +123,26 @@ function closestFloatingSurface(clickElement: Element): Element | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function hoverSurfaceSignature(el: Element): string {
|
||||
const rect = rectFor(el);
|
||||
return [
|
||||
rect
|
||||
? `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)},${Math.round(rect.height)}`
|
||||
: "",
|
||||
(el.textContent ?? "").trim().slice(0, 512),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function collectHoverSurfaceStates(): HoverSurfaceState[] {
|
||||
const states: HoverSurfaceState[] = [];
|
||||
for (const el of document.querySelectorAll("*")) {
|
||||
if (isHoverSurfaceCandidateElement(el) && isVisibleSurfaceElement(el)) {
|
||||
states.push({ element: el, signature: hoverSurfaceSignature(el) });
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
function rectFor(el: Element): DOMRect | null {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
@@ -98,6 +153,10 @@ function isNearTrigger(surface: Element, trigger: Element): boolean {
|
||||
const surfaceRect = rectFor(surface);
|
||||
const triggerRect = rectFor(trigger);
|
||||
if (!surfaceRect || !triggerRect) return true;
|
||||
const horizontalOverlap =
|
||||
Math.min(triggerRect.right, surfaceRect.right) - Math.max(triggerRect.left, surfaceRect.left);
|
||||
const verticalOverlap =
|
||||
Math.min(triggerRect.bottom, surfaceRect.bottom) - Math.max(triggerRect.top, surfaceRect.top);
|
||||
const horizontalGap = Math.max(
|
||||
0,
|
||||
triggerRect.left - surfaceRect.right,
|
||||
@@ -108,7 +167,11 @@ function isNearTrigger(surface: Element, trigger: Element): boolean {
|
||||
triggerRect.top - surfaceRect.bottom,
|
||||
surfaceRect.top - triggerRect.bottom,
|
||||
);
|
||||
return horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP && verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP;
|
||||
if (horizontalOverlap > 0 && verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
|
||||
if (verticalOverlap > 0 && horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
|
||||
return (
|
||||
horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP && verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP
|
||||
);
|
||||
}
|
||||
|
||||
export function decideHoverSurfaceRelation(
|
||||
|
||||
Reference in New Issue
Block a user