@greypan/web-ui 10.1.1 → 10.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -33,6 +33,13 @@ export declare class WebUiContextMenu extends LitElement {
|
|
|
33
33
|
* 因此在窗口内吸收这两个补发事件;窗口由下一次 `pointerdown` 关闭。
|
|
34
34
|
*/
|
|
35
35
|
private _longPressOpenedAt;
|
|
36
|
+
/**
|
|
37
|
+
* 长按手势结束(pointerup)的时刻,补发事件窗口的起点。
|
|
38
|
+
*
|
|
39
|
+
* 为 null 表示「这次长按的手势还没结束」——窗口退回以菜单打开时刻为起点。手势被
|
|
40
|
+
* pointercancel 打断、或组件在抬手前就被卸载时都会走到这条兜底,行为与此前一致。
|
|
41
|
+
*/
|
|
42
|
+
private _longPressGestureEndedAt;
|
|
36
43
|
private readonly _submenuPositionEpochs;
|
|
37
44
|
private _menu?;
|
|
38
45
|
/**
|
|
@@ -71,10 +78,11 @@ export declare class WebUiContextMenu extends LitElement {
|
|
|
71
78
|
*/
|
|
72
79
|
private _closeSource;
|
|
73
80
|
/**
|
|
74
|
-
*
|
|
81
|
+
* 触发「菜单项激活」的那次事件对象;作用域与理由见 `_onMenuItemActivation`。
|
|
75
82
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
83
|
+
* 存**事件**而不是布尔标志:判定「这次 `close()` 是不是发生在这一次派发里」可以问事件自己
|
|
84
|
+
* (`eventPhase !== 0`),不必用定时器去近似。布尔标志只能靠「派发结束时清掉」来划界,
|
|
85
|
+
* 而划界的那一步必须与派发末尾落在同一个任务、同一个微任务检查点之后——那是个会错的假设。
|
|
78
86
|
*
|
|
79
87
|
* 存在的理由是 `close()` 的出顶层判定不能只看 `_closeSource`:消费方完全可以在自己的
|
|
80
88
|
* 菜单项回调里直接调 `close()`(interweave 的右键重命名就是 `emit` 之后自己关),
|
|
@@ -84,7 +92,7 @@ export declare class WebUiContextMenu extends LitElement {
|
|
|
84
92
|
* 消费方先关、组件后关的路径(`_onMenuClick` 的 `_closeFromUser('menu-item')`)随后看到的是
|
|
85
93
|
* `'menu-item'`,两条判定同向,不会互相抵消。
|
|
86
94
|
*/
|
|
87
|
-
private
|
|
95
|
+
private _menuItemActivationEvent;
|
|
88
96
|
/** 根面板的开启会话句柄;未开启时为 null。查询走它。 */
|
|
89
97
|
private _menuHandle;
|
|
90
98
|
/**
|
|
@@ -123,6 +131,19 @@ export declare class WebUiContextMenu extends LitElement {
|
|
|
123
131
|
* @returns 无返回值;命令式关闭不派发 `open-change` 事件。
|
|
124
132
|
*/
|
|
125
133
|
close(): void;
|
|
134
|
+
/**
|
|
135
|
+
* 「当前是否正处在一次可激活菜单项的点击派发里」。
|
|
136
|
+
*
|
|
137
|
+
* 判据是 `eventPhase !== 0`(DOM 规范:派发开始前置 CAPTURING_PHASE,结束后由派发算法的
|
|
138
|
+
* terminating 步骤置回 NONE),即**事件自己知道它此刻有没有在派发中**。于是作用域精确等于
|
|
139
|
+
* 「这一次派发」,既不需要定时器也不需要猜微任务检查点的位置:
|
|
140
|
+
*
|
|
141
|
+
* - 消费方在条目自己的监听器里同步调 `close()` → 派发未结束 → 判定为真(要的就是这个)。
|
|
142
|
+
* - 消费方 `stopPropagation()` 让菜单留着 → 派发照样结束 → 之后的 Escape / 点外面 / 程序式
|
|
143
|
+
* `close()` 一律判定为假,README 承诺的「那三条关闭不抢焦点」不会被这一次点击污染。
|
|
144
|
+
* - 消费方把 `close()` 推到下一个任务 → 判定为假,退回不提前出顶层(与没有这个标记时一致)。
|
|
145
|
+
*/
|
|
146
|
+
private _isDispatchingMenuItemActivation;
|
|
126
147
|
private readonly _closeFromUser;
|
|
127
148
|
private _scheduleRefresh;
|
|
128
149
|
private _refreshMenu;
|
|
@@ -260,6 +281,8 @@ export declare class WebUiContextMenu extends LitElement {
|
|
|
260
281
|
private _cancelLongPress;
|
|
261
282
|
/** 该事件是否是长按开菜单之后、浏览器为同一次触摸补发的事件。 */
|
|
262
283
|
private _isLongPressFollowUp;
|
|
284
|
+
/** 结束「同一次手势的补发事件」窗口。与 _longPressOpenedAt 的清零同源,两处都要清。 */
|
|
285
|
+
private _clearLongPressFollowUp;
|
|
263
286
|
private _onContextMenu;
|
|
264
287
|
private _onContextMenuOutside;
|
|
265
288
|
private _onKeydown;
|
|
@@ -18,7 +18,7 @@ import { computePosition as I, flip as L, shift as R } from "@floating-ui/dom";
|
|
|
18
18
|
//#region src/components/context-menu/index.ts
|
|
19
19
|
var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wui-menu-scrim", G = 5, K = 8, q = class extends A {
|
|
20
20
|
constructor(...e) {
|
|
21
|
-
super(...e), this.disabled = !1, this.noScrollLock = !1, this.longPress = !1, this._longPressDelay = B, this._isOpen = !1, this._x = 0, this._y = 0, this._activeSubmenus = [], this._activeSubmenuItems = [], this._longPressOpenedAt = null, this._submenuPositionEpochs = /* @__PURE__ */ new WeakMap(), this._centerOnPressPoint = !1, this._closeSource = "default", this.
|
|
21
|
+
super(...e), this.disabled = !1, this.noScrollLock = !1, this.longPress = !1, this._longPressDelay = B, this._isOpen = !1, this._x = 0, this._y = 0, this._activeSubmenus = [], this._activeSubmenuItems = [], this._longPressOpenedAt = null, this._longPressGestureEndedAt = null, this._submenuPositionEpochs = /* @__PURE__ */ new WeakMap(), this._centerOnPressPoint = !1, this._closeSource = "default", this._menuItemActivationEvent = null, this._menuHandle = null, this._submenuHandles = /* @__PURE__ */ new Map(), this._outsideClickGuard = C(this, (e) => this._menuHandle?.contains(e) ?? !1), this._closingSubmenus = x({
|
|
22
22
|
getPanel: (e) => e.panel,
|
|
23
23
|
restoreItems: (e, t) => this._restoreSubmenuItems(e, t),
|
|
24
24
|
dispose: (e) => {
|
|
@@ -59,7 +59,7 @@ var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wu
|
|
|
59
59
|
}, this._onScrimCancel = (e) => {
|
|
60
60
|
e.preventDefault();
|
|
61
61
|
}, this._onPointerDown = (e) => {
|
|
62
|
-
this.
|
|
62
|
+
this._clearLongPressFollowUp(), this.longPress && !this.disabled && e.pointerType === "touch" && (this._cancelLongPress(), this._longPressOrigin = {
|
|
63
63
|
x: e.clientX,
|
|
64
64
|
y: e.clientY
|
|
65
65
|
}, this._longPressTimer = setTimeout(() => {
|
|
@@ -72,7 +72,7 @@ var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wu
|
|
|
72
72
|
let t = this._longPressOrigin;
|
|
73
73
|
t && Math.hypot(e.clientX - t.x, e.clientY - t.y) > H && this._cancelLongPress();
|
|
74
74
|
}, this._onPointerUp = (e) => {
|
|
75
|
-
e.pointerType === "touch" && this._cancelLongPress();
|
|
75
|
+
e.pointerType === "touch" && (this._longPressOpenedAt !== null && this._longPressGestureEndedAt === null && (this._longPressGestureEndedAt = performance.now()), this._cancelLongPress());
|
|
76
76
|
}, this._onPointerCancel = () => {
|
|
77
77
|
this._cancelLongPress();
|
|
78
78
|
}, this._onContextMenu = (e) => {
|
|
@@ -97,9 +97,7 @@ var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wu
|
|
|
97
97
|
}
|
|
98
98
|
}, this._onMenuItemActivation = (e) => {
|
|
99
99
|
let t = m(e);
|
|
100
|
-
!t || t.hasAttribute("disabled") || t.hasAttribute("submenu") || (this.
|
|
101
|
-
this._menuItemActivated = !1;
|
|
102
|
-
}));
|
|
100
|
+
!t || t.hasAttribute("disabled") || t.hasAttribute("submenu") || (this._menuItemActivationEvent = e);
|
|
103
101
|
}, this._onMenuClick = (e) => {
|
|
104
102
|
let t = m(e);
|
|
105
103
|
if (t && !t.hasAttribute("disabled")) {
|
|
@@ -140,7 +138,7 @@ var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wu
|
|
|
140
138
|
}), document.addEventListener("keydown", this._onDocumentKeydown);
|
|
141
139
|
}
|
|
142
140
|
disconnectedCallback() {
|
|
143
|
-
super.disconnectedCallback(), this.removeEventListener("contextmenu", this._onContextMenu), this.removeEventListener("keydown", this._onKeydown), this.removeEventListener("click", this._onMenuClick), this.removeEventListener("pointerdown", this._onPointerDown), this.removeEventListener("pointermove", this._onPointerMove), this.removeEventListener("pointerup", this._onPointerUp), this.removeEventListener("pointercancel", this._onPointerCancel), document.removeEventListener("contextmenu", this._onContextMenuOutside), document.removeEventListener("wheel", this._onWheel, !0), document.removeEventListener("touchmove", this._onTouchMove, !0), document.removeEventListener("keydown", this._onDocumentKeydown), this._contentObserver.disconnect(), this._menuPositionGeneration.invalidate(), this._outsideClickGuard.dispose(), this._hoverBinder.dispose(), this._scrollLock.release(), this._menuHandle?.release(), this._menuHandle = null, this._returnItemsToSlot(), this._menu?.panel.remove(), this._menu = void 0, this._closeSubmenusFrom(0, !0), this._closingSubmenus.restoreAll(), this._closeScrim(), this._isOpen = !1, this._cancelLongPress(), this._longPressOpenedAt = null;
|
|
141
|
+
super.disconnectedCallback(), this.removeEventListener("contextmenu", this._onContextMenu), this.removeEventListener("keydown", this._onKeydown), this.removeEventListener("click", this._onMenuClick), this.removeEventListener("pointerdown", this._onPointerDown), this.removeEventListener("pointermove", this._onPointerMove), this.removeEventListener("pointerup", this._onPointerUp), this.removeEventListener("pointercancel", this._onPointerCancel), document.removeEventListener("contextmenu", this._onContextMenuOutside), document.removeEventListener("wheel", this._onWheel, !0), document.removeEventListener("touchmove", this._onTouchMove, !0), document.removeEventListener("keydown", this._onDocumentKeydown), this._contentObserver.disconnect(), this._menuPositionGeneration.invalidate(), this._outsideClickGuard.dispose(), this._hoverBinder.dispose(), this._scrollLock.release(), this._menuHandle?.release(), this._menuHandle = null, this._returnItemsToSlot(), this._menu?.panel.remove(), this._menu = void 0, this._closeSubmenusFrom(0, !0), this._closingSubmenus.restoreAll(), this._closeScrim(), this._isOpen = !1, this._cancelLongPress(), this._longPressOpenedAt = null, this._longPressGestureEndedAt = null;
|
|
144
142
|
}
|
|
145
143
|
updated(e) {
|
|
146
144
|
if (!this.isConnected) {
|
|
@@ -166,7 +164,7 @@ var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wu
|
|
|
166
164
|
this._isOpen && this._menu && this._focusFirstItem();
|
|
167
165
|
}));
|
|
168
166
|
});
|
|
169
|
-
} else this._syncScrollLock(!1), this._longPressOpenedAt = null, this._menuHandle?.release(), this._menuHandle = null, this._contentObserver.disconnect(), this._menuPositionGeneration.invalidate(), this._refreshScheduled = !1, this._closeMenuAfterPresence();
|
|
167
|
+
} else this._syncScrollLock(!1), this._longPressOpenedAt = null, this._longPressGestureEndedAt = null, this._menuHandle?.release(), this._menuHandle = null, this._contentObserver.disconnect(), this._menuPositionGeneration.invalidate(), this._refreshScheduled = !1, this._closeMenuAfterPresence();
|
|
170
168
|
this._userOpenChange.consume() && this._dispatchChange(this._isOpen);
|
|
171
169
|
}
|
|
172
170
|
e.has("noScrollLock") && this._syncScrollLock();
|
|
@@ -175,10 +173,13 @@ var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wu
|
|
|
175
173
|
this._openAt(e, t, !0, !0);
|
|
176
174
|
}
|
|
177
175
|
_openAt(e, t, n, r, i = !1) {
|
|
178
|
-
return !this.disabled && (this._x = e, this._y = t, this._shouldOpenInstantly = n, this._suppressInitialFocusVisible = r, this._centerOnPressPoint = i, this._outsideClickGuard.arm(), this._isOpen ? (this._closeSubmenusFrom(0, !0), this._closingSubmenus.restoreAll(), this._menuPositionGeneration.invalidate(), this._scheduleRefresh(), !1) : (this._isOpen = !0, !0));
|
|
176
|
+
return !this.disabled && (this._x = e, this._y = t, this._shouldOpenInstantly = n, this._suppressInitialFocusVisible = r, this._centerOnPressPoint = i, this._outsideClickGuard.arm(), this._isOpen ? (this._closeSubmenusFrom(0, !0), this._closingSubmenus.restoreAll(), this._menuPositionGeneration.invalidate(), this._scheduleRefresh(), !1) : (this._isOpen = !0, this._menuItemActivationEvent = null, !0));
|
|
179
177
|
}
|
|
180
178
|
close() {
|
|
181
|
-
this._isOpen && (this._isOpen = !1, this._menuPositionGeneration.invalidate(), (this._closeSource === "menu-item" || this.
|
|
179
|
+
this._isOpen && (this._isOpen = !1, this._menuPositionGeneration.invalidate(), (this._closeSource === "menu-item" || this._isDispatchingMenuItemActivation()) && this._releaseScrimModality(), this._closeSource = "default", this._menuItemActivationEvent = null);
|
|
180
|
+
}
|
|
181
|
+
_isDispatchingMenuItemActivation() {
|
|
182
|
+
return this._menuItemActivationEvent !== null && this._menuItemActivationEvent.eventPhase !== 0;
|
|
182
183
|
}
|
|
183
184
|
_scheduleRefresh() {
|
|
184
185
|
!this._refreshScheduled && this._isOpen && (this._refreshScheduled = !0, requestAnimationFrame(() => {
|
|
@@ -321,7 +322,12 @@ var z = "wui-context-menu-item", B = 500, V = 5e3, H = 10, U = 1e3, W = "data-wu
|
|
|
321
322
|
}
|
|
322
323
|
_isLongPressFollowUp() {
|
|
323
324
|
let e = this._longPressOpenedAt;
|
|
324
|
-
|
|
325
|
+
if (e === null) return !1;
|
|
326
|
+
let t = this._longPressGestureEndedAt ?? e;
|
|
327
|
+
return performance.now() - t > U ? (this._clearLongPressFollowUp(), !1) : !0;
|
|
328
|
+
}
|
|
329
|
+
_clearLongPressFollowUp() {
|
|
330
|
+
this._longPressOpenedAt = null, this._longPressGestureEndedAt = null;
|
|
325
331
|
}
|
|
326
332
|
_getLevelContainer(e) {
|
|
327
333
|
return e === 0 ? this._menu : this._activeSubmenus[e - 1];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../src/components/context-menu/index.ts"],"sourcesContent":["import { computePosition, flip, shift } from '@floating-ui/dom'\nimport { html, LitElement, unsafeCSS } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\nimport { UserChangeController } from '@/shared/events/user-change'\nimport {\n createClosingSubmenuStack,\n createMenuHoverBinder,\n createMenuOutsideClickGuard,\n getEnabledMenuLevelItems,\n getFocusedMenuItemFromPanels,\n handleMenuKeyboard,\n type MenuHoverDelegate,\n type MenuKeyboardDelegate\n} from '@/shared/menu-behavior'\nimport { createMenuPortalOverlay, type MenuPortalOverlay } from '@/shared/menu-portal/menu-portal'\nimport {\n captureFrameworkAnchors,\n findFocusedMenuItem,\n focusMenuItem,\n getMenuChildren,\n getMenuItemFromEvent,\n hideNestedMenuChildren,\n moveMenuChildren,\n orderManagedMenuItems,\n reconcileManagedMenuItems,\n restoreFrameworkAnchors,\n returnManagedMenuItemsToSlot\n} from '@/shared/menu-portal/menu-tree'\nimport { normalizeNumber } from '@/shared/normalize'\nimport { dispatchOpenChangeEvent } from '@/shared/open-state'\nimport { defineOpenOverlay, type OpenOverlayHandle } from '@/shared/overlay/open-overlay'\nimport { defineOverlayPositioningGeneration } from '@/shared/overlay/positioning-generation'\nimport { hideOverlayPresence, showOverlayPresence } from '@/shared/overlay/presence'\nimport { findNearestTheme } from '@/shared/overlay/theme-overlay-scope'\nimport { defineScrollLockLease } from '@/shared/scroll-lock/scroll-lock'\n\nimport scrimStyle from './scrim.css?inline'\nimport style from './style.css?inline'\n\nconst MARKER_TEXT = 'wui-context-menu-item'\n\n/** 与 iOS / Android 原生长按一致的默认时长。 */\nconst DEFAULT_LONG_PRESS_DELAY = 500\n/** long-press-delay 的上界:超过 5s 的长按不再是「长按」,等同没有该属性。 */\nconst MAX_LONG_PRESS_DELAY = 5000\n/** 按住期间的容差位移;超出即视为滚动/拖拽意图,取消长按。 */\nconst LONG_PRESS_MOVE_TOLERANCE = 10\n/**\n * 长按开菜单后吸收浏览器补发事件的窗口上限。\n *\n * 这不是时序保证:实测补发几乎紧跟抬手(touchend(1019ms) → click(1022ms),间隔 3ms),\n * 1000ms 远大于实际需要,只是一个陈旧兜底上限。真正的边界是下一次 `pointerdown`\n * (`_onPointerDown` 无条件清零,含鼠标)与菜单关闭,两条硬边界都在窗口判定之外,\n * 所以窗口只负责兜住「补发事件既没等到下一次按下、菜单也没关闭」这种不该发生的路径。\n */\nconst LONG_PRESS_FOLLOW_UP_WINDOW_MS = 1000\n\n/**\n * 菜单打开期间铺在整屏上的 scrim `<dialog>`。\n *\n * 用 `<dialog>` + `showModal()` 而不是 plain `div`:模态 dialog 进 top layer,\n * 于是「z-index 谁高谁低」这道约束整体消失 —— 面板与 scrim 同在 top layer,\n * 而 top layer 之下的任何 z-index 都碰不到它们。plain `div` 也能修好 light-dismiss\n * 穿透,但那样 scrim 必须靠 z-index 压过 `--wui-layer-menu`(100),是另一套易碎的约定。\n */\nconst SCRIM_ATTR = 'data-wui-menu-scrim'\n\ntype CloseSource = 'menu-item' | 'default'\n\n/** 首项聚焦的最大尝试次数(含首次)。见 `_focusFirstItem`。 */\nconst FOCUS_FIRST_ITEM_ATTEMPTS = 5\n\n/** 面板与视口边缘的最小间距,与既有夹取算术同源。 */\nconst VIEWPORT_PADDING = 8\n\n@customElement('web-ui-context-menu')\nexport class WebUiContextMenu extends LitElement {\n static override styles = unsafeCSS(style)\n\n @property({ type: Boolean, reflect: true }) disabled = false\n @property({ type: Boolean, reflect: true, attribute: 'no-scroll-lock' }) noScrollLock = false\n /** opt-in:触屏长按打开菜单。默认关闭,鼠标路径靠原生 contextmenu,无需此属性。 */\n @property({ type: Boolean, reflect: true, attribute: 'long-press' }) longPress = false\n\n private _longPressDelay = DEFAULT_LONG_PRESS_DELAY\n\n /**\n * 走包内既有的 `normalizeNumber` 通道(与 `dropdown.offset`、`popover.offset` 同形)。\n * 不能只靠 `Math.max(0, …)` 挡负数:`Math.max(0, NaN) === NaN`,而属性写错\n * (`long-press-delay=\"abc\"`)时 Lit 的 Number converter 给的正是 NaN,\n * `setTimeout(fn, NaN)` 会立刻触发,长按就在 pointerdown 同一帧开菜单、绕过全部时长语义。\n */\n @property({ type: Number, attribute: 'long-press-delay' })\n get longPressDelay(): number {\n return this._longPressDelay\n }\n set longPressDelay(v: number) {\n const old = this._longPressDelay\n this._longPressDelay = normalizeNumber(v, 0, MAX_LONG_PRESS_DELAY, DEFAULT_LONG_PRESS_DELAY)\n this.requestUpdate('longPressDelay', old)\n }\n\n @state() private _isOpen = false\n @state() private _x = 0\n @state() private _y = 0\n\n private _activeSubmenus: MenuPortalOverlay[] = []\n private _activeSubmenuItems: HTMLElement[] = []\n private _longPressTimer: ReturnType<typeof setTimeout> | undefined\n private _longPressOrigin: { x: number; y: number } | undefined\n /**\n * 长按自行开菜单的时刻。浏览器会为**同一次触摸**补发两个事件,二者都属于\n * 那一次意图,不是两次独立操作:\n *\n * - 原生 `contextmenu`:长按被识别时立刻派发,会重复打开(`_openAt` 走已开分支)。\n * - 合成 `click`:`touchend` 之后由引擎补发,会把刚打开的菜单立刻 light-dismiss。\n * 实测真实触控管线下的顺序为 pointerdown(9ms) → 长按开菜单(≈509ms) →\n * touchend(1019ms) → click(1022ms),缺这一步菜单开了就被自己的补发事件关掉。\n *\n * 因此在窗口内吸收这两个补发事件;窗口由下一次 `pointerdown` 关闭。\n */\n private _longPressOpenedAt: number | null = null\n // 子菜单 dialog 定位的唯一写者代数(按 panel 键控):同帧关闭→重开复用同一 panel\n // 时只允许最新一次定位写入;不同层级子菜单各有 panel,互不作废。\n private readonly _submenuPositionEpochs = new WeakMap<HTMLElement, number>()\n private _menu?: MenuPortalOverlay\n /**\n * 模态 scrim。菜单打开期间存在并处于 `showModal()` 状态,关闭时随面板一起移除。\n *\n * 面板必须**显式**挂进它(`createMenuPortalOverlay` 的第三参):`resolveOverlayContainer`\n * 查的是 target 的**祖先**里有无已打开的 dialog,而 scrim 是面板的反方向,祖先发现查不到。\n */\n private _scrim?: HTMLDialogElement\n /**\n * 本次开启是否**水平居中于触发点**(触屏长按路径)。鼠标 / 键盘路径仍左上角对齐光标。\n *\n * 只切换 placement 的对齐量,不切换定位算法 —— 两条入口共用 `_positionMenu` 里的\n * 同一套 `computePosition` + `flip` / `shift`,垂直契约(上缘对齐触发点、向下展开、\n * 放不下翻到上方)完全一致。\n *\n * 触屏要居中:手指按下去的位置是「这块内容在这里」的意思,菜单在它正下方居中展开,\n * 正是 iOS / Android 原生长按菜单的形态;左对齐会让菜单整体偏到手指右侧,看起来像\n * 弹在了别的东西上面。\n *\n * 鼠标不居中:桌面右键的肌肉记忆是菜单**左上角**出现在光标处。更要紧的是居中会让光标\n * 落在面板上缘正中 —— 也就是落在第一项的命中区里。后果是菜单一开就有项处于 hover 高亮,\n * 并且紧接着再右键一次(想开子菜单)会打在菜单项上而不是空白处。`overlay-in-dialog`\n * 里 `openAt(100, 400)` 那条用例钉的 `'top left'` 正是这条契约。\n *\n * 判据是**开启来源**(调用点显式传入),不靠运行时嗅探 pointerType 或视口宽度:\n * 后两者会让同一块屏幕上的两种输入拿到两套布局,且与本组件 opt-in 的 `long-press`\n * 属性不同源。\n */\n private _centerOnPressPoint = false\n\n /**\n * 本次关闭的来源,决定焦点归还走哪条路。语义见 `close()` 里的说明。\n *\n * 只有菜单项激活这一条需要抢在退场之前出顶层;点外面 / Escape / 程序式关闭一律\n * 保留 dialog 默认效果 —— 那正是用户「回到原处」的期望。\n */\n private _closeSource: CloseSource = 'default'\n /**\n * 「可激活菜单项被点了」这件事是否发生在**当前这一次事件派发里**,且关闭尚未被组件接管。\n *\n * 由面板上的**捕获阶段**监听器置位,早于菜单项自己的监听器;作用域与理由(尤其是为什么\n * 不能留到会话结束)见 `_onMenuItemActivation`。\n *\n * 存在的理由是 `close()` 的出顶层判定不能只看 `_closeSource`:消费方完全可以在自己的\n * 菜单项回调里直接调 `close()`(interweave 的右键重命名就是 `emit` 之后自己关),\n * 那时 `_closeSource` 还是 `'default'`,scrim 就一直留在 top layer 里没退出,\n * 消费方随后把焦点送进编辑器的动作落在 inert 文档上被浏览器静默丢弃。\n *\n * 消费方先关、组件后关的路径(`_onMenuClick` 的 `_closeFromUser('menu-item')`)随后看到的是\n * `'menu-item'`,两条判定同向,不会互相抵消。\n */\n private _menuItemActivated = false\n /** 根面板的开启会话句柄;未开启时为 null。查询走它。 */\n private _menuHandle: OpenOverlayHandle | null = null\n /**\n * 子菜单 panel → 会话句柄。子层经 `adopt` 进根句柄子树(默认不成为独立候选),\n * 但仍各自持有句柄:整层关闭或收尾动画结束时按 panel 精确撤销。\n */\n private readonly _submenuHandles = new Map<HTMLElement, OpenOverlayHandle>()\n // 行为层(hover / outside-click / 键盘 / submenu 收尾)由 shared/menu-behavior 驱动\n private readonly _outsideClickGuard = createMenuOutsideClickGuard(\n this,\n // 根句柄的 contains 递归覆盖全部已 adopt 的子层,一次查询替代逐 panel 判定。\n node => this._menuHandle?.contains(node) ?? false\n )\n private readonly _closingSubmenus = createClosingSubmenuStack<MenuPortalOverlay>({\n getPanel: container => container.panel,\n restoreItems: (container, parentItem) => this._restoreSubmenuItems(container, parentItem),\n dispose: container => {\n this._releaseSubmenu(container.panel)\n container.panel.remove()\n }\n })\n private readonly _hoverDelegate: MenuHoverDelegate = {\n getOpenDepth: () => this._activeSubmenus.length,\n getLevelItems: level => this._getLevelItems(level),\n getActiveItem: level => this._activeSubmenuItems[level],\n isSubmenuItem: item => item.hasAttribute('submenu'),\n openSubmenu: item => this._openSubmenu(item),\n closeFrom: level => {\n this._closeSubmenusFrom(level)\n this._hoverBinder.bind()\n }\n }\n private readonly _hoverBinder = createMenuHoverBinder(this._hoverDelegate, 'web-ui-dropdown-item')\n private readonly _keyboardDelegate: MenuKeyboardDelegate = {\n getFocusedItem: event =>\n getFocusedMenuItemFromPanels(\n event,\n [this._menu?.content, ...this._activeSubmenus.map(submenu => submenu.content)],\n 'web-ui-dropdown-item'\n ),\n getLevelOf: item => {\n const level = this._getItemLevel(item)\n return level === -1 ? undefined : level\n },\n getEnabledItems: level => getEnabledMenuLevelItems(this._getLevelContainer(level)?.content),\n isSubmenuItem: item => item.hasAttribute('submenu'),\n openSubmenuInstant: item => this._openSubmenu(item, true),\n closeToParent: level => {\n const parent = this._activeSubmenuItems[level - 1]\n this._closeSubmenusFrom(level - 1)\n this._hoverBinder.bind()\n return parent\n },\n closeDeepestOrAll: () => this._closeLastSubmenuOrMenu()\n }\n /*\n * 开启态浮层(issue #120 Block 1)。根面板是登记对象:子菜单经 adopt 成为它的逻辑\n * 后代;关闭动作仍走 closeDeepestOrAll(最深子菜单优先)。\n */\n private readonly _overlay = defineOpenOverlay().make({\n requestClose: () => this._keyboardDelegate.closeDeepestOrAll(),\n isConnected: () => this.isConnected\n })\n private readonly _menuItemAnchors = new Map<HTMLElement, Comment>()\n private readonly _scrollLock = defineScrollLockLease().make()\n private readonly _userOpenChange = new UserChangeController()\n private _shouldOpenInstantly = true\n private _suppressInitialFocusVisible = true\n private _refreshScheduled = false\n // 主菜单 dialog 路径与子菜单一样使用代数令牌;快速 openAt/refresh 或 close 后,\n // 迟到的 positioning promise 只能被丢弃,不能覆盖最新坐标。\n private readonly _menuPositionGeneration = defineOverlayPositioningGeneration().make()\n // 菜单打开期间宿主可能不经重定位直接改写子内容(网络推送、定时器等),\n // 新节点缺隐藏 slot 会可见叠加到菜单上;观察 portal 内容并在下一帧重新同步。\n private readonly _contentObserver = new MutationObserver(() => {\n if (this._isOpen) this._scheduleRefresh()\n })\n\n get isOpen(): boolean {\n return this._isOpen\n }\n\n override connectedCallback() {\n super.connectedCallback()\n this._hideMenuItems()\n this.addEventListener('contextmenu', this._onContextMenu)\n this.addEventListener('keydown', this._onKeydown)\n this.addEventListener('click', this._onMenuClick)\n this.addEventListener('pointerdown', this._onPointerDown)\n this.addEventListener('pointermove', this._onPointerMove)\n this.addEventListener('pointerup', this._onPointerUp)\n this.addEventListener('pointercancel', this._onPointerCancel)\n document.addEventListener('contextmenu', this._onContextMenuOutside)\n document.addEventListener('wheel', this._onWheel, { capture: true, passive: false })\n document.addEventListener('touchmove', this._onTouchMove, { capture: true, passive: false })\n document.addEventListener('keydown', this._onDocumentKeydown)\n }\n\n override disconnectedCallback() {\n super.disconnectedCallback()\n this.removeEventListener('contextmenu', this._onContextMenu)\n this.removeEventListener('keydown', this._onKeydown)\n this.removeEventListener('click', this._onMenuClick)\n this.removeEventListener('pointerdown', this._onPointerDown)\n this.removeEventListener('pointermove', this._onPointerMove)\n this.removeEventListener('pointerup', this._onPointerUp)\n this.removeEventListener('pointercancel', this._onPointerCancel)\n document.removeEventListener('contextmenu', this._onContextMenuOutside)\n document.removeEventListener('wheel', this._onWheel, true)\n document.removeEventListener('touchmove', this._onTouchMove, true)\n document.removeEventListener('keydown', this._onDocumentKeydown)\n this._contentObserver.disconnect()\n this._menuPositionGeneration.invalidate()\n this._outsideClickGuard.dispose()\n this._hoverBinder.dispose()\n this._scrollLock.release()\n this._menuHandle?.release()\n this._menuHandle = null\n this._returnItemsToSlot()\n this._menu?.panel.remove()\n this._menu = undefined\n this._closeSubmenusFrom(0, true)\n this._closingSubmenus.restoreAll()\n /*\n * scrim 挂在宿主以外的容器里(theme overlay root,无主题时 document.body),不随宿主一起\n * 脱离文档。不在这里收掉就会留下一张仍然 `showModal()` 的全屏 dialog:top layer 不受宿主\n * 移除影响,整个页面就此锁死。\n */\n this._closeScrim()\n // 脱离文档即视为关闭:否则重连后 _isOpen 仍为 true 而 _menu 已清空,\n // 下次 openAt 会走已打开分支静默失败,菜单无法再打开。\n this._isOpen = false\n this._cancelLongPress()\n this._longPressOpenedAt = null\n }\n\n protected override updated(changed: Map<string, unknown>) {\n // Lit 的已排队更新可在卸载后执行,不能让已失效实例重新获取全局滚动锁。\n if (!this.isConnected) {\n this._syncScrollLock(false)\n return\n }\n\n if (changed.has('_isOpen')) {\n if (this._isOpen) {\n this._syncScrollLock()\n if (!this._menu) {\n /*\n * 顺序:先建 scrim 并 `showModal()`,再把面板挂进去。\n *\n * `showModal()` 会把此前聚焦的元素记为还原目标;此刻焦点仍在 opener 上,\n * 正是归还目标。面板随后 `_focusFirstItem()` 把焦点带进菜单,关闭时 UA 还回去。\n *\n * 面板挂进 scrim 是承重的,不是「顺手放一起」:top layer 只对**自身子树内的\n * 元素**做命中拦截,面板若留在 overlay root(scrim 之外),模态期间它自己就不可点。\n */\n const scrim = this._openScrim()\n this._menu = createMenuPortalOverlay('context-menu', this, scrim)\n this._menu.panel.setAttribute('role', 'menu')\n this._menu.panel.setAttribute('aria-label', '上下文菜单')\n this._menu.panel.addEventListener('click', this._onMenuClick)\n this._menu.panel.addEventListener('click', this._onMenuItemActivation, true)\n }\n /*\n * 登记与面板建立解耦。退场动画被打断时(`_closeMenuAfterPresence` 在\n * `hideOverlayPresence` 返回 false 后提前退出)`_menu` 还在,而关闭分支已经把句柄\n * 撤了;此时重开必须补 claim。否则菜单开着且可见却没有登记:Escape 关不掉它\n * (仲裁看不到它),而 `_menuHandle === null` 会让每次 document click——包括面板\n * 内部与子菜单父项——都被 `_outsideClickGuard` 判成外部点击而关闭菜单。\n */\n if (!this._menuHandle) {\n this._menuHandle = this._overlay.claim(this._menu.panel)\n // 重新 claim = 新会话:根层的子层随之作废,仍在场的子菜单面板要重新 adopt 回\n // 新句柄的子树,否则根句柄的 contains 看不到它们。\n for (const submenu of this._activeSubmenus) {\n this._submenuHandles.delete(submenu.panel)\n this._submenuHandles.set(submenu.panel, this._menuHandle.adopt(submenu.panel))\n }\n // 收尾栈里的子菜单面板同样「可见但已脱离快照」:`_closeSubmenusFrom` 只把它们\n // 移出 `_activeSubmenus`,面板仍在 DOM 里退场。**这个循环承重**:不补 adopt,\n // 它们就会落在句柄的登记树之外,被判成「面板外」,点它内部会关掉整张菜单。\n // 用例 `退场窗口内重开后,收尾中的子菜单面板在取回前后都不可被当成面板外` 守着\n // 这一条 —— 把循环体整段删掉,那条会红(实测 1 failed | 95 passed)。\n // 写进 `_submenuHandles` 还因为收尾结束时 `_releaseSubmenu` 按 panel 取句柄释放。\n for (const container of this._closingSubmenus.closing()) {\n this._submenuHandles.set(container.panel, this._menuHandle.adopt(container.panel))\n }\n }\n // 父项始终留在 menu.content 内,观察它即可覆盖各级子菜单在打开期间的内容重建。\n this._contentObserver.observe(this._menu.content, { childList: true, subtree: true })\n requestAnimationFrame(() => {\n if (!this._isOpen || !this._menu) return\n this._refreshMenu()\n showOverlayPresence(this._menu.panel, { isInstant: this._shouldOpenInstantly })\n this._shouldOpenInstantly = true\n /*\n * 首项聚焦必须再等一帧,不能和 `_refreshMenu()` 同帧。\n *\n * 两个原因都指向「太早」:\n *\n * 1. `showModal()` 的 dialog focusing steps 由 UA 排队执行,会在我们已经聚焦首项\n * **之后**再把焦点挪到 dialog 上 —— 模态化之前没有这一步,也就没有这场竞争。\n * 2. `_setupMenuItems()` 刚创建的 `web-ui-dropdown-item` 尚未完成 Lit 的 shadow\n * 渲染,`focusItem()` 此刻还找不到 `.item-inner`,静默无操作。\n *\n * 实测:同帧聚焦后 `document.activeElement` 是 `<dialog>` 而非首项;再等一帧聚焦,\n * 焦点稳定落在菜单项内。\n */\n requestAnimationFrame(() => {\n if (!this._isOpen || !this._menu) return\n this._focusFirstItem()\n })\n })\n } else {\n this._syncScrollLock(false)\n // 吸收窗口只覆盖「长按之后浏览器补发的那几个事件」。菜单已关,\n // 之后任何一次真实右键或点击都必须走正常路径,不能被这个窗口吞掉。\n this._longPressOpenedAt = null\n // release ⟺ 关闭:登记与仲裁归属同时撤销,退场动画只是视觉收尾。\n this._menuHandle?.release()\n this._menuHandle = null\n this._contentObserver.disconnect()\n this._menuPositionGeneration.invalidate()\n this._refreshScheduled = false\n void this._closeMenuAfterPresence()\n }\n if (this._userOpenChange.consume()) this._dispatchChange(this._isOpen)\n }\n if (changed.has('noScrollLock')) this._syncScrollLock()\n }\n\n /**\n * 在指定视口坐标打开菜单。\n * @param x 水平坐标(px)。\n * @param y 垂直坐标(px)。\n * @returns 无返回值;命令式打开不派发 `open-change` 事件。\n */\n openAt(x: number, y: number) {\n this._openAt(x, y, true, true)\n }\n\n private _openAt(\n x: number,\n y: number,\n isInstant: boolean,\n suppressFocusVisible: boolean,\n centerOnPressPoint = false\n ): boolean {\n if (this.disabled) return false\n this._x = x\n this._y = y\n this._shouldOpenInstantly = isInstant\n this._suppressInitialFocusVisible = suppressFocusVisible\n this._centerOnPressPoint = centerOnPressPoint\n this._outsideClickGuard.arm()\n if (this._isOpen) {\n this._closeSubmenusFrom(0, true)\n this._closingSubmenus.restoreAll()\n // 先失效尚未完成的旧定位,再调度下一帧新代;避免新请求前的旧 promise 胜出。\n this._menuPositionGeneration.invalidate()\n this._scheduleRefresh()\n return false\n }\n this._isOpen = true\n return true\n }\n\n /**\n * 关闭菜单。\n * @returns 无返回值;命令式关闭不派发 `open-change` 事件。\n */\n close() {\n if (!this._isOpen) return\n this._isOpen = false\n this._menuPositionGeneration.invalidate()\n /*\n * 菜单项激活触发的关闭要**现在**就让 scrim 出顶层,不能等退场结束。\n *\n * 焦点归还由 UA 负责(`close()` 把焦点还给 `showModal()` 之前的 activeElement),\n * 而菜单项的回调常紧接着把焦点送到别处 —— 典型是右键重命名:回调把行换成编辑态、\n * 下一帧 `select()` 进 textarea。等到退场结束才关,UA 的归还会把焦点从 textarea\n * 拽回那一行,blur 掉编辑器,而 editable-text 的 blur 契约会**提交未改动的标题**。\n * 那是 interweave 上真实存在的 bug,不能放回来。\n *\n * **判定不能只看 `_closeSource`。** 消费方可以在自己的菜单项回调里直接调 `close()`:\n * 那时 `_onMenuClick` 还没跑到(它在面板上是冒泡阶段,而回调挂在条目上、先跑),\n * `_closeSource` 仍是 `'default'`,scrim 就留在 top layer 里不退,模态期间整份文档\n * 是 inert 的 —— 消费方紧接着送进编辑器的焦点被浏览器静默丢弃,症状是「重命名后焦点\n * 停在菜单项上、编辑器根本没进编辑态」,与本段要防的 blur 是同一个根因的两种落点。\n * `_menuItemActivated`(捕获阶段置位)把那一条路径也纳入判定,理由见它的注释。\n *\n * 此刻焦点还在菜单里,UA 的归还正好落在 opener 上——那本来就是用户期望的落点,\n * 应用随后的焦点转移不会被覆盖。元素本身要留到面板真正移除(见 `_releaseScrimModality`)。\n */\n if (this._closeSource === 'menu-item' || this._menuItemActivated) this._releaseScrimModality()\n this._closeSource = 'default'\n this._menuItemActivated = false\n }\n\n private readonly _closeFromUser = (source: CloseSource = 'default') => {\n if (!this._isOpen) return\n this._userOpenChange.mark()\n this._closeSource = source\n this.close()\n }\n\n // 同一帧内多次内容变化只刷新一次,避免观察者与刷新自身 append 形成循环。\n private _scheduleRefresh() {\n if (this._refreshScheduled || !this._isOpen) return\n this._refreshScheduled = true\n requestAnimationFrame(() => {\n this._refreshScheduled = false\n if (!this._isOpen || !this._menu) return\n this._refreshMenu()\n })\n }\n\n // 重新同步 portal 内容并重定位;fresh-open、重定位与观察者触发的刷新共用。\n private _refreshMenu() {\n if (!this._menu) return\n this._setupMenuItems()\n this._positionMenu()\n this._hoverBinder.bind()\n }\n\n /**\n * 定位:**触屏长按与鼠标右键共用这一条 Floating UI 路径**。\n *\n * 两条入口现在只差「触发点的 x/y 从哪来」,布局契约完全一致:\n * `bottom-start` 表达「面板上缘 == 触发点 y、向下展开」,水平以触发点 x 为基准。\n * 之前有一条手写的贴底分支(先按点、再按视口下缘算 `top`),那是把「贴视口底」硬编码\n * 成算术;真机反馈证明它错(菜单跑到屏幕最底部,与手指按下的位置完全脱节)。现在\n * 落点交给 `flip` / `shift` 按**实际空间**决定,`placement` 只表达意图。\n *\n * 不再按「是否触屏」分流:窄视口的鼠标右键同样期望菜单贴着光标出现,按输入类型或视口\n * 宽度分流会让同一块屏幕上的两种输入拿到两套布局。分流所需的 `_anchorAtPressPoint`\n * 连同 `_positionMenuInViewport` / `_positionMenuAtPressPoint` 两个方法一起删掉 ——\n * 面板恒为 open scrim `<dialog>` 的直接子节点(`createMenuPortalOverlay` 第三参传入\n * scrim,`menu-portal.ts` 里 `container = scrim ?? …`),所以旧代码那条\n * 「非 dialog 容器」的分支对 context-menu 本来就不可达。\n *\n * 算出来的 left/top 是**视口坐标**。面板是 `position: fixed`,而 scrim 刻意不带\n * transform / filter / contain(见 style.css),因此不构成 fixed 的包含块 ——\n * 坐标直接相对视口解析。这条等价关系由 style.css 的满屏声明保证,并由 R6 的\n * 「scrim 无 transform/filter/contain」+ viewport-fit 的几何断言兜底。\n */\n private _positionMenu() {\n const panel = this._menu?.panel\n if (!panel) return\n\n /*\n * 先读安全区,再碰面板:`getComputedStyle` 会强制一次同步布局,让它发生在面板进入\n * 「可测」状态之前,而不是夹在「面板刚可测」与「Floating UI 量它」之间。\n *\n * 需要说清楚的是,**这不是** viewport-fit 那条横向断言里 2px 抖动的成因。把这一行移到\n * 最前面实测并没有让那个偏差消失(改前改后都是 204 对期望 206)。那个 2px 来自 flip 的\n * reset 会多跑一轮测量:那一刻面板宽 202px、落定后 200px。相关用例已经不再钉与视口\n * 边界的精确距离。\n */\n const safeAreaBottom = this._safeAreaBottom()\n\n panel.style.visibility = 'hidden'\n panel.style.display = ''\n\n const generation = this._menuPositionGeneration.next()\n /*\n * 可用矩形 = 视口 − 安全区 − 最小间距。flip 与 shift 必须吃**同一份** padding:\n *\n * - flip 用它判断「这一侧放不放得下」,决定翻不翻;\n * - shift 用它做实际钳制,且 `crossAxis: true` 会**同时**钳主轴与交叉轴\n * (core 的 shift 默认 mainAxis=true / crossAxis=false,开了 crossAxis 才两边都管)。\n *\n * 安全区必须进 shift 而不只是 flip:flip 只挑「哪一侧」,挑完就把面板贴在触发点上,\n * 不再管这一侧内部的位置。触发点本身落在安全区里时(按在屏幕最底部),挑出来的\n * 「上方」那一侧仍然把面板下缘按在安全区里 —— 只有 shift 的主轴钳制会把它推上来。\n * 这条正是 R7 用例在钉的行为。\n */\n const boundaryPadding = {\n top: VIEWPORT_PADDING,\n bottom: VIEWPORT_PADDING + safeAreaBottom,\n left: VIEWPORT_PADDING,\n right: VIEWPORT_PADDING\n }\n /*\n * 限制 flip 的候选集为「下方 / 上方」两个,**刻意不含 `-start` / `-end` 的另一侧**。\n *\n * 为什么必须限制:`flip` 对对齐过的 placement(`bottom-start`)默认展开成\n * `getExpandedPlacements()`,候选里带着 `bottom-end` / `top-end`。于是鼠标贴视口右缘、\n * `bottom-start` 的对齐侧溢出时,flip 会认定「`bottom-end` 才放得下」并把面板整个挪到\n * 光标左侧。落点仍在视口内,但那是一次没人要求的契约变更 —— 水平夹取本来就归 shift,\n * 而且对齐量一变,transform-origin 的推导前提(对齐量不变 ⇒ 水平 origin 只由 shift 位移\n * 决定)就不成立了。\n *\n * 用的是 `fallbackPlacements` 而不是 `allowedPlacements`:后者是 `autoPlacement` 的选项,\n * `flip` 根本不读,传进去只会被静默忽略(TS 会报错,运行时会当成 padding 之外的字段丢掉)。\n * `fallbackPlacements` 正是 flip 用来**替换**上面那个默认展开列表的字段,指定后\n * `placements = [initialPlacement, ...fallbackPlacements]`,连 bestFit 兜底也只在这份\n * 候选里挑,`-end` 因此永远不可能出现。\n *\n * 用例「鼠标贴视口右缘时对齐量不被 flip 改成 -end」钉的就是这一条。\n */\n const placements = this._centerOnPressPoint\n ? (['bottom', 'top'] as const)\n : (['bottom-start', 'top-start'] as const)\n\n void computePosition({ getBoundingClientRect: () => new DOMRect(this._x, this._y, 0, 0) }, panel, {\n strategy: 'fixed',\n /*\n * `bottom`(触屏):面板上缘对齐触发点 y、水平居中于触发点 x。\n * `bottom-start`(鼠标):同样上缘对齐 y,但左缘对齐光标 x。理由见\n * `_centerOnPressPoint` —— 居中会把光标落进面板上缘的命中区。\n *\n * 居中在贴边时**必然**被 shift 的交叉轴钳制打破(按点 x 太靠边,居中的面板会溢出视口)。\n * 这不是缺陷而是取舍:完整可见优先于几何居中。shift 推完面板仍在视口内,\n * 只是不再居中 —— 用例「按点贴视口右缘时仍完整可见」钉的就是这一条。\n */\n placement: placements[0],\n middleware: [\n /*\n * flip 负责「向下放不下就翻到按点上方」—— 面板下缘对齐按点、向上长。\n * 两侧都放不下(按点极低且面板极高)时它按 bestFit 选空间多的一侧并夹住主轴,\n * 所以不需要自己再写一遍兜底夹取。\n */\n flip({ fallbackPlacements: [...placements], padding: boundaryPadding }),\n shift({ padding: boundaryPadding, crossAxis: true })\n ]\n }).then(({ x, y, placement, middlewareData }) => {\n if (!this._isOpen || this._menu?.panel !== panel || !this._menuPositionGeneration.isCurrent(generation)) return\n const shiftX = middlewareData.shift?.x ?? 0\n /*\n * 垂直 origin 取 flip 后的实际 placement,不看 shift 位移:placement 直接说明面板\n * 落在触发点的哪一侧,而「离触发点最近的那条边」就是 scale 应该长出来的地方。\n *\n * 水平:默认跟 placement 的对齐量;被 shift 推动时改用推动方向那一侧,菜单才是朝\n * 光标「长过来」而不是从视口边缘滑进去。居中(placement 不带 `-` 对齐后缀)且未被\n * 推动时保持 `center`。\n */\n const horizontalOrigin = !placement.includes('-')\n ? shiftX === 0\n ? 'center'\n : shiftX < 0\n ? 'right'\n : 'left'\n : shiftX < 0\n ? 'right'\n : 'left'\n this._applyMenuPosition(panel, x, y, horizontalOrigin, placement.startsWith('bottom') ? 'top' : 'bottom')\n })\n }\n\n /**\n * 视口下缘的安全区高度(px)。\n *\n * 改按压点展开之后它**仍然必需**,而且不再只服务触屏:菜单默认从触发点向下长,触发点\n * 落在屏幕下半部时面板下缘会进入 home indicator 区域。它现在折进 flip 与 shift 共用的\n * 边界 padding(见 `_positionMenu`),不再需要单独一条 `maxBottom` 算术。\n *\n * 读宿主的自定义属性而不是 `env()`:自定义属性读回的是**计算后**的解析值,\n * 而 `env()` 在 JS 里没有对应的求值入口,只能间接经由声明它的属性取。\n * 属性未解析时 `parseFloat` 得 NaN,落到 0 —— 与 `env(..., 0px)` 的缺省一致。\n */\n private _safeAreaBottom(): number {\n const value = Number.parseFloat(getComputedStyle(this).getPropertyValue('--wui-context-menu-safe-area-bottom'))\n return Number.isFinite(value) ? value : 0\n }\n\n /**\n * 写下面板坐标与进场 transform 的原点。\n *\n * 两个 origin 必传且**不**再回退成坐标比较:flip 会改变垂直方向,回退表达式\n * `y < this._y ? 'bottom' : 'top'` 在「面板顶边与触发点重合」时两边都成立,等于随机取。\n * 唯一调用方从 flip 后的 placement 与 shift 位移推出真实侧边,见 `_positionMenu`。\n */\n private _applyMenuPosition(\n panel: HTMLElement,\n x: number,\n y: number,\n horizontalOrigin: 'left' | 'right' | 'center',\n verticalOrigin: 'top' | 'bottom'\n ) {\n panel.style.left = `${x}px`\n panel.style.top = `${y}px`\n panel.style.setProperty('--wui-internal-overlay-transform-origin', `${verticalOrigin} ${horizontalOrigin}`)\n panel.style.visibility = ''\n }\n\n /**\n * 把焦点带进菜单的第一项。\n *\n * `attempt` 是重试计数:菜单项是刚被 `reconcileManagedMenuItems` 搬进来的自定义元素,\n * 它们的 shadow(`.item-inner`)由各自的 Lit 更新**异步**渲染。`focusMenuItem()` 委托给\n * 项的 `focusItem()`,那一刻 `.item-inner` 还不存在,于是**静默无操作** —— 不抛错、\n * 也不留痕,只表现为「菜单开了但焦点没进去」。\n *\n * 所以这里不假设一次成功:调用后用 `findFocusedMenuItem` 复核,没落上就在下一帧再试,\n * 最多 `FOCUS_FIRST_ITEM_ATTEMPTS` 次。上界是必要的 —— 菜单项若始终渲染不出来\n * (空菜单、内容被外部换掉),不能让重试链一直跑下去。\n */\n private _focusFirstItem(attempt = 0) {\n const items = this._menu?.content.querySelectorAll<HTMLElement>('web-ui-dropdown-item:not([disabled])')\n const firstItem = items?.[0]\n if (!firstItem) return\n focusMenuItem(firstItem, { suppressFocusVisible: this._suppressInitialFocusVisible })\n if (attempt + 1 >= FOCUS_FIRST_ITEM_ATTEMPTS) return\n if (findFocusedMenuItem([this._menu?.panel])) return\n requestAnimationFrame(() => {\n if (!this._isOpen || !this._menu) return\n this._focusFirstItem(attempt + 1)\n })\n }\n\n private _setupMenuItems() {\n const menu = this._menu\n if (!menu) return\n const content = menu.content\n\n reconcileManagedMenuItems(this, content, this._menuItemAnchors, MARKER_TEXT)\n this._hideMenuItems()\n // 宿主可能在菜单打开期间动态改写 slot 子内容(如切换上下文后重建嵌套子项),\n // 新节点没有隐藏 slot 会落入父项默认 slot 可见渲染并叠到一级菜单上;\n // 因此对已移入 content 的嵌套子项也要重新隐藏。\n hideNestedMenuChildren(content, 'context-menu-hidden')\n\n // 框架 v-if 注释锚点的复位独立于元素重排:无论元素序是否已变都要执行。\n // 若挂在「元素序已变才重排」之下,顺序恰好正确时会跳过复位,锚点漂移无法收敛,\n // 而 Vue 下次翻转正依赖锚点引导新分支项的插入点。\n const anchors = captureFrameworkAnchors(content, MARKER_TEXT)\n orderManagedMenuItems(this, content, this._menuItemAnchors, MARKER_TEXT)\n restoreFrameworkAnchors(content, anchors)\n }\n\n private _returnItemsToSlot() {\n const menu = this._menu\n if (!menu) return\n\n this._closeSubmenusFrom(0, true)\n this._closingSubmenus.restoreAll()\n\n returnManagedMenuItemsToSlot(this, menu.content, this._menuItemAnchors)\n }\n\n /**\n * 建 scrim 并让它进入模态态,返回容器供面板挂载。\n *\n * 每次开启都新建而不是复用:scrim 的生命周期必须与「这一次开启」严格同进同出。\n * 复用一个 dialog 意味着上一轮的 `close()` 事件、`open` 属性与 UA 记下的焦点还原目标\n * 会跨会话残留,而这些都不是本组件能重置的。\n */\n private _openScrim(): HTMLDialogElement {\n const scrim = document.createElement('dialog')\n scrim.className = 'wui-context-menu-scrim'\n /*\n * scrim 的样式必须**显式挂到 scrim 元素上**。\n *\n * `static styles`(style.css)只进宿主的 shadow root,scrim 又是本组件自己造的节点、不进\n * 宿主 shadow root,拿不到那份样式;`scrim.css` 若只躺在文件里而不注入,就是死代码。\n * 实测未注入时 scrim 完全是 UA 默认 `dialog:modal` 的样子:`margin: auto` +\n * `width/height: fit-content` 让它在 414x896 视口里缩成 **38x38 的白盒子**正中,\n * `background` 还是不透明白 —— 菜单一开就在屏幕中间糊一块白色方块。\n *\n * 用子 `<style>` 而不是行内 `cssText`:与 `menu-portal.ts` 对 dialog 容器注入共享样式\n * 是同一手法(那边已在 dialog 容器上验证过),随元素一起移除,不留全局残留。\n */\n const scrimStyles = document.createElement('style')\n scrimStyles.textContent = scrimStyle\n scrim.append(scrimStyles)\n scrim.setAttribute(SCRIM_ATTR, '')\n scrim.addEventListener('click', this._onScrimClick)\n scrim.addEventListener('cancel', this._onScrimCancel)\n // showModal() 要求元素已在文档中。\n this._scrimParent().append(scrim)\n scrim.showModal()\n this._scrim = scrim\n return scrim\n }\n\n /**\n * scrim 的挂载点:**与面板在没有 scrim 时会落到的容器同源** —— 最近主题的 theme-owned\n * overlay root。scrim 就是那个容器的替身(面板必须挂进 scrim,见 `updated()` 的顺序注释),\n * 所以它得站在容器该站的位置上。\n *\n * 为什么不能在整棵 DOM 里随便挑位置:`--wui-color-*` 由 `<web-ui-theme>` 写在自己的 `:host`\n * 上,只沿 DOM 树向下继承。scrim 若挂到 `document.body`,而 `<web-ui-theme>` 是 body 的**子节点**,\n * 这条继承链就断了 —— 面板与 scrim 双双落回 `var(--wui-color-*, <浅色字面量>)` 的 fallback,\n * 深色外观下菜单是一块浅色玻璃(app 侧的 `dark:` 变体也够不到它)。挂进 theme overlay root\n * 之后,面板与 scrim 跟 dropdown / select 一样吃同一份 token。\n *\n * 无主题时保持历史行为挂在 `document.body`:那条路径本来就没有 token 作用域可继承,改用\n * fallback overlay root 只会改变既有测试与消费者的观察面,不换来任何东西。\n */\n private _scrimParent(): HTMLElement {\n return findNearestTheme(this)?.getOverlayRoot() ?? document.body\n }\n\n /**\n * 关掉并摘除 scrim。幂等:关闭分支与 `disconnectedCallback` 都会调。\n *\n * `close()` 触发 UA 的焦点归还,**必须先于面板 `remove()`** —— 理由见 `_closeMenuAfterPresence`。\n */\n private _closeScrim() {\n const scrim = this._scrim\n if (!scrim) return\n this._scrim = undefined\n if (scrim.open) scrim.close()\n scrim.remove()\n }\n\n /**\n * 只让 scrim 出顶层,不摘除元素。面板挂在 scrim 里,摘了就把退场动画一起摘掉。\n *\n * 提前出顶层是为了焦点(理由见 `close()`)。代价是这个尾巴上 scrim 已经在 top layer\n * 之外:下层恢复可命中,且面板的层级退回普通流 —— 在「菜单开在 drawer/dialog 之上」\n * 的场景里,退场动画会被下层的 top layer 盖住。\n *\n * 这条尾巴只有一次退场过渡的时长(默认 160ms),且只发生在菜单项激活时。\n * 摘除仍在 `_closeScrim()`,那时面板已经走完。\n */\n private _releaseScrimModality() {\n const scrim = this._scrim\n if (scrim?.open) scrim.close()\n }\n\n /**\n * 点 scrim = 关菜单(Q12:必关,不给 prop 选项),与 dialog / drawer 的实现同形:\n * 只有落点就是 scrim 本身才算「点外面」,落在面板(scrim 的后代)上不算。\n *\n * 落点判据之所以只能是这两个,是模态给的:菜单打开期间下层收不到命中,事件只可能\n * 落在 scrim 或面板上,两者都由本组件判定。\n *\n * 更早的实现在 document 捕获阶段挂 click 监听,把「点外面」理解成「命中测试落在菜单\n * 面板之外」—— 宿主 light DOM 的每一行都满足这个条件,于是判定与行激活成了同一个\n * click 的两个后果,中间没有任何仲裁,下层目标先被激活。那条监听器连同它的实现已一并\n * 删除:模态化之后它没有任何还能生效的场景,别再挂回来。\n */\n private _onScrimClick = (e: MouseEvent) => {\n if (e.target !== this._scrim) return\n // 长按抬手后浏览器补发的 click 属于那一次触摸意图,不能把刚开的菜单关掉。\n if (this._isLongPressFollowUp()) return\n this._closeFromUser()\n }\n\n /**\n * Escape 的**唯一**关闭路径仍是共享仲裁者(`_overlay.requestClose` → `closeDeepestOrAll`),\n * 它按层深决定是关最深子菜单还是关整张菜单。原生 dialog 自己也监听 Escape 并派发\n * `cancel`,若放任默认行为,UA 会直接关掉 scrim —— 那条路径不经过组件状态机,\n * 既可能漏派 `open-change`,也会把「关最深一层」的语义降级成「整张菜单没了」。\n *\n * 因此这里只吞掉默认行为,不重复关闭。仲裁者那条路径已经会走 `_closeFromUser()`。\n */\n private _onScrimCancel = (e: Event) => {\n e.preventDefault()\n }\n\n private async _closeMenuAfterPresence() {\n const menu = this._menu\n if (menu && !(await hideOverlayPresence(menu.panel))) return\n if (this._isOpen || !this.isConnected || this._menu !== menu) return\n\n /*\n * 归还判定必须在移除面板之前做完:面板一脱离文档,「焦点仍在菜单内」就再也读不到了。\n * 子菜单层此刻仍可能持有焦点,一并计入。\n */\n /*\n * 先关 scrim 再摘面板,顺序不能反。\n *\n * 焦点归还是 `showModal()` / `close()` 的 UA 行为,不再由组件自己实现:UA 记的是\n * `showModal()` 之前的 `document.activeElement`,`close()` 时还回去。反过来做就成了\n * 实测里那条「open 中直接移除 dialog → 焦点丢到 body,不归还」——面板先脱离文档,\n * 焦点链断了,UA 的还原目标也就落空。\n *\n * 菜单项激活那条路径的 scrim 早在 `close()` 里就出了顶层(`_releaseScrimModality`),\n * 这里的 `close()` 对它是幂等空转,摘除照常发生。\n *\n * 「关闭期间焦点被外部接管时不抢回」那道防护不是被删除,是被**移到了更早的位置**:\n * UA 自己没有这个分支(实测:外部接管后 close,焦点必被抢回 opener,`close()` /\n * `remove()` / `open = false` / 摘 `open` / 置 `display:none` / opener 移除或不可聚焦,\n * 十种写法全都照旧夺焦点)。所以只能在焦点**还没被送走**的时候就把顶层让掉 ——\n * 也就是菜单项激活那一刻。点外面 / Escape / 程序式关闭不抢焦点是用户期望的语义,\n * 那三条保留 dialog 默认效果。\n */\n this._closeScrim()\n\n // 登记已在关闭分支撤销(release ⟺ 关闭),这里只做内容归还与 DOM 收尾。\n this._returnItemsToSlot()\n menu?.panel.remove()\n this._menu = undefined\n }\n\n private _hideMenuItems() {\n getMenuChildren(this).forEach(child => child.setAttribute('slot', 'context-menu-hidden'))\n hideNestedMenuChildren(this, 'context-menu-hidden')\n }\n\n private _openSubmenu(item: HTMLElement, isInstant = false) {\n if (!this._isOpen || !item.hasAttribute('submenu') || item.hasAttribute('disabled')) return\n\n const level = this._getItemLevel(item)\n if (level === -1 || this._activeSubmenuItems[level] === item) return\n\n this._closeSubmenusFrom(level)\n const closingSubmenu = this._closingSubmenus.take(item)\n if (closingSubmenu) {\n this._activeSubmenus[level] = closingSubmenu\n this._activeSubmenuItems[level] = item\n /*\n * 取回的面板写回 `_submenuHandles`,让收尾结束时的 `_releaseSubmenu` 释放到正确的句柄。\n * 注:「面板在根句柄子树里」这一条已由上面的 claim 分支保证 —— 该分支会把仍在\n * closing 栈里的面板一并重挂(`_closingSubmenus.closing()`)。实测单独摘掉这一行\n * 全部用例仍绿,所以它维护的是释放账本,不是树的成员关系。\n *\n * **这一行没有任何测试守护**:把 `adopt` 与 `set` 一起摘掉,context-menu 的 96 条\n * 用例仍然全绿(实测 96 passed)。所以下面写的是「当前如此」—— 这一行维护的是\n * 释放账本(收尾结束时 `_releaseSubmenu` 按 panel 取句柄),不是树的成员关系;\n * 成员关系由上面 claim 分支的 `_closingSubmenus.closing()` 循环负责,那一条**是**\n * 有测试的。改动这一行前请自己判断要不要补一条能红的用例,别把这里的沉默当成覆盖。\n */\n if (this._menuHandle) this._submenuHandles.set(closingSubmenu.panel, this._menuHandle.adopt(closingSubmenu.panel))\n item.setAttribute('active', '')\n this._positionSubmenu(item, closingSubmenu)\n showOverlayPresence(closingSubmenu.panel, { isInstant })\n this._hoverBinder.bind()\n return\n }\n if (getMenuChildren(item).length === 0) return\n\n /*\n * 子菜单与根菜单挂进**同一个** scrim。只改根菜单会让两层行为分裂:根层被 scrim\n * 罩住时,子菜单若仍留在 overlay root,它自己就成了 scrim 子树外的元素 —— 模态期间\n * 不可点,且点它内部会被判成「点外面」而关掉整张菜单。\n */\n const submenu = createMenuPortalOverlay('context-submenu', this, this._scrim)\n submenu.panel.dataset.level = String(level)\n submenu.panel.setAttribute('role', 'menu')\n submenu.panel.setAttribute('aria-label', '子菜单')\n submenu.panel.style.visibility = 'hidden'\n submenu.panel.addEventListener('click', this._onMenuClick)\n submenu.panel.addEventListener('click', this._onMenuItemActivation, true)\n moveMenuChildren(item, submenu.content)\n\n this._activeSubmenus[level] = submenu\n this._activeSubmenuItems[level] = item\n /*\n * 子层进根句柄的子树,默认不是独立候选 —— 「它在树里、但不参与这一层裁决」由此\n * 表达。原先按 level===0 选择根面板还是上一层子面板的父级判断随之消失:contains\n * 沿子树递归,拍平与嵌套对任何现有查询结果等价。\n */\n if (this._menuHandle) this._submenuHandles.set(submenu.panel, this._menuHandle.adopt(submenu.panel))\n item.setAttribute('active', '')\n this._positionSubmenu(item, submenu)\n showOverlayPresence(submenu.panel, { isInstant })\n this._hoverBinder.bind()\n }\n\n private _closeSubmenusFrom(level: number, isInstant = false) {\n for (let index = this._activeSubmenus.length - 1; index >= level; index--) {\n const submenu = this._activeSubmenus[index]\n const item = this._activeSubmenuItems[index]\n item?.removeAttribute('active')\n if (!item || isInstant) {\n if (submenu) this._releaseSubmenu(submenu.panel)\n this._restoreSubmenuItems(submenu, item)\n submenu.panel.remove()\n } else {\n this._closingSubmenus.closeAsync(item, submenu)\n }\n }\n this._activeSubmenus.length = level\n this._activeSubmenuItems.length = level\n }\n\n private _restoreSubmenuItems(submenu: MenuPortalOverlay, item?: HTMLElement) {\n if (!item) return\n moveMenuChildren(submenu.content, item)\n // 子菜单打开期间宿主可能重建了嵌套子项,归还时补隐藏,避免可见叠加。\n hideNestedMenuChildren(item, 'context-menu-hidden')\n }\n\n private _getItemLevel(item: HTMLElement): number {\n const menu = this._menu\n if (menu?.panel.contains(item)) return 0\n const parentLevel = this._activeSubmenus.findIndex(submenu => submenu.panel.contains(item))\n return parentLevel === -1 ? -1 : parentLevel + 1\n }\n\n private _positionSubmenu(item: HTMLElement, submenu: MenuPortalOverlay) {\n // 与主菜单同因:panel 进入 open native dialog 后处于 transformed containing\n // block,viewport 坐标的 left/top 会相对 dialog padding box 解析而整体偏移,\n // 需改走 Floating UI 换算为 dialog 相对坐标;开合方向仍按视口坐标度量预判。\n if (submenu.panel.parentElement instanceof HTMLDialogElement && submenu.panel.parentElement.open) {\n const itemRect = item.getBoundingClientRect()\n const padding = 8\n const canOpenRight = itemRect.right + submenu.panel.getBoundingClientRect().width + padding <= window.innerWidth\n // 同帧关闭→重开会复用同一 panel 产生两个 in-flight 定位 promise,完成序不保证\n // 后者胜出;按 panel 键控的代数 token 只允许最新一次调用写入,迟到旧 promise 丢弃。\n const epoch = (this._submenuPositionEpochs.get(submenu.panel) ?? 0) + 1\n this._submenuPositionEpochs.set(submenu.panel, epoch)\n void computePosition(item, submenu.panel, {\n strategy: 'fixed',\n placement: canOpenRight ? 'right-start' : 'left-start',\n middleware: [shift({ padding, crossAxis: true })]\n }).then(({ x, y, middlewareData }) => {\n if (!this._activeSubmenus.includes(submenu) || epoch !== this._submenuPositionEpochs.get(submenu.panel)) return\n // 与主菜单 dialog 路径一致:origin 由 shift 增量推导,视口预判只决定 placement。\n const shiftX = middlewareData.shift?.x ?? 0\n const shiftY = middlewareData.shift?.y ?? 0\n const horizontalOrigin = shiftX < 0 ? 'right' : 'left'\n const verticalOrigin = shiftY < 0 ? 'bottom' : 'top'\n submenu.panel.style.left = `${x}px`\n submenu.panel.style.top = `${y}px`\n submenu.panel.style.setProperty(\n '--wui-internal-overlay-transform-origin',\n `${verticalOrigin} ${horizontalOrigin}`\n )\n submenu.panel.style.visibility = ''\n })\n return\n }\n\n const itemRect = item.getBoundingClientRect()\n const submenuRect = submenu.panel.getBoundingClientRect()\n const padding = 8\n const canOpenRight = itemRect.right + submenuRect.width + padding <= window.innerWidth\n const left = canOpenRight ? itemRect.right : Math.max(padding, itemRect.left - submenuRect.width)\n const top = Math.min(Math.max(padding, itemRect.top), window.innerHeight - submenuRect.height - padding)\n\n submenu.panel.style.left = `${left}px`\n submenu.panel.style.top = `${top}px`\n submenu.panel.style.setProperty('--wui-internal-overlay-transform-origin', canOpenRight ? 'top left' : 'top right')\n submenu.panel.style.visibility = ''\n }\n\n private _dispatchChange(open: boolean) {\n dispatchOpenChangeEvent(this, open)\n }\n\n private _onPointerDown = (e: PointerEvent) => {\n // 任何一次新的按下都结束「上一次长按的补发事件」窗口:抬手后浏览器合成的\n // click 一定早于下一次 pointerdown,所以这个边界既能吸收补发,又不会误吞\n // 用户之后真正的点击。\n this._longPressOpenedAt = null\n // 鼠标走原生 contextmenu,无需长按;只认触屏指针。\n if (!this.longPress || this.disabled || e.pointerType !== 'touch') return\n this._cancelLongPress()\n this._longPressOrigin = { x: e.clientX, y: e.clientY }\n this._longPressTimer = setTimeout(\n () => {\n this._longPressTimer = undefined\n const origin = this._longPressOrigin\n if (!origin) return\n this._longPressOrigin = undefined\n if (this._isOpen) return\n this._longPressOpenedAt = performance.now()\n // 触屏长按:面板上缘对齐按点、向下展开,水平居中于按点(判据见 `_centerOnPressPoint`)。\n if (this._openAt(origin.x, origin.y, false, true, true)) this._userOpenChange.mark()\n },\n // 属性已被 normalizeNumber 钳到 [0, 5000],这里直接用;不再二次 Math.max。\n this.longPressDelay\n )\n }\n\n private _onPointerMove = (e: PointerEvent) => {\n if (e.pointerType !== 'touch') return\n const origin = this._longPressOrigin\n if (!origin) return\n // 触屏有隐式指针捕获,pointermove 会持续派发到 pointerdown 的落点,\n // 因此位移超阈值即判定为滚动意图,取消长按。\n const distance = Math.hypot(e.clientX - origin.x, e.clientY - origin.y)\n if (distance > LONG_PRESS_MOVE_TOLERANCE) this._cancelLongPress()\n }\n\n private _onPointerUp = (e: PointerEvent) => {\n if (e.pointerType === 'touch') this._cancelLongPress()\n }\n\n private _onPointerCancel = () => {\n this._cancelLongPress()\n }\n\n private _cancelLongPress() {\n if (this._longPressTimer !== undefined) {\n clearTimeout(this._longPressTimer)\n this._longPressTimer = undefined\n }\n this._longPressOrigin = undefined\n }\n\n /** 该事件是否是长按开菜单之后、浏览器为同一次触摸补发的事件。 */\n private _isLongPressFollowUp(): boolean {\n const openedAt = this._longPressOpenedAt\n if (openedAt === null) return false\n // 超期只说明「这次长按的补发没来」,不代表窗口承担时序保证;见常量注释。\n if (performance.now() - openedAt > LONG_PRESS_FOLLOW_UP_WINDOW_MS) {\n this._longPressOpenedAt = null\n return false\n }\n return true\n }\n\n private _onContextMenu = (e: MouseEvent) => {\n if (this.disabled) return\n if (this._isLongPressFollowUp()) {\n e.preventDefault()\n return\n }\n e.preventDefault()\n if (this._openAt(e.clientX, e.clientY, false, true)) this._userOpenChange.mark()\n }\n\n private _onContextMenuOutside = (e: MouseEvent) => {\n if (this._isOpen && !this._outsideClickGuard.isInside(e)) {\n this._closeFromUser()\n }\n }\n\n private _onKeydown = (e: KeyboardEvent) => {\n if (this.disabled) return\n\n if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {\n e.preventDefault()\n const focused = document.activeElement\n if (focused && focused !== document.body) {\n const rect = focused.getBoundingClientRect()\n if (this._openAt(rect.left, rect.bottom, true, false)) this._userOpenChange.mark()\n } else {\n if (this._openAt(window.innerWidth / 2, window.innerHeight / 2, true, false)) this._userOpenChange.mark()\n }\n return\n }\n }\n\n /*\n * 捕获阶段记「可激活的菜单项被点了」,早于条目自己的监听器。\n *\n * 判据与 `_onMenuClick` 同源(同一个 `getMenuItemFromEvent` + 同样的 disabled /\n * submenu 排除),但只置标志、不做关闭。子菜单父项与禁用项在这一层就被排除,\n * 它们不触发关闭,不该让后续关闭提前出顶层。\n *\n * 挂在面板上而不是条目上:条目在开菜单期间被搬进面板,面板是它们此刻共同的祖先。\n *\n * **标志的作用域是这一次点击,不是整个会话。** 菜单项被点并不必然导致关闭:消费方在\n * 自己的条目回调里 `stopPropagation()` 是常见写法(勾选型条目要让菜单留着),此时面板上\n * 的冒泡监听收不到,菜单不关。若标志一直留到会话结束,那一次点击之后的**每一次**关闭——\n * Escape、点外面、程序式 `close()`——都会被误判成菜单项激活而提前出顶层,把 README 承诺的\n * 「点外面 / Escape / 程序式关闭不抢焦点」整个破坏掉。\n *\n * 复位用微任务而不是「会话结束」:捕获阶段(含本监听器)与冒泡阶段(`_onMenuClick`\n * 所在的阶段,关闭动作就发生在这里)都在同一个任务里跑完,微任务检查点在它们之后。\n * 于是需要这个标志的两条路径都赶得上——消费方同步调 `close()`、组件自己在冒泡阶段关——\n * 而「点了但没关」的那一条必然跨过微任务检查点,标志已被清掉。\n */\n private _onMenuItemActivation = (e: Event) => {\n const item = getMenuItemFromEvent(e)\n if (!item || item.hasAttribute('disabled') || item.hasAttribute('submenu')) return\n this._menuItemActivated = true\n queueMicrotask(() => {\n this._menuItemActivated = false\n })\n }\n\n private _onMenuClick = (e: MouseEvent) => {\n const item = getMenuItemFromEvent(e)\n if (!item || item.hasAttribute('disabled')) return\n if (item.hasAttribute('submenu')) {\n this._openSubmenu(item)\n return\n }\n this._closeFromUser('menu-item')\n }\n\n private _getLevelContainer(level: number) {\n return level === 0 ? this._menu : this._activeSubmenus[level - 1]\n }\n\n private _getLevelItems(level: number): HTMLElement[] {\n const container = this._getLevelContainer(level)\n if (!container) return []\n return getMenuChildren(container.content)\n }\n\n private _onWheel = (e: WheelEvent) => {\n this._preventBackgroundScroll(e)\n }\n\n private _onTouchMove = (e: TouchEvent) => {\n this._preventBackgroundScroll(e)\n }\n\n private _onDocumentKeydown = (e: KeyboardEvent) => {\n if (!this._isOpen) return\n // 鼠标右键打开的菜单焦点不在菜单内,e.target !== this 时也必须能 Escape 关闭;\n // 菜单为模态浮层,按 Escape 即关闭,无需限定焦点位置\n handleMenuKeyboard(this._keyboardDelegate, e)\n }\n\n private _closeLastSubmenuOrMenu() {\n if (this._activeSubmenus.length > 0) {\n const level = this._activeSubmenus.length - 1\n const parent = this._activeSubmenuItems[level]\n this._closeSubmenusFrom(level)\n focusMenuItem(parent)\n this._hoverBinder.bind()\n } else {\n this._closeFromUser()\n }\n }\n\n private _preventBackgroundScroll(e: Event) {\n if (!this._isOpen || this.noScrollLock || this._isMenuPanelEvent(e)) return\n e.preventDefault()\n }\n\n private _syncScrollLock(isOpen = this._isOpen) {\n this._scrollLock.sync(isOpen && !this.noScrollLock)\n }\n\n /** 按 panel 撤销一层子菜单的登记;句柄已随根层递归撤销时幂等无操作。 */\n private _releaseSubmenu(panel: HTMLElement) {\n this._submenuHandles.get(panel)?.release()\n this._submenuHandles.delete(panel)\n }\n\n private _isMenuPanelEvent(e: Event): boolean {\n // 在监听器内部判定:composedPath() 在派发结束后会被清空。\n return this._menuHandle?.containsEvent(e) ?? false\n }\n\n override render() {\n return html`\n <div class=\"context-menu-anchor\">\n <slot @slotchange=${this._onSlotChange}></slot>\n </div>\n `\n }\n\n private _onSlotChange() {\n this._hideMenuItems()\n // 打开期间宿主重建顶层项时,新成员要移入 portal 才对用户可见;\n // 关闭状态下无需移动,等下次打开时由 _setupMenuItems 统一处理。\n if (this._isOpen) this._scheduleRefresh()\n }\n\n declare readonly $events: {\n 'open-change': CustomEvent<{ open: boolean }>\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'web-ui-context-menu': WebUiContextMenu\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/components/context-menu/index.ts"],"sourcesContent":["import { computePosition, flip, shift } from '@floating-ui/dom'\nimport { html, LitElement, unsafeCSS } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\nimport { UserChangeController } from '@/shared/events/user-change'\nimport {\n createClosingSubmenuStack,\n createMenuHoverBinder,\n createMenuOutsideClickGuard,\n getEnabledMenuLevelItems,\n getFocusedMenuItemFromPanels,\n handleMenuKeyboard,\n type MenuHoverDelegate,\n type MenuKeyboardDelegate\n} from '@/shared/menu-behavior'\nimport { createMenuPortalOverlay, type MenuPortalOverlay } from '@/shared/menu-portal/menu-portal'\nimport {\n captureFrameworkAnchors,\n findFocusedMenuItem,\n focusMenuItem,\n getMenuChildren,\n getMenuItemFromEvent,\n hideNestedMenuChildren,\n moveMenuChildren,\n orderManagedMenuItems,\n reconcileManagedMenuItems,\n restoreFrameworkAnchors,\n returnManagedMenuItemsToSlot\n} from '@/shared/menu-portal/menu-tree'\nimport { normalizeNumber } from '@/shared/normalize'\nimport { dispatchOpenChangeEvent } from '@/shared/open-state'\nimport { defineOpenOverlay, type OpenOverlayHandle } from '@/shared/overlay/open-overlay'\nimport { defineOverlayPositioningGeneration } from '@/shared/overlay/positioning-generation'\nimport { hideOverlayPresence, showOverlayPresence } from '@/shared/overlay/presence'\nimport { findNearestTheme } from '@/shared/overlay/theme-overlay-scope'\nimport { defineScrollLockLease } from '@/shared/scroll-lock/scroll-lock'\n\nimport scrimStyle from './scrim.css?inline'\nimport style from './style.css?inline'\n\nconst MARKER_TEXT = 'wui-context-menu-item'\n\n/** 与 iOS / Android 原生长按一致的默认时长。 */\nconst DEFAULT_LONG_PRESS_DELAY = 500\n/** long-press-delay 的上界:超过 5s 的长按不再是「长按」,等同没有该属性。 */\nconst MAX_LONG_PRESS_DELAY = 5000\n/** 按住期间的容差位移;超出即视为滚动/拖拽意图,取消长按。 */\nconst LONG_PRESS_MOVE_TOLERANCE = 10\n/**\n * 长按开菜单后吸收浏览器补发事件的窗口上限。\n *\n * 这不是时序保证:实测补发几乎紧跟抬手(touchend(1019ms) → click(1022ms),间隔 3ms),\n * 1000ms 远大于实际需要,只是一个陈旧兜底上限。真正的边界是下一次 `pointerdown`\n * (`_onPointerDown` 无条件清零,含鼠标)与菜单关闭,两条硬边界都在窗口判定之外,\n * 所以窗口只负责兜住「补发事件既没等到下一次按下、菜单也没关闭」这种不该发生的路径。\n *\n * **窗口的起点是手势结束而不是菜单打开**(见 `_longPressGestureEndedAt`):补发事件由\n * 引擎在 touchend 处理时生成,主线程被长任务占住时这一步能比菜单打开晚 1s 以上\n * (实测 click 比打开晚 1325ms)。以打开时刻为起点,这个上限会被那段延迟挤爆——\n * 补发 click 落到窗口外,被当成用户真实点击把**同一次长按刚打开**的菜单关掉。\n * 以手势结束为起点,补发事件相对它只有几毫秒,窗口才真的覆盖「同一次手势的补发」。\n */\nconst LONG_PRESS_FOLLOW_UP_WINDOW_MS = 1000\n\n/**\n * 菜单打开期间铺在整屏上的 scrim `<dialog>`。\n *\n * 用 `<dialog>` + `showModal()` 而不是 plain `div`:模态 dialog 进 top layer,\n * 于是「z-index 谁高谁低」这道约束整体消失 —— 面板与 scrim 同在 top layer,\n * 而 top layer 之下的任何 z-index 都碰不到它们。plain `div` 也能修好 light-dismiss\n * 穿透,但那样 scrim 必须靠 z-index 压过 `--wui-layer-menu`(100),是另一套易碎的约定。\n */\nconst SCRIM_ATTR = 'data-wui-menu-scrim'\n\ntype CloseSource = 'menu-item' | 'default'\n\n/** 首项聚焦的最大尝试次数(含首次)。见 `_focusFirstItem`。 */\nconst FOCUS_FIRST_ITEM_ATTEMPTS = 5\n\n/** 面板与视口边缘的最小间距,与既有夹取算术同源。 */\nconst VIEWPORT_PADDING = 8\n\n@customElement('web-ui-context-menu')\nexport class WebUiContextMenu extends LitElement {\n static override styles = unsafeCSS(style)\n\n @property({ type: Boolean, reflect: true }) disabled = false\n @property({ type: Boolean, reflect: true, attribute: 'no-scroll-lock' }) noScrollLock = false\n /** opt-in:触屏长按打开菜单。默认关闭,鼠标路径靠原生 contextmenu,无需此属性。 */\n @property({ type: Boolean, reflect: true, attribute: 'long-press' }) longPress = false\n\n private _longPressDelay = DEFAULT_LONG_PRESS_DELAY\n\n /**\n * 走包内既有的 `normalizeNumber` 通道(与 `dropdown.offset`、`popover.offset` 同形)。\n * 不能只靠 `Math.max(0, …)` 挡负数:`Math.max(0, NaN) === NaN`,而属性写错\n * (`long-press-delay=\"abc\"`)时 Lit 的 Number converter 给的正是 NaN,\n * `setTimeout(fn, NaN)` 会立刻触发,长按就在 pointerdown 同一帧开菜单、绕过全部时长语义。\n */\n @property({ type: Number, attribute: 'long-press-delay' })\n get longPressDelay(): number {\n return this._longPressDelay\n }\n set longPressDelay(v: number) {\n const old = this._longPressDelay\n this._longPressDelay = normalizeNumber(v, 0, MAX_LONG_PRESS_DELAY, DEFAULT_LONG_PRESS_DELAY)\n this.requestUpdate('longPressDelay', old)\n }\n\n @state() private _isOpen = false\n @state() private _x = 0\n @state() private _y = 0\n\n private _activeSubmenus: MenuPortalOverlay[] = []\n private _activeSubmenuItems: HTMLElement[] = []\n private _longPressTimer: ReturnType<typeof setTimeout> | undefined\n private _longPressOrigin: { x: number; y: number } | undefined\n /**\n * 长按自行开菜单的时刻。浏览器会为**同一次触摸**补发两个事件,二者都属于\n * 那一次意图,不是两次独立操作:\n *\n * - 原生 `contextmenu`:长按被识别时立刻派发,会重复打开(`_openAt` 走已开分支)。\n * - 合成 `click`:`touchend` 之后由引擎补发,会把刚打开的菜单立刻 light-dismiss。\n * 实测真实触控管线下的顺序为 pointerdown(9ms) → 长按开菜单(≈509ms) →\n * touchend(1019ms) → click(1022ms),缺这一步菜单开了就被自己的补发事件关掉。\n *\n * 因此在窗口内吸收这两个补发事件;窗口由下一次 `pointerdown` 关闭。\n */\n private _longPressOpenedAt: number | null = null\n /**\n * 长按手势结束(pointerup)的时刻,补发事件窗口的起点。\n *\n * 为 null 表示「这次长按的手势还没结束」——窗口退回以菜单打开时刻为起点。手势被\n * pointercancel 打断、或组件在抬手前就被卸载时都会走到这条兜底,行为与此前一致。\n */\n private _longPressGestureEndedAt: number | null = null\n // 子菜单 dialog 定位的唯一写者代数(按 panel 键控):同帧关闭→重开复用同一 panel\n // 时只允许最新一次定位写入;不同层级子菜单各有 panel,互不作废。\n private readonly _submenuPositionEpochs = new WeakMap<HTMLElement, number>()\n private _menu?: MenuPortalOverlay\n /**\n * 模态 scrim。菜单打开期间存在并处于 `showModal()` 状态,关闭时随面板一起移除。\n *\n * 面板必须**显式**挂进它(`createMenuPortalOverlay` 的第三参):`resolveOverlayContainer`\n * 查的是 target 的**祖先**里有无已打开的 dialog,而 scrim 是面板的反方向,祖先发现查不到。\n */\n private _scrim?: HTMLDialogElement\n /**\n * 本次开启是否**水平居中于触发点**(触屏长按路径)。鼠标 / 键盘路径仍左上角对齐光标。\n *\n * 只切换 placement 的对齐量,不切换定位算法 —— 两条入口共用 `_positionMenu` 里的\n * 同一套 `computePosition` + `flip` / `shift`,垂直契约(上缘对齐触发点、向下展开、\n * 放不下翻到上方)完全一致。\n *\n * 触屏要居中:手指按下去的位置是「这块内容在这里」的意思,菜单在它正下方居中展开,\n * 正是 iOS / Android 原生长按菜单的形态;左对齐会让菜单整体偏到手指右侧,看起来像\n * 弹在了别的东西上面。\n *\n * 鼠标不居中:桌面右键的肌肉记忆是菜单**左上角**出现在光标处。更要紧的是居中会让光标\n * 落在面板上缘正中 —— 也就是落在第一项的命中区里。后果是菜单一开就有项处于 hover 高亮,\n * 并且紧接着再右键一次(想开子菜单)会打在菜单项上而不是空白处。`overlay-in-dialog`\n * 里 `openAt(100, 400)` 那条用例钉的 `'top left'` 正是这条契约。\n *\n * 判据是**开启来源**(调用点显式传入),不靠运行时嗅探 pointerType 或视口宽度:\n * 后两者会让同一块屏幕上的两种输入拿到两套布局,且与本组件 opt-in 的 `long-press`\n * 属性不同源。\n */\n private _centerOnPressPoint = false\n\n /**\n * 本次关闭的来源,决定焦点归还走哪条路。语义见 `close()` 里的说明。\n *\n * 只有菜单项激活这一条需要抢在退场之前出顶层;点外面 / Escape / 程序式关闭一律\n * 保留 dialog 默认效果 —— 那正是用户「回到原处」的期望。\n */\n private _closeSource: CloseSource = 'default'\n /**\n * 触发「菜单项激活」的那次事件对象;作用域与理由见 `_onMenuItemActivation`。\n *\n * 存**事件**而不是布尔标志:判定「这次 `close()` 是不是发生在这一次派发里」可以问事件自己\n * (`eventPhase !== 0`),不必用定时器去近似。布尔标志只能靠「派发结束时清掉」来划界,\n * 而划界的那一步必须与派发末尾落在同一个任务、同一个微任务检查点之后——那是个会错的假设。\n *\n * 存在的理由是 `close()` 的出顶层判定不能只看 `_closeSource`:消费方完全可以在自己的\n * 菜单项回调里直接调 `close()`(interweave 的右键重命名就是 `emit` 之后自己关),\n * 那时 `_closeSource` 还是 `'default'`,scrim 就一直留在 top layer 里没退出,\n * 消费方随后把焦点送进编辑器的动作落在 inert 文档上被浏览器静默丢弃。\n *\n * 消费方先关、组件后关的路径(`_onMenuClick` 的 `_closeFromUser('menu-item')`)随后看到的是\n * `'menu-item'`,两条判定同向,不会互相抵消。\n */\n private _menuItemActivationEvent: Event | null = null\n /** 根面板的开启会话句柄;未开启时为 null。查询走它。 */\n private _menuHandle: OpenOverlayHandle | null = null\n /**\n * 子菜单 panel → 会话句柄。子层经 `adopt` 进根句柄子树(默认不成为独立候选),\n * 但仍各自持有句柄:整层关闭或收尾动画结束时按 panel 精确撤销。\n */\n private readonly _submenuHandles = new Map<HTMLElement, OpenOverlayHandle>()\n // 行为层(hover / outside-click / 键盘 / submenu 收尾)由 shared/menu-behavior 驱动\n private readonly _outsideClickGuard = createMenuOutsideClickGuard(\n this,\n // 根句柄的 contains 递归覆盖全部已 adopt 的子层,一次查询替代逐 panel 判定。\n node => this._menuHandle?.contains(node) ?? false\n )\n private readonly _closingSubmenus = createClosingSubmenuStack<MenuPortalOverlay>({\n getPanel: container => container.panel,\n restoreItems: (container, parentItem) => this._restoreSubmenuItems(container, parentItem),\n dispose: container => {\n this._releaseSubmenu(container.panel)\n container.panel.remove()\n }\n })\n private readonly _hoverDelegate: MenuHoverDelegate = {\n getOpenDepth: () => this._activeSubmenus.length,\n getLevelItems: level => this._getLevelItems(level),\n getActiveItem: level => this._activeSubmenuItems[level],\n isSubmenuItem: item => item.hasAttribute('submenu'),\n openSubmenu: item => this._openSubmenu(item),\n closeFrom: level => {\n this._closeSubmenusFrom(level)\n this._hoverBinder.bind()\n }\n }\n private readonly _hoverBinder = createMenuHoverBinder(this._hoverDelegate, 'web-ui-dropdown-item')\n private readonly _keyboardDelegate: MenuKeyboardDelegate = {\n getFocusedItem: event =>\n getFocusedMenuItemFromPanels(\n event,\n [this._menu?.content, ...this._activeSubmenus.map(submenu => submenu.content)],\n 'web-ui-dropdown-item'\n ),\n getLevelOf: item => {\n const level = this._getItemLevel(item)\n return level === -1 ? undefined : level\n },\n getEnabledItems: level => getEnabledMenuLevelItems(this._getLevelContainer(level)?.content),\n isSubmenuItem: item => item.hasAttribute('submenu'),\n openSubmenuInstant: item => this._openSubmenu(item, true),\n closeToParent: level => {\n const parent = this._activeSubmenuItems[level - 1]\n this._closeSubmenusFrom(level - 1)\n this._hoverBinder.bind()\n return parent\n },\n closeDeepestOrAll: () => this._closeLastSubmenuOrMenu()\n }\n /*\n * 开启态浮层(issue #120 Block 1)。根面板是登记对象:子菜单经 adopt 成为它的逻辑\n * 后代;关闭动作仍走 closeDeepestOrAll(最深子菜单优先)。\n */\n private readonly _overlay = defineOpenOverlay().make({\n requestClose: () => this._keyboardDelegate.closeDeepestOrAll(),\n isConnected: () => this.isConnected\n })\n private readonly _menuItemAnchors = new Map<HTMLElement, Comment>()\n private readonly _scrollLock = defineScrollLockLease().make()\n private readonly _userOpenChange = new UserChangeController()\n private _shouldOpenInstantly = true\n private _suppressInitialFocusVisible = true\n private _refreshScheduled = false\n // 主菜单 dialog 路径与子菜单一样使用代数令牌;快速 openAt/refresh 或 close 后,\n // 迟到的 positioning promise 只能被丢弃,不能覆盖最新坐标。\n private readonly _menuPositionGeneration = defineOverlayPositioningGeneration().make()\n // 菜单打开期间宿主可能不经重定位直接改写子内容(网络推送、定时器等),\n // 新节点缺隐藏 slot 会可见叠加到菜单上;观察 portal 内容并在下一帧重新同步。\n private readonly _contentObserver = new MutationObserver(() => {\n if (this._isOpen) this._scheduleRefresh()\n })\n\n get isOpen(): boolean {\n return this._isOpen\n }\n\n override connectedCallback() {\n super.connectedCallback()\n this._hideMenuItems()\n this.addEventListener('contextmenu', this._onContextMenu)\n this.addEventListener('keydown', this._onKeydown)\n this.addEventListener('click', this._onMenuClick)\n this.addEventListener('pointerdown', this._onPointerDown)\n this.addEventListener('pointermove', this._onPointerMove)\n this.addEventListener('pointerup', this._onPointerUp)\n this.addEventListener('pointercancel', this._onPointerCancel)\n document.addEventListener('contextmenu', this._onContextMenuOutside)\n document.addEventListener('wheel', this._onWheel, { capture: true, passive: false })\n document.addEventListener('touchmove', this._onTouchMove, { capture: true, passive: false })\n document.addEventListener('keydown', this._onDocumentKeydown)\n }\n\n override disconnectedCallback() {\n super.disconnectedCallback()\n this.removeEventListener('contextmenu', this._onContextMenu)\n this.removeEventListener('keydown', this._onKeydown)\n this.removeEventListener('click', this._onMenuClick)\n this.removeEventListener('pointerdown', this._onPointerDown)\n this.removeEventListener('pointermove', this._onPointerMove)\n this.removeEventListener('pointerup', this._onPointerUp)\n this.removeEventListener('pointercancel', this._onPointerCancel)\n document.removeEventListener('contextmenu', this._onContextMenuOutside)\n document.removeEventListener('wheel', this._onWheel, true)\n document.removeEventListener('touchmove', this._onTouchMove, true)\n document.removeEventListener('keydown', this._onDocumentKeydown)\n this._contentObserver.disconnect()\n this._menuPositionGeneration.invalidate()\n this._outsideClickGuard.dispose()\n this._hoverBinder.dispose()\n this._scrollLock.release()\n this._menuHandle?.release()\n this._menuHandle = null\n this._returnItemsToSlot()\n this._menu?.panel.remove()\n this._menu = undefined\n this._closeSubmenusFrom(0, true)\n this._closingSubmenus.restoreAll()\n /*\n * scrim 挂在宿主以外的容器里(theme overlay root,无主题时 document.body),不随宿主一起\n * 脱离文档。不在这里收掉就会留下一张仍然 `showModal()` 的全屏 dialog:top layer 不受宿主\n * 移除影响,整个页面就此锁死。\n */\n this._closeScrim()\n // 脱离文档即视为关闭:否则重连后 _isOpen 仍为 true 而 _menu 已清空,\n // 下次 openAt 会走已打开分支静默失败,菜单无法再打开。\n this._isOpen = false\n this._cancelLongPress()\n this._longPressOpenedAt = null\n this._longPressGestureEndedAt = null\n }\n\n protected override updated(changed: Map<string, unknown>) {\n // Lit 的已排队更新可在卸载后执行,不能让已失效实例重新获取全局滚动锁。\n if (!this.isConnected) {\n this._syncScrollLock(false)\n return\n }\n\n if (changed.has('_isOpen')) {\n if (this._isOpen) {\n this._syncScrollLock()\n if (!this._menu) {\n /*\n * 顺序:先建 scrim 并 `showModal()`,再把面板挂进去。\n *\n * `showModal()` 会把此前聚焦的元素记为还原目标;此刻焦点仍在 opener 上,\n * 正是归还目标。面板随后 `_focusFirstItem()` 把焦点带进菜单,关闭时 UA 还回去。\n *\n * 面板挂进 scrim 是承重的,不是「顺手放一起」:top layer 只对**自身子树内的\n * 元素**做命中拦截,面板若留在 overlay root(scrim 之外),模态期间它自己就不可点。\n */\n const scrim = this._openScrim()\n this._menu = createMenuPortalOverlay('context-menu', this, scrim)\n this._menu.panel.setAttribute('role', 'menu')\n this._menu.panel.setAttribute('aria-label', '上下文菜单')\n this._menu.panel.addEventListener('click', this._onMenuClick)\n this._menu.panel.addEventListener('click', this._onMenuItemActivation, true)\n }\n /*\n * 登记与面板建立解耦。退场动画被打断时(`_closeMenuAfterPresence` 在\n * `hideOverlayPresence` 返回 false 后提前退出)`_menu` 还在,而关闭分支已经把句柄\n * 撤了;此时重开必须补 claim。否则菜单开着且可见却没有登记:Escape 关不掉它\n * (仲裁看不到它),而 `_menuHandle === null` 会让每次 document click——包括面板\n * 内部与子菜单父项——都被 `_outsideClickGuard` 判成外部点击而关闭菜单。\n */\n if (!this._menuHandle) {\n this._menuHandle = this._overlay.claim(this._menu.panel)\n // 重新 claim = 新会话:根层的子层随之作废,仍在场的子菜单面板要重新 adopt 回\n // 新句柄的子树,否则根句柄的 contains 看不到它们。\n for (const submenu of this._activeSubmenus) {\n this._submenuHandles.delete(submenu.panel)\n this._submenuHandles.set(submenu.panel, this._menuHandle.adopt(submenu.panel))\n }\n // 收尾栈里的子菜单面板同样「可见但已脱离快照」:`_closeSubmenusFrom` 只把它们\n // 移出 `_activeSubmenus`,面板仍在 DOM 里退场。**这个循环承重**:不补 adopt,\n // 它们就会落在句柄的登记树之外,被判成「面板外」,点它内部会关掉整张菜单。\n // 用例 `退场窗口内重开后,收尾中的子菜单面板在取回前后都不可被当成面板外` 守着\n // 这一条 —— 把循环体整段删掉,那条会红(实测 1 failed | 95 passed)。\n // 写进 `_submenuHandles` 还因为收尾结束时 `_releaseSubmenu` 按 panel 取句柄释放。\n for (const container of this._closingSubmenus.closing()) {\n this._submenuHandles.set(container.panel, this._menuHandle.adopt(container.panel))\n }\n }\n // 父项始终留在 menu.content 内,观察它即可覆盖各级子菜单在打开期间的内容重建。\n this._contentObserver.observe(this._menu.content, { childList: true, subtree: true })\n requestAnimationFrame(() => {\n if (!this._isOpen || !this._menu) return\n this._refreshMenu()\n showOverlayPresence(this._menu.panel, { isInstant: this._shouldOpenInstantly })\n this._shouldOpenInstantly = true\n /*\n * 首项聚焦必须再等一帧,不能和 `_refreshMenu()` 同帧。\n *\n * 两个原因都指向「太早」:\n *\n * 1. `showModal()` 的 dialog focusing steps 由 UA 排队执行,会在我们已经聚焦首项\n * **之后**再把焦点挪到 dialog 上 —— 模态化之前没有这一步,也就没有这场竞争。\n * 2. `_setupMenuItems()` 刚创建的 `web-ui-dropdown-item` 尚未完成 Lit 的 shadow\n * 渲染,`focusItem()` 此刻还找不到 `.item-inner`,静默无操作。\n *\n * 实测:同帧聚焦后 `document.activeElement` 是 `<dialog>` 而非首项;再等一帧聚焦,\n * 焦点稳定落在菜单项内。\n */\n requestAnimationFrame(() => {\n if (!this._isOpen || !this._menu) return\n this._focusFirstItem()\n })\n })\n } else {\n this._syncScrollLock(false)\n // 吸收窗口只覆盖「长按之后浏览器补发的那几个事件」。菜单已关,\n // 之后任何一次真实右键或点击都必须走正常路径,不能被这个窗口吞掉。\n this._longPressOpenedAt = null\n this._longPressGestureEndedAt = null\n // release ⟺ 关闭:登记与仲裁归属同时撤销,退场动画只是视觉收尾。\n this._menuHandle?.release()\n this._menuHandle = null\n this._contentObserver.disconnect()\n this._menuPositionGeneration.invalidate()\n this._refreshScheduled = false\n void this._closeMenuAfterPresence()\n }\n if (this._userOpenChange.consume()) this._dispatchChange(this._isOpen)\n }\n if (changed.has('noScrollLock')) this._syncScrollLock()\n }\n\n /**\n * 在指定视口坐标打开菜单。\n * @param x 水平坐标(px)。\n * @param y 垂直坐标(px)。\n * @returns 无返回值;命令式打开不派发 `open-change` 事件。\n */\n openAt(x: number, y: number) {\n this._openAt(x, y, true, true)\n }\n\n private _openAt(\n x: number,\n y: number,\n isInstant: boolean,\n suppressFocusVisible: boolean,\n centerOnPressPoint = false\n ): boolean {\n if (this.disabled) return false\n this._x = x\n this._y = y\n this._shouldOpenInstantly = isInstant\n this._suppressInitialFocusVisible = suppressFocusVisible\n this._centerOnPressPoint = centerOnPressPoint\n this._outsideClickGuard.arm()\n if (this._isOpen) {\n this._closeSubmenusFrom(0, true)\n this._closingSubmenus.restoreAll()\n // 先失效尚未完成的旧定位,再调度下一帧新代;避免新请求前的旧 promise 胜出。\n this._menuPositionGeneration.invalidate()\n this._scheduleRefresh()\n return false\n }\n this._isOpen = true\n // 新会话:上一次派发留下的事件对象(若 `disconnectedCallback` 那条路径绕过了 `close()`)\n // 一并丢掉。它那时 `eventPhase` 已是 0、判定为假,但不该跨会话留着。\n this._menuItemActivationEvent = null\n return true\n }\n\n /**\n * 关闭菜单。\n * @returns 无返回值;命令式关闭不派发 `open-change` 事件。\n */\n close() {\n if (!this._isOpen) return\n this._isOpen = false\n this._menuPositionGeneration.invalidate()\n /*\n * 菜单项激活触发的关闭要**现在**就让 scrim 出顶层,不能等退场结束。\n *\n * 焦点归还由 UA 负责(`close()` 把焦点还给 `showModal()` 之前的 activeElement),\n * 而菜单项的回调常紧接着把焦点送到别处 —— 典型是右键重命名:回调把行换成编辑态、\n * 下一帧 `select()` 进 textarea。等到退场结束才关,UA 的归还会把焦点从 textarea\n * 拽回那一行,blur 掉编辑器,而 editable-text 的 blur 契约会**提交未改动的标题**。\n * 那是 interweave 上真实存在的 bug,不能放回来。\n *\n * **判定不能只看 `_closeSource`。** 消费方可以在自己的菜单项回调里直接调 `close()`:\n * 那时 `_onMenuClick` 还没跑到(它在面板上是冒泡阶段,而回调挂在条目上、先跑),\n * `_closeSource` 仍是 `'default'`,scrim 就留在 top layer 里不退,模态期间整份文档\n * 是 inert 的 —— 消费方紧接着送进编辑器的焦点被浏览器静默丢弃,症状是「重命名后焦点\n * 停在菜单项上、编辑器根本没进编辑态」,与本段要防的 blur 是同一个根因的两种落点。\n * `_menuItemActivationEvent`(捕获阶段记下的那次派发)把那一条路径也纳入判定,\n * 理由见它的注释。\n *\n * 此刻焦点还在菜单里,UA 的归还正好落在 opener 上——那本来就是用户期望的落点,\n * 应用随后的焦点转移不会被覆盖。元素本身要留到面板真正移除(见 `_releaseScrimModality`)。\n */\n if (this._closeSource === 'menu-item' || this._isDispatchingMenuItemActivation()) {\n this._releaseScrimModality()\n }\n this._closeSource = 'default'\n this._menuItemActivationEvent = null\n }\n\n /**\n * 「当前是否正处在一次可激活菜单项的点击派发里」。\n *\n * 判据是 `eventPhase !== 0`(DOM 规范:派发开始前置 CAPTURING_PHASE,结束后由派发算法的\n * terminating 步骤置回 NONE),即**事件自己知道它此刻有没有在派发中**。于是作用域精确等于\n * 「这一次派发」,既不需要定时器也不需要猜微任务检查点的位置:\n *\n * - 消费方在条目自己的监听器里同步调 `close()` → 派发未结束 → 判定为真(要的就是这个)。\n * - 消费方 `stopPropagation()` 让菜单留着 → 派发照样结束 → 之后的 Escape / 点外面 / 程序式\n * `close()` 一律判定为假,README 承诺的「那三条关闭不抢焦点」不会被这一次点击污染。\n * - 消费方把 `close()` 推到下一个任务 → 判定为假,退回不提前出顶层(与没有这个标记时一致)。\n */\n private _isDispatchingMenuItemActivation(): boolean {\n return this._menuItemActivationEvent !== null && this._menuItemActivationEvent.eventPhase !== 0\n }\n\n private readonly _closeFromUser = (source: CloseSource = 'default') => {\n if (!this._isOpen) return\n this._userOpenChange.mark()\n this._closeSource = source\n this.close()\n }\n\n // 同一帧内多次内容变化只刷新一次,避免观察者与刷新自身 append 形成循环。\n private _scheduleRefresh() {\n if (this._refreshScheduled || !this._isOpen) return\n this._refreshScheduled = true\n requestAnimationFrame(() => {\n this._refreshScheduled = false\n if (!this._isOpen || !this._menu) return\n this._refreshMenu()\n })\n }\n\n // 重新同步 portal 内容并重定位;fresh-open、重定位与观察者触发的刷新共用。\n private _refreshMenu() {\n if (!this._menu) return\n this._setupMenuItems()\n this._positionMenu()\n this._hoverBinder.bind()\n }\n\n /**\n * 定位:**触屏长按与鼠标右键共用这一条 Floating UI 路径**。\n *\n * 两条入口现在只差「触发点的 x/y 从哪来」,布局契约完全一致:\n * `bottom-start` 表达「面板上缘 == 触发点 y、向下展开」,水平以触发点 x 为基准。\n * 之前有一条手写的贴底分支(先按点、再按视口下缘算 `top`),那是把「贴视口底」硬编码\n * 成算术;真机反馈证明它错(菜单跑到屏幕最底部,与手指按下的位置完全脱节)。现在\n * 落点交给 `flip` / `shift` 按**实际空间**决定,`placement` 只表达意图。\n *\n * 不再按「是否触屏」分流:窄视口的鼠标右键同样期望菜单贴着光标出现,按输入类型或视口\n * 宽度分流会让同一块屏幕上的两种输入拿到两套布局。分流所需的 `_anchorAtPressPoint`\n * 连同 `_positionMenuInViewport` / `_positionMenuAtPressPoint` 两个方法一起删掉 ——\n * 面板恒为 open scrim `<dialog>` 的直接子节点(`createMenuPortalOverlay` 第三参传入\n * scrim,`menu-portal.ts` 里 `container = scrim ?? …`),所以旧代码那条\n * 「非 dialog 容器」的分支对 context-menu 本来就不可达。\n *\n * 算出来的 left/top 是**视口坐标**。面板是 `position: fixed`,而 scrim 刻意不带\n * transform / filter / contain(见 style.css),因此不构成 fixed 的包含块 ——\n * 坐标直接相对视口解析。这条等价关系由 style.css 的满屏声明保证,并由 R6 的\n * 「scrim 无 transform/filter/contain」+ viewport-fit 的几何断言兜底。\n */\n private _positionMenu() {\n const panel = this._menu?.panel\n if (!panel) return\n\n /*\n * 先读安全区,再碰面板:`getComputedStyle` 会强制一次同步布局,让它发生在面板进入\n * 「可测」状态之前,而不是夹在「面板刚可测」与「Floating UI 量它」之间。\n *\n * 需要说清楚的是,**这不是** viewport-fit 那条横向断言里 2px 抖动的成因。把这一行移到\n * 最前面实测并没有让那个偏差消失(改前改后都是 204 对期望 206)。那个 2px 来自 flip 的\n * reset 会多跑一轮测量:那一刻面板宽 202px、落定后 200px。相关用例已经不再钉与视口\n * 边界的精确距离。\n */\n const safeAreaBottom = this._safeAreaBottom()\n\n panel.style.visibility = 'hidden'\n panel.style.display = ''\n\n const generation = this._menuPositionGeneration.next()\n /*\n * 可用矩形 = 视口 − 安全区 − 最小间距。flip 与 shift 必须吃**同一份** padding:\n *\n * - flip 用它判断「这一侧放不放得下」,决定翻不翻;\n * - shift 用它做实际钳制,且 `crossAxis: true` 会**同时**钳主轴与交叉轴\n * (core 的 shift 默认 mainAxis=true / crossAxis=false,开了 crossAxis 才两边都管)。\n *\n * 安全区必须进 shift 而不只是 flip:flip 只挑「哪一侧」,挑完就把面板贴在触发点上,\n * 不再管这一侧内部的位置。触发点本身落在安全区里时(按在屏幕最底部),挑出来的\n * 「上方」那一侧仍然把面板下缘按在安全区里 —— 只有 shift 的主轴钳制会把它推上来。\n * 这条正是 R7 用例在钉的行为。\n */\n const boundaryPadding = {\n top: VIEWPORT_PADDING,\n bottom: VIEWPORT_PADDING + safeAreaBottom,\n left: VIEWPORT_PADDING,\n right: VIEWPORT_PADDING\n }\n /*\n * 限制 flip 的候选集为「下方 / 上方」两个,**刻意不含 `-start` / `-end` 的另一侧**。\n *\n * 为什么必须限制:`flip` 对对齐过的 placement(`bottom-start`)默认展开成\n * `getExpandedPlacements()`,候选里带着 `bottom-end` / `top-end`。于是鼠标贴视口右缘、\n * `bottom-start` 的对齐侧溢出时,flip 会认定「`bottom-end` 才放得下」并把面板整个挪到\n * 光标左侧。落点仍在视口内,但那是一次没人要求的契约变更 —— 水平夹取本来就归 shift,\n * 而且对齐量一变,transform-origin 的推导前提(对齐量不变 ⇒ 水平 origin 只由 shift 位移\n * 决定)就不成立了。\n *\n * 用的是 `fallbackPlacements` 而不是 `allowedPlacements`:后者是 `autoPlacement` 的选项,\n * `flip` 根本不读,传进去只会被静默忽略(TS 会报错,运行时会当成 padding 之外的字段丢掉)。\n * `fallbackPlacements` 正是 flip 用来**替换**上面那个默认展开列表的字段,指定后\n * `placements = [initialPlacement, ...fallbackPlacements]`,连 bestFit 兜底也只在这份\n * 候选里挑,`-end` 因此永远不可能出现。\n *\n * 用例「鼠标贴视口右缘时对齐量不被 flip 改成 -end」钉的就是这一条。\n */\n const placements = this._centerOnPressPoint\n ? (['bottom', 'top'] as const)\n : (['bottom-start', 'top-start'] as const)\n\n void computePosition({ getBoundingClientRect: () => new DOMRect(this._x, this._y, 0, 0) }, panel, {\n strategy: 'fixed',\n /*\n * `bottom`(触屏):面板上缘对齐触发点 y、水平居中于触发点 x。\n * `bottom-start`(鼠标):同样上缘对齐 y,但左缘对齐光标 x。理由见\n * `_centerOnPressPoint` —— 居中会把光标落进面板上缘的命中区。\n *\n * 居中在贴边时**必然**被 shift 的交叉轴钳制打破(按点 x 太靠边,居中的面板会溢出视口)。\n * 这不是缺陷而是取舍:完整可见优先于几何居中。shift 推完面板仍在视口内,\n * 只是不再居中 —— 用例「按点贴视口右缘时仍完整可见」钉的就是这一条。\n */\n placement: placements[0],\n middleware: [\n /*\n * flip 负责「向下放不下就翻到按点上方」—— 面板下缘对齐按点、向上长。\n * 两侧都放不下(按点极低且面板极高)时它按 bestFit 选空间多的一侧并夹住主轴,\n * 所以不需要自己再写一遍兜底夹取。\n */\n flip({ fallbackPlacements: [...placements], padding: boundaryPadding }),\n shift({ padding: boundaryPadding, crossAxis: true })\n ]\n }).then(({ x, y, placement, middlewareData }) => {\n if (!this._isOpen || this._menu?.panel !== panel || !this._menuPositionGeneration.isCurrent(generation)) return\n const shiftX = middlewareData.shift?.x ?? 0\n /*\n * 垂直 origin 取 flip 后的实际 placement,不看 shift 位移:placement 直接说明面板\n * 落在触发点的哪一侧,而「离触发点最近的那条边」就是 scale 应该长出来的地方。\n *\n * 水平:默认跟 placement 的对齐量;被 shift 推动时改用推动方向那一侧,菜单才是朝\n * 光标「长过来」而不是从视口边缘滑进去。居中(placement 不带 `-` 对齐后缀)且未被\n * 推动时保持 `center`。\n */\n const horizontalOrigin = !placement.includes('-')\n ? shiftX === 0\n ? 'center'\n : shiftX < 0\n ? 'right'\n : 'left'\n : shiftX < 0\n ? 'right'\n : 'left'\n this._applyMenuPosition(panel, x, y, horizontalOrigin, placement.startsWith('bottom') ? 'top' : 'bottom')\n })\n }\n\n /**\n * 视口下缘的安全区高度(px)。\n *\n * 改按压点展开之后它**仍然必需**,而且不再只服务触屏:菜单默认从触发点向下长,触发点\n * 落在屏幕下半部时面板下缘会进入 home indicator 区域。它现在折进 flip 与 shift 共用的\n * 边界 padding(见 `_positionMenu`),不再需要单独一条 `maxBottom` 算术。\n *\n * 读宿主的自定义属性而不是 `env()`:自定义属性读回的是**计算后**的解析值,\n * 而 `env()` 在 JS 里没有对应的求值入口,只能间接经由声明它的属性取。\n * 属性未解析时 `parseFloat` 得 NaN,落到 0 —— 与 `env(..., 0px)` 的缺省一致。\n */\n private _safeAreaBottom(): number {\n const value = Number.parseFloat(getComputedStyle(this).getPropertyValue('--wui-context-menu-safe-area-bottom'))\n return Number.isFinite(value) ? value : 0\n }\n\n /**\n * 写下面板坐标与进场 transform 的原点。\n *\n * 两个 origin 必传且**不**再回退成坐标比较:flip 会改变垂直方向,回退表达式\n * `y < this._y ? 'bottom' : 'top'` 在「面板顶边与触发点重合」时两边都成立,等于随机取。\n * 唯一调用方从 flip 后的 placement 与 shift 位移推出真实侧边,见 `_positionMenu`。\n */\n private _applyMenuPosition(\n panel: HTMLElement,\n x: number,\n y: number,\n horizontalOrigin: 'left' | 'right' | 'center',\n verticalOrigin: 'top' | 'bottom'\n ) {\n panel.style.left = `${x}px`\n panel.style.top = `${y}px`\n panel.style.setProperty('--wui-internal-overlay-transform-origin', `${verticalOrigin} ${horizontalOrigin}`)\n panel.style.visibility = ''\n }\n\n /**\n * 把焦点带进菜单的第一项。\n *\n * `attempt` 是重试计数:菜单项是刚被 `reconcileManagedMenuItems` 搬进来的自定义元素,\n * 它们的 shadow(`.item-inner`)由各自的 Lit 更新**异步**渲染。`focusMenuItem()` 委托给\n * 项的 `focusItem()`,那一刻 `.item-inner` 还不存在,于是**静默无操作** —— 不抛错、\n * 也不留痕,只表现为「菜单开了但焦点没进去」。\n *\n * 所以这里不假设一次成功:调用后用 `findFocusedMenuItem` 复核,没落上就在下一帧再试,\n * 最多 `FOCUS_FIRST_ITEM_ATTEMPTS` 次。上界是必要的 —— 菜单项若始终渲染不出来\n * (空菜单、内容被外部换掉),不能让重试链一直跑下去。\n */\n private _focusFirstItem(attempt = 0) {\n const items = this._menu?.content.querySelectorAll<HTMLElement>('web-ui-dropdown-item:not([disabled])')\n const firstItem = items?.[0]\n if (!firstItem) return\n focusMenuItem(firstItem, { suppressFocusVisible: this._suppressInitialFocusVisible })\n if (attempt + 1 >= FOCUS_FIRST_ITEM_ATTEMPTS) return\n if (findFocusedMenuItem([this._menu?.panel])) return\n requestAnimationFrame(() => {\n if (!this._isOpen || !this._menu) return\n this._focusFirstItem(attempt + 1)\n })\n }\n\n private _setupMenuItems() {\n const menu = this._menu\n if (!menu) return\n const content = menu.content\n\n reconcileManagedMenuItems(this, content, this._menuItemAnchors, MARKER_TEXT)\n this._hideMenuItems()\n // 宿主可能在菜单打开期间动态改写 slot 子内容(如切换上下文后重建嵌套子项),\n // 新节点没有隐藏 slot 会落入父项默认 slot 可见渲染并叠到一级菜单上;\n // 因此对已移入 content 的嵌套子项也要重新隐藏。\n hideNestedMenuChildren(content, 'context-menu-hidden')\n\n // 框架 v-if 注释锚点的复位独立于元素重排:无论元素序是否已变都要执行。\n // 若挂在「元素序已变才重排」之下,顺序恰好正确时会跳过复位,锚点漂移无法收敛,\n // 而 Vue 下次翻转正依赖锚点引导新分支项的插入点。\n const anchors = captureFrameworkAnchors(content, MARKER_TEXT)\n orderManagedMenuItems(this, content, this._menuItemAnchors, MARKER_TEXT)\n restoreFrameworkAnchors(content, anchors)\n }\n\n private _returnItemsToSlot() {\n const menu = this._menu\n if (!menu) return\n\n this._closeSubmenusFrom(0, true)\n this._closingSubmenus.restoreAll()\n\n returnManagedMenuItemsToSlot(this, menu.content, this._menuItemAnchors)\n }\n\n /**\n * 建 scrim 并让它进入模态态,返回容器供面板挂载。\n *\n * 每次开启都新建而不是复用:scrim 的生命周期必须与「这一次开启」严格同进同出。\n * 复用一个 dialog 意味着上一轮的 `close()` 事件、`open` 属性与 UA 记下的焦点还原目标\n * 会跨会话残留,而这些都不是本组件能重置的。\n */\n private _openScrim(): HTMLDialogElement {\n const scrim = document.createElement('dialog')\n scrim.className = 'wui-context-menu-scrim'\n /*\n * scrim 的样式必须**显式挂到 scrim 元素上**。\n *\n * `static styles`(style.css)只进宿主的 shadow root,scrim 又是本组件自己造的节点、不进\n * 宿主 shadow root,拿不到那份样式;`scrim.css` 若只躺在文件里而不注入,就是死代码。\n * 实测未注入时 scrim 完全是 UA 默认 `dialog:modal` 的样子:`margin: auto` +\n * `width/height: fit-content` 让它在 414x896 视口里缩成 **38x38 的白盒子**正中,\n * `background` 还是不透明白 —— 菜单一开就在屏幕中间糊一块白色方块。\n *\n * 用子 `<style>` 而不是行内 `cssText`:与 `menu-portal.ts` 对 dialog 容器注入共享样式\n * 是同一手法(那边已在 dialog 容器上验证过),随元素一起移除,不留全局残留。\n */\n const scrimStyles = document.createElement('style')\n scrimStyles.textContent = scrimStyle\n scrim.append(scrimStyles)\n scrim.setAttribute(SCRIM_ATTR, '')\n scrim.addEventListener('click', this._onScrimClick)\n scrim.addEventListener('cancel', this._onScrimCancel)\n // showModal() 要求元素已在文档中。\n this._scrimParent().append(scrim)\n scrim.showModal()\n this._scrim = scrim\n return scrim\n }\n\n /**\n * scrim 的挂载点:**与面板在没有 scrim 时会落到的容器同源** —— 最近主题的 theme-owned\n * overlay root。scrim 就是那个容器的替身(面板必须挂进 scrim,见 `updated()` 的顺序注释),\n * 所以它得站在容器该站的位置上。\n *\n * 为什么不能在整棵 DOM 里随便挑位置:`--wui-color-*` 由 `<web-ui-theme>` 写在自己的 `:host`\n * 上,只沿 DOM 树向下继承。scrim 若挂到 `document.body`,而 `<web-ui-theme>` 是 body 的**子节点**,\n * 这条继承链就断了 —— 面板与 scrim 双双落回 `var(--wui-color-*, <浅色字面量>)` 的 fallback,\n * 深色外观下菜单是一块浅色玻璃(app 侧的 `dark:` 变体也够不到它)。挂进 theme overlay root\n * 之后,面板与 scrim 跟 dropdown / select 一样吃同一份 token。\n *\n * 无主题时保持历史行为挂在 `document.body`:那条路径本来就没有 token 作用域可继承,改用\n * fallback overlay root 只会改变既有测试与消费者的观察面,不换来任何东西。\n */\n private _scrimParent(): HTMLElement {\n return findNearestTheme(this)?.getOverlayRoot() ?? document.body\n }\n\n /**\n * 关掉并摘除 scrim。幂等:关闭分支与 `disconnectedCallback` 都会调。\n *\n * `close()` 触发 UA 的焦点归还,**必须先于面板 `remove()`** —— 理由见 `_closeMenuAfterPresence`。\n */\n private _closeScrim() {\n const scrim = this._scrim\n if (!scrim) return\n this._scrim = undefined\n if (scrim.open) scrim.close()\n scrim.remove()\n }\n\n /**\n * 只让 scrim 出顶层,不摘除元素。面板挂在 scrim 里,摘了就把退场动画一起摘掉。\n *\n * 提前出顶层是为了焦点(理由见 `close()`)。代价是这个尾巴上 scrim 已经在 top layer\n * 之外:下层恢复可命中,且面板的层级退回普通流 —— 在「菜单开在 drawer/dialog 之上」\n * 的场景里,退场动画会被下层的 top layer 盖住。\n *\n * 这条尾巴只有一次退场过渡的时长(默认 160ms),且只发生在菜单项激活时。\n * 摘除仍在 `_closeScrim()`,那时面板已经走完。\n */\n private _releaseScrimModality() {\n const scrim = this._scrim\n if (scrim?.open) scrim.close()\n }\n\n /**\n * 点 scrim = 关菜单(Q12:必关,不给 prop 选项),与 dialog / drawer 的实现同形:\n * 只有落点就是 scrim 本身才算「点外面」,落在面板(scrim 的后代)上不算。\n *\n * 落点判据之所以只能是这两个,是模态给的:菜单打开期间下层收不到命中,事件只可能\n * 落在 scrim 或面板上,两者都由本组件判定。\n *\n * 更早的实现在 document 捕获阶段挂 click 监听,把「点外面」理解成「命中测试落在菜单\n * 面板之外」—— 宿主 light DOM 的每一行都满足这个条件,于是判定与行激活成了同一个\n * click 的两个后果,中间没有任何仲裁,下层目标先被激活。那条监听器连同它的实现已一并\n * 删除:模态化之后它没有任何还能生效的场景,别再挂回来。\n */\n private _onScrimClick = (e: MouseEvent) => {\n if (e.target !== this._scrim) return\n // 长按抬手后浏览器补发的 click 属于那一次触摸意图,不能把刚开的菜单关掉。\n if (this._isLongPressFollowUp()) return\n this._closeFromUser()\n }\n\n /**\n * Escape 的**唯一**关闭路径仍是共享仲裁者(`_overlay.requestClose` → `closeDeepestOrAll`),\n * 它按层深决定是关最深子菜单还是关整张菜单。原生 dialog 自己也监听 Escape 并派发\n * `cancel`,若放任默认行为,UA 会直接关掉 scrim —— 那条路径不经过组件状态机,\n * 既可能漏派 `open-change`,也会把「关最深一层」的语义降级成「整张菜单没了」。\n *\n * 因此这里只吞掉默认行为,不重复关闭。仲裁者那条路径已经会走 `_closeFromUser()`。\n */\n private _onScrimCancel = (e: Event) => {\n e.preventDefault()\n }\n\n private async _closeMenuAfterPresence() {\n const menu = this._menu\n if (menu && !(await hideOverlayPresence(menu.panel))) return\n if (this._isOpen || !this.isConnected || this._menu !== menu) return\n\n /*\n * 归还判定必须在移除面板之前做完:面板一脱离文档,「焦点仍在菜单内」就再也读不到了。\n * 子菜单层此刻仍可能持有焦点,一并计入。\n */\n /*\n * 先关 scrim 再摘面板,顺序不能反。\n *\n * 焦点归还是 `showModal()` / `close()` 的 UA 行为,不再由组件自己实现:UA 记的是\n * `showModal()` 之前的 `document.activeElement`,`close()` 时还回去。反过来做就成了\n * 实测里那条「open 中直接移除 dialog → 焦点丢到 body,不归还」——面板先脱离文档,\n * 焦点链断了,UA 的还原目标也就落空。\n *\n * 菜单项激活那条路径的 scrim 早在 `close()` 里就出了顶层(`_releaseScrimModality`),\n * 这里的 `close()` 对它是幂等空转,摘除照常发生。\n *\n * 「关闭期间焦点被外部接管时不抢回」那道防护不是被删除,是被**移到了更早的位置**:\n * UA 自己没有这个分支(实测:外部接管后 close,焦点必被抢回 opener,`close()` /\n * `remove()` / `open = false` / 摘 `open` / 置 `display:none` / opener 移除或不可聚焦,\n * 十种写法全都照旧夺焦点)。所以只能在焦点**还没被送走**的时候就把顶层让掉 ——\n * 也就是菜单项激活那一刻。点外面 / Escape / 程序式关闭不抢焦点是用户期望的语义,\n * 那三条保留 dialog 默认效果。\n */\n this._closeScrim()\n\n // 登记已在关闭分支撤销(release ⟺ 关闭),这里只做内容归还与 DOM 收尾。\n this._returnItemsToSlot()\n menu?.panel.remove()\n this._menu = undefined\n }\n\n private _hideMenuItems() {\n getMenuChildren(this).forEach(child => child.setAttribute('slot', 'context-menu-hidden'))\n hideNestedMenuChildren(this, 'context-menu-hidden')\n }\n\n private _openSubmenu(item: HTMLElement, isInstant = false) {\n if (!this._isOpen || !item.hasAttribute('submenu') || item.hasAttribute('disabled')) return\n\n const level = this._getItemLevel(item)\n if (level === -1 || this._activeSubmenuItems[level] === item) return\n\n this._closeSubmenusFrom(level)\n const closingSubmenu = this._closingSubmenus.take(item)\n if (closingSubmenu) {\n this._activeSubmenus[level] = closingSubmenu\n this._activeSubmenuItems[level] = item\n /*\n * 取回的面板写回 `_submenuHandles`,让收尾结束时的 `_releaseSubmenu` 释放到正确的句柄。\n * 注:「面板在根句柄子树里」这一条已由上面的 claim 分支保证 —— 该分支会把仍在\n * closing 栈里的面板一并重挂(`_closingSubmenus.closing()`)。实测单独摘掉这一行\n * 全部用例仍绿,所以它维护的是释放账本,不是树的成员关系。\n *\n * **这一行没有任何测试守护**:把 `adopt` 与 `set` 一起摘掉,context-menu 的 96 条\n * 用例仍然全绿(实测 96 passed)。所以下面写的是「当前如此」—— 这一行维护的是\n * 释放账本(收尾结束时 `_releaseSubmenu` 按 panel 取句柄),不是树的成员关系;\n * 成员关系由上面 claim 分支的 `_closingSubmenus.closing()` 循环负责,那一条**是**\n * 有测试的。改动这一行前请自己判断要不要补一条能红的用例,别把这里的沉默当成覆盖。\n */\n if (this._menuHandle) this._submenuHandles.set(closingSubmenu.panel, this._menuHandle.adopt(closingSubmenu.panel))\n item.setAttribute('active', '')\n this._positionSubmenu(item, closingSubmenu)\n showOverlayPresence(closingSubmenu.panel, { isInstant })\n this._hoverBinder.bind()\n return\n }\n if (getMenuChildren(item).length === 0) return\n\n /*\n * 子菜单与根菜单挂进**同一个** scrim。只改根菜单会让两层行为分裂:根层被 scrim\n * 罩住时,子菜单若仍留在 overlay root,它自己就成了 scrim 子树外的元素 —— 模态期间\n * 不可点,且点它内部会被判成「点外面」而关掉整张菜单。\n */\n const submenu = createMenuPortalOverlay('context-submenu', this, this._scrim)\n submenu.panel.dataset.level = String(level)\n submenu.panel.setAttribute('role', 'menu')\n submenu.panel.setAttribute('aria-label', '子菜单')\n submenu.panel.style.visibility = 'hidden'\n submenu.panel.addEventListener('click', this._onMenuClick)\n submenu.panel.addEventListener('click', this._onMenuItemActivation, true)\n moveMenuChildren(item, submenu.content)\n\n this._activeSubmenus[level] = submenu\n this._activeSubmenuItems[level] = item\n /*\n * 子层进根句柄的子树,默认不是独立候选 —— 「它在树里、但不参与这一层裁决」由此\n * 表达。原先按 level===0 选择根面板还是上一层子面板的父级判断随之消失:contains\n * 沿子树递归,拍平与嵌套对任何现有查询结果等价。\n */\n if (this._menuHandle) this._submenuHandles.set(submenu.panel, this._menuHandle.adopt(submenu.panel))\n item.setAttribute('active', '')\n this._positionSubmenu(item, submenu)\n showOverlayPresence(submenu.panel, { isInstant })\n this._hoverBinder.bind()\n }\n\n private _closeSubmenusFrom(level: number, isInstant = false) {\n for (let index = this._activeSubmenus.length - 1; index >= level; index--) {\n const submenu = this._activeSubmenus[index]\n const item = this._activeSubmenuItems[index]\n item?.removeAttribute('active')\n if (!item || isInstant) {\n if (submenu) this._releaseSubmenu(submenu.panel)\n this._restoreSubmenuItems(submenu, item)\n submenu.panel.remove()\n } else {\n this._closingSubmenus.closeAsync(item, submenu)\n }\n }\n this._activeSubmenus.length = level\n this._activeSubmenuItems.length = level\n }\n\n private _restoreSubmenuItems(submenu: MenuPortalOverlay, item?: HTMLElement) {\n if (!item) return\n moveMenuChildren(submenu.content, item)\n // 子菜单打开期间宿主可能重建了嵌套子项,归还时补隐藏,避免可见叠加。\n hideNestedMenuChildren(item, 'context-menu-hidden')\n }\n\n private _getItemLevel(item: HTMLElement): number {\n const menu = this._menu\n if (menu?.panel.contains(item)) return 0\n const parentLevel = this._activeSubmenus.findIndex(submenu => submenu.panel.contains(item))\n return parentLevel === -1 ? -1 : parentLevel + 1\n }\n\n private _positionSubmenu(item: HTMLElement, submenu: MenuPortalOverlay) {\n // 与主菜单同因:panel 进入 open native dialog 后处于 transformed containing\n // block,viewport 坐标的 left/top 会相对 dialog padding box 解析而整体偏移,\n // 需改走 Floating UI 换算为 dialog 相对坐标;开合方向仍按视口坐标度量预判。\n if (submenu.panel.parentElement instanceof HTMLDialogElement && submenu.panel.parentElement.open) {\n const itemRect = item.getBoundingClientRect()\n const padding = 8\n const canOpenRight = itemRect.right + submenu.panel.getBoundingClientRect().width + padding <= window.innerWidth\n // 同帧关闭→重开会复用同一 panel 产生两个 in-flight 定位 promise,完成序不保证\n // 后者胜出;按 panel 键控的代数 token 只允许最新一次调用写入,迟到旧 promise 丢弃。\n const epoch = (this._submenuPositionEpochs.get(submenu.panel) ?? 0) + 1\n this._submenuPositionEpochs.set(submenu.panel, epoch)\n void computePosition(item, submenu.panel, {\n strategy: 'fixed',\n placement: canOpenRight ? 'right-start' : 'left-start',\n middleware: [shift({ padding, crossAxis: true })]\n }).then(({ x, y, middlewareData }) => {\n if (!this._activeSubmenus.includes(submenu) || epoch !== this._submenuPositionEpochs.get(submenu.panel)) return\n // 与主菜单 dialog 路径一致:origin 由 shift 增量推导,视口预判只决定 placement。\n const shiftX = middlewareData.shift?.x ?? 0\n const shiftY = middlewareData.shift?.y ?? 0\n const horizontalOrigin = shiftX < 0 ? 'right' : 'left'\n const verticalOrigin = shiftY < 0 ? 'bottom' : 'top'\n submenu.panel.style.left = `${x}px`\n submenu.panel.style.top = `${y}px`\n submenu.panel.style.setProperty(\n '--wui-internal-overlay-transform-origin',\n `${verticalOrigin} ${horizontalOrigin}`\n )\n submenu.panel.style.visibility = ''\n })\n return\n }\n\n const itemRect = item.getBoundingClientRect()\n const submenuRect = submenu.panel.getBoundingClientRect()\n const padding = 8\n const canOpenRight = itemRect.right + submenuRect.width + padding <= window.innerWidth\n const left = canOpenRight ? itemRect.right : Math.max(padding, itemRect.left - submenuRect.width)\n const top = Math.min(Math.max(padding, itemRect.top), window.innerHeight - submenuRect.height - padding)\n\n submenu.panel.style.left = `${left}px`\n submenu.panel.style.top = `${top}px`\n submenu.panel.style.setProperty('--wui-internal-overlay-transform-origin', canOpenRight ? 'top left' : 'top right')\n submenu.panel.style.visibility = ''\n }\n\n private _dispatchChange(open: boolean) {\n dispatchOpenChangeEvent(this, open)\n }\n\n private _onPointerDown = (e: PointerEvent) => {\n // 任何一次新的按下都结束「上一次长按的补发事件」窗口:抬手后浏览器合成的\n // click 一定早于下一次 pointerdown,所以这个边界既能吸收补发,又不会误吞\n // 用户之后真正的点击。\n this._clearLongPressFollowUp()\n // 鼠标走原生 contextmenu,无需长按;只认触屏指针。\n if (!this.longPress || this.disabled || e.pointerType !== 'touch') return\n this._cancelLongPress()\n this._longPressOrigin = { x: e.clientX, y: e.clientY }\n this._longPressTimer = setTimeout(\n () => {\n this._longPressTimer = undefined\n const origin = this._longPressOrigin\n if (!origin) return\n this._longPressOrigin = undefined\n if (this._isOpen) return\n this._longPressOpenedAt = performance.now()\n // 触屏长按:面板上缘对齐按点、向下展开,水平居中于按点(判据见 `_centerOnPressPoint`)。\n if (this._openAt(origin.x, origin.y, false, true, true)) this._userOpenChange.mark()\n },\n // 属性已被 normalizeNumber 钳到 [0, 5000],这里直接用;不再二次 Math.max。\n this.longPressDelay\n )\n }\n\n private _onPointerMove = (e: PointerEvent) => {\n if (e.pointerType !== 'touch') return\n const origin = this._longPressOrigin\n if (!origin) return\n // 触屏有隐式指针捕获,pointermove 会持续派发到 pointerdown 的落点,\n // 因此位移超阈值即判定为滚动意图,取消长按。\n const distance = Math.hypot(e.clientX - origin.x, e.clientY - origin.y)\n if (distance > LONG_PRESS_MOVE_TOLERANCE) this._cancelLongPress()\n }\n\n private _onPointerUp = (e: PointerEvent) => {\n if (e.pointerType !== 'touch') return\n // 补发事件窗口改以手势结束为起点:引擎的补发 contextmenu / click 与这次 pointerup\n // 同一个输入批处理生成(实测 click 的 timeStamp 与 pointerup 处理时刻相差 ≤3ms),\n // 所以相对它窗口覆盖得到;相对「菜单打开时刻」则覆盖不到(负载下能差出 1300ms+)。\n // 只认**开菜单那一次**手势的抬手:之后用户又按了一次时,它的 click 属于新手势,\n // 把窗口重新锚过去会让本该 dismiss 的点击被吞(「窗口过期后点击照常 dismiss」\n // 钉的正是这条)。所以只在还没锚过时记一次。\n if (this._longPressOpenedAt !== null && this._longPressGestureEndedAt === null) {\n this._longPressGestureEndedAt = performance.now()\n }\n this._cancelLongPress()\n }\n\n private _onPointerCancel = () => {\n this._cancelLongPress()\n }\n\n private _cancelLongPress() {\n if (this._longPressTimer !== undefined) {\n clearTimeout(this._longPressTimer)\n this._longPressTimer = undefined\n }\n this._longPressOrigin = undefined\n }\n\n /** 该事件是否是长按开菜单之后、浏览器为同一次触摸补发的事件。 */\n private _isLongPressFollowUp(): boolean {\n const openedAt = this._longPressOpenedAt\n if (openedAt === null) return false\n // 起点取手势结束(补发事件与它同批生成),拿不到时才退回打开时刻。\n const anchor = this._longPressGestureEndedAt ?? openedAt\n // 超期只说明「这次长按的补发没来」,不代表窗口承担时序保证;见常量注释。\n if (performance.now() - anchor > LONG_PRESS_FOLLOW_UP_WINDOW_MS) {\n this._clearLongPressFollowUp()\n return false\n }\n return true\n }\n\n /** 结束「同一次手势的补发事件」窗口。与 _longPressOpenedAt 的清零同源,两处都要清。 */\n private _clearLongPressFollowUp() {\n this._longPressOpenedAt = null\n this._longPressGestureEndedAt = null\n }\n\n private _onContextMenu = (e: MouseEvent) => {\n if (this.disabled) return\n if (this._isLongPressFollowUp()) {\n e.preventDefault()\n return\n }\n e.preventDefault()\n if (this._openAt(e.clientX, e.clientY, false, true)) this._userOpenChange.mark()\n }\n\n private _onContextMenuOutside = (e: MouseEvent) => {\n if (this._isOpen && !this._outsideClickGuard.isInside(e)) {\n this._closeFromUser()\n }\n }\n\n private _onKeydown = (e: KeyboardEvent) => {\n if (this.disabled) return\n\n if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {\n e.preventDefault()\n const focused = document.activeElement\n if (focused && focused !== document.body) {\n const rect = focused.getBoundingClientRect()\n if (this._openAt(rect.left, rect.bottom, true, false)) this._userOpenChange.mark()\n } else {\n if (this._openAt(window.innerWidth / 2, window.innerHeight / 2, true, false)) this._userOpenChange.mark()\n }\n return\n }\n }\n\n /*\n * 捕获阶段记「可激活的菜单项被点了」,早于条目自己的监听器。\n *\n * 判据与 `_onMenuClick` 同源(同一个 `getMenuItemFromEvent` + 同样的 disabled /\n * submenu 排除),但只置标志、不做关闭。子菜单父项与禁用项在这一层就被排除,\n * 它们不触发关闭,不该让后续关闭提前出顶层。\n *\n * 挂在面板上而不是条目上:条目在开菜单期间被搬进面板,面板是它们此刻共同的祖先。\n *\n * **作用域是「这一次事件派发」,不是整个会话。** 菜单项被点并不必然导致关闭:消费方在\n * 自己的条目回调里 `stopPropagation()` 是常见写法(勾选型条目要让菜单留着),此时面板上\n * 的冒泡监听收不到,菜单不关。若标志一直留到会话结束,那一次点击之后的**每一次**关闭——\n * Escape、点外面、程序式 `close()`——都会被误判成菜单项激活而提前出顶层,把 README 承诺的\n * 「点外面 / Escape / 程序式关闭不抢焦点」整个破坏掉。\n *\n * 划界不靠定时器,靠事件自己:`close()` 判的是 `eventPhase !== 0`(见 `_isDispatchingMenuItemActivation`)。\n * **不能用微任务复位**——真实输入下事件由浏览器任务派发,每个监听器返回后都做一次微任务检查点,\n * 于是捕获阶段排的微任务会在条目自己的监听器(消费方同步调 `close()` 的地方)**之前**跑掉,\n * 标志到 `close()` 时已是 false,scrim 留在 top layer,消费方送进编辑器的焦点被 inert 文档丢弃。\n * 从脚本里合成 `dispatchEvent` 时调用栈非空、检查点被推迟,所以那种驱动方式会给这条缺陷\n * 盖上假绿——一条能红的回归用例必须走 `userEvent`(真实输入)而不能用 `element.click()`。\n */\n private _onMenuItemActivation = (e: Event) => {\n const item = getMenuItemFromEvent(e)\n if (!item || item.hasAttribute('disabled') || item.hasAttribute('submenu')) return\n this._menuItemActivationEvent = e\n }\n\n private _onMenuClick = (e: MouseEvent) => {\n const item = getMenuItemFromEvent(e)\n if (!item || item.hasAttribute('disabled')) return\n if (item.hasAttribute('submenu')) {\n this._openSubmenu(item)\n return\n }\n this._closeFromUser('menu-item')\n }\n\n private _getLevelContainer(level: number) {\n return level === 0 ? this._menu : this._activeSubmenus[level - 1]\n }\n\n private _getLevelItems(level: number): HTMLElement[] {\n const container = this._getLevelContainer(level)\n if (!container) return []\n return getMenuChildren(container.content)\n }\n\n private _onWheel = (e: WheelEvent) => {\n this._preventBackgroundScroll(e)\n }\n\n private _onTouchMove = (e: TouchEvent) => {\n this._preventBackgroundScroll(e)\n }\n\n private _onDocumentKeydown = (e: KeyboardEvent) => {\n if (!this._isOpen) return\n // 鼠标右键打开的菜单焦点不在菜单内,e.target !== this 时也必须能 Escape 关闭;\n // 菜单为模态浮层,按 Escape 即关闭,无需限定焦点位置\n handleMenuKeyboard(this._keyboardDelegate, e)\n }\n\n private _closeLastSubmenuOrMenu() {\n if (this._activeSubmenus.length > 0) {\n const level = this._activeSubmenus.length - 1\n const parent = this._activeSubmenuItems[level]\n this._closeSubmenusFrom(level)\n focusMenuItem(parent)\n this._hoverBinder.bind()\n } else {\n this._closeFromUser()\n }\n }\n\n private _preventBackgroundScroll(e: Event) {\n if (!this._isOpen || this.noScrollLock || this._isMenuPanelEvent(e)) return\n e.preventDefault()\n }\n\n private _syncScrollLock(isOpen = this._isOpen) {\n this._scrollLock.sync(isOpen && !this.noScrollLock)\n }\n\n /** 按 panel 撤销一层子菜单的登记;句柄已随根层递归撤销时幂等无操作。 */\n private _releaseSubmenu(panel: HTMLElement) {\n this._submenuHandles.get(panel)?.release()\n this._submenuHandles.delete(panel)\n }\n\n private _isMenuPanelEvent(e: Event): boolean {\n // 在监听器内部判定:composedPath() 在派发结束后会被清空。\n return this._menuHandle?.containsEvent(e) ?? false\n }\n\n override render() {\n return html`\n <div class=\"context-menu-anchor\">\n <slot @slotchange=${this._onSlotChange}></slot>\n </div>\n `\n }\n\n private _onSlotChange() {\n this._hideMenuItems()\n // 打开期间宿主重建顶层项时,新成员要移入 portal 才对用户可见;\n // 关闭状态下无需移动,等下次打开时由 _setupMenuItems 统一处理。\n if (this._isOpen) this._scheduleRefresh()\n }\n\n declare readonly $events: {\n 'open-change': CustomEvent<{ open: boolean }>\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'web-ui-context-menu': WebUiContextMenu\n }\n}\n"],"mappings":"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"}
|