@fuzdev/fuz_ui 0.201.0 → 0.203.0
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.
- package/dist/ApiIndex.svelte +6 -3
- package/dist/ApiIndex.svelte.d.ts +1 -1
- package/dist/ApiIndex.svelte.d.ts.map +1 -1
- package/dist/ApiModule.svelte +72 -21
- package/dist/ApiModule.svelte.d.ts +1 -1
- package/dist/ApiModule.svelte.d.ts.map +1 -1
- package/dist/ContextmenuEntry.svelte +11 -10
- package/dist/ContextmenuEntry.svelte.d.ts +1 -1
- package/dist/ContextmenuEntry.svelte.d.ts.map +1 -1
- package/dist/ContextmenuLinkEntry.svelte +30 -12
- package/dist/ContextmenuLinkEntry.svelte.d.ts +2 -1
- package/dist/ContextmenuLinkEntry.svelte.d.ts.map +1 -1
- package/dist/ContextmenuMenu.svelte +207 -0
- package/dist/ContextmenuMenu.svelte.d.ts +41 -0
- package/dist/ContextmenuMenu.svelte.d.ts.map +1 -0
- package/dist/ContextmenuRoot.svelte +58 -260
- package/dist/ContextmenuRoot.svelte.d.ts +2 -60
- package/dist/ContextmenuRoot.svelte.d.ts.map +1 -1
- package/dist/ContextmenuRootForSafariCompatibility.svelte +72 -283
- package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts +2 -58
- package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts.map +1 -1
- package/dist/ContextmenuSeparator.svelte +2 -1
- package/dist/ContextmenuSeparator.svelte.d.ts.map +1 -1
- package/dist/ContextmenuSubmenu.svelte +27 -23
- package/dist/ContextmenuSubmenu.svelte.d.ts +1 -1
- package/dist/ContextmenuSubmenu.svelte.d.ts.map +1 -1
- package/dist/ContextmenuTextEntry.svelte +7 -3
- package/dist/ContextmenuTextEntry.svelte.d.ts +2 -1
- package/dist/ContextmenuTextEntry.svelte.d.ts.map +1 -1
- package/dist/CopyToClipboard.svelte +5 -2
- package/dist/CopyToClipboard.svelte.d.ts.map +1 -1
- package/dist/DeclarationLink.svelte +15 -2
- package/dist/DeclarationLink.svelte.d.ts +7 -0
- package/dist/DeclarationLink.svelte.d.ts.map +1 -1
- package/dist/DialogContent.svelte +3 -1
- package/dist/DialogContent.svelte.d.ts.map +1 -1
- package/dist/Docs.svelte +11 -8
- package/dist/Docs.svelte.d.ts.map +1 -1
- package/dist/DocsLink.svelte +3 -1
- package/dist/DocsLink.svelte.d.ts.map +1 -1
- package/dist/DocsSearch.svelte +4 -1
- package/dist/DocsSearch.svelte.d.ts.map +1 -1
- package/dist/DocsTertiaryNav.svelte +2 -1
- package/dist/DocsTertiaryNav.svelte.d.ts.map +1 -1
- package/dist/LibraryDetail.svelte +9 -3
- package/dist/LibraryDetail.svelte.d.ts +1 -1
- package/dist/LibraryDetail.svelte.d.ts.map +1 -1
- package/dist/ModuleLink.svelte +2 -1
- package/dist/ModuleLink.svelte.d.ts.map +1 -1
- package/dist/Svg.svelte +46 -9
- package/dist/Svg.svelte.d.ts.map +1 -1
- package/dist/TypeLink.svelte +2 -1
- package/dist/TypeLink.svelte.d.ts.map +1 -1
- package/dist/contextmenu_helpers.d.ts +324 -1
- package/dist/contextmenu_helpers.d.ts.map +1 -1
- package/dist/contextmenu_helpers.js +403 -2
- package/dist/contextmenu_state.svelte.d.ts +61 -15
- package/dist/contextmenu_state.svelte.d.ts.map +1 -1
- package/dist/contextmenu_state.svelte.js +206 -119
- package/dist/declaration.svelte.d.ts +1 -0
- package/dist/declaration.svelte.d.ts.map +1 -1
- package/dist/declaration.svelte.js +2 -1
- package/dist/icons.d.ts +469 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +578 -0
- package/dist/library.svelte.d.ts +30 -3
- package/dist/library.svelte.d.ts.map +1 -1
- package/dist/library.svelte.js +38 -0
- package/dist/logos.d.ts +1 -3
- package/dist/logos.d.ts.map +1 -1
- package/dist/logos.js +1 -1
- package/dist/module.svelte.d.ts +62 -1
- package/dist/module.svelte.d.ts.map +1 -1
- package/dist/module.svelte.js +71 -1
- package/dist/svg.d.ts +69 -10
- package/dist/svg.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/lib/contextmenu_helpers.ts +546 -3
- package/src/lib/contextmenu_state.svelte.ts +219 -119
- package/src/lib/declaration.svelte.ts +2 -1
- package/src/lib/icons.ts +671 -0
- package/src/lib/library.svelte.ts +43 -1
- package/src/lib/logos.ts +1 -1
- package/src/lib/module.svelte.ts +97 -2
- package/src/lib/svg.ts +70 -9
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import {is_editable, swallow, inside_editable} from '@fuzdev/fuz_util/dom.js';
|
|
2
|
+
import {EMPTY_OBJECT} from '@fuzdev/fuz_util/object.js';
|
|
3
|
+
import type {Attachment} from 'svelte/attachments';
|
|
4
|
+
import type {ComponentProps, Snippet} from 'svelte';
|
|
2
5
|
|
|
3
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
contextmenu_open,
|
|
8
|
+
type ContextmenuOpenOptions,
|
|
9
|
+
type ContextmenuState,
|
|
10
|
+
} from './contextmenu_state.svelte.js';
|
|
11
|
+
import type ContextmenuLinkEntry from './ContextmenuLinkEntry.svelte';
|
|
12
|
+
import type ContextmenuTextEntry from './ContextmenuTextEntry.svelte';
|
|
13
|
+
import type ContextmenuSeparator from './ContextmenuSeparator.svelte';
|
|
4
14
|
|
|
5
15
|
// Constants for default prop values
|
|
6
16
|
export const CONTEXTMENU_DEFAULT_OPEN_OFFSET_X = -2;
|
|
@@ -10,6 +20,72 @@ export const CONTEXTMENU_DEFAULT_BYPASS_MOVE_TOLERANCE = 11;
|
|
|
10
20
|
export const CONTEXTMENU_DEFAULT_LONGPRESS_DURATION = 633;
|
|
11
21
|
export const CONTEXTMENU_DEFAULT_LONGPRESS_MOVE_TOLERANCE = 21;
|
|
12
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Props shared by `ContextmenuRoot.svelte` and `ContextmenuRootForSafariCompatibility.svelte`.
|
|
25
|
+
* Defaults are applied identically by both roots.
|
|
26
|
+
*/
|
|
27
|
+
export interface ContextmenuRootBaseProps {
|
|
28
|
+
/**
|
|
29
|
+
* The contextmenu state. Each root defaults to its own new instance -
|
|
30
|
+
* pass one to control or observe the menu externally.
|
|
31
|
+
*/
|
|
32
|
+
contextmenu?: ContextmenuState;
|
|
33
|
+
/**
|
|
34
|
+
* The number of pixels to offset from the pointer X position when opened.
|
|
35
|
+
* Useful to ensure the first menu item is immediately under the pointer.
|
|
36
|
+
*/
|
|
37
|
+
open_offset_x?: number;
|
|
38
|
+
/**
|
|
39
|
+
* The number of pixels to offset from the pointer Y position when opened.
|
|
40
|
+
* Useful to ensure the first menu item is immediately under the pointer.
|
|
41
|
+
*/
|
|
42
|
+
open_offset_y?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Whether to detect tap-then-longpress to bypass the Fuz contextmenu.
|
|
45
|
+
* This allows access to the system contextmenu by tapping once then rightclicking/long-pressing.
|
|
46
|
+
* Setting to `false` disables the gesture.
|
|
47
|
+
*/
|
|
48
|
+
bypass_with_tap_then_longpress?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The number of milliseconds between taps to detect a gesture that bypasses the Fuz contextmenu.
|
|
51
|
+
* Used only when `bypass_with_tap_then_longpress` is true.
|
|
52
|
+
* If the duration is too long, it'll detect more false positives and interrupt normal usage,
|
|
53
|
+
* but too short and some people will have difficulty performing the gesture.
|
|
54
|
+
*/
|
|
55
|
+
bypass_window?: number;
|
|
56
|
+
/**
|
|
57
|
+
* The number of pixels the pointer can be moved between taps to detect a tap-then-longpress.
|
|
58
|
+
* Used only when `bypass_with_tap_then_longpress` is true.
|
|
59
|
+
*/
|
|
60
|
+
bypass_move_tolerance?: number;
|
|
61
|
+
/**
|
|
62
|
+
* If `true`, wraps `children` with a div and listens to events on it instead of the window.
|
|
63
|
+
*/
|
|
64
|
+
scoped?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Snippet for rendering link entries.
|
|
67
|
+
* Set to `null` to disable automatic link detection.
|
|
68
|
+
* Defaults to `link_entry_default` which renders `ContextmenuLinkEntry`.
|
|
69
|
+
*/
|
|
70
|
+
link_entry?: Snippet<[ComponentProps<typeof ContextmenuLinkEntry>]> | null;
|
|
71
|
+
/**
|
|
72
|
+
* Snippet for rendering copy text entries.
|
|
73
|
+
* Set to `null` to disable automatic copy text detection.
|
|
74
|
+
* Defaults to `text_entry_default` which renders `ContextmenuTextEntry`.
|
|
75
|
+
*/
|
|
76
|
+
text_entry?: Snippet<[ComponentProps<typeof ContextmenuTextEntry>]> | null;
|
|
77
|
+
/**
|
|
78
|
+
* Snippet for rendering separator entries.
|
|
79
|
+
* Set to `null` to disable automatic separator rendering.
|
|
80
|
+
* Defaults to `separator_entry_default` which renders `ContextmenuSeparator`.
|
|
81
|
+
*/
|
|
82
|
+
separator_entry?: Snippet<[ComponentProps<typeof ContextmenuSeparator>]> | null;
|
|
83
|
+
/**
|
|
84
|
+
* The content the root listens over for contextmenu gestures.
|
|
85
|
+
*/
|
|
86
|
+
children: Snippet;
|
|
87
|
+
}
|
|
88
|
+
|
|
13
89
|
/**
|
|
14
90
|
* Returns true if valid and narrows the type to HTMLElement | SVGElement.
|
|
15
91
|
*/
|
|
@@ -48,14 +124,481 @@ export const contextmenu_create_keydown_handler =
|
|
|
48
124
|
handler();
|
|
49
125
|
};
|
|
50
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Constrains the menu's x coordinate to the layout, shifting left to fit the right edge.
|
|
129
|
+
* Clamps at `0` so a menu wider than the layout pins its left edge - the start of each
|
|
130
|
+
* item stays visible.
|
|
131
|
+
*/
|
|
51
132
|
export const contextmenu_calculate_constrained_x = (
|
|
52
133
|
menu_x: number,
|
|
53
134
|
menu_width: number,
|
|
54
135
|
layout_width: number,
|
|
55
|
-
): number => menu_x + Math.min(0, layout_width - (menu_x + menu_width));
|
|
136
|
+
): number => Math.max(0, menu_x + Math.min(0, layout_width - (menu_x + menu_width)));
|
|
56
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Constrains the menu's y coordinate to the layout, shifting up to fit the bottom edge.
|
|
140
|
+
* Clamps at `0` so a menu taller than the layout pins its top edge - the first
|
|
141
|
+
* items stay visible.
|
|
142
|
+
*/
|
|
57
143
|
export const contextmenu_calculate_constrained_y = (
|
|
58
144
|
menu_y: number,
|
|
59
145
|
menu_height: number,
|
|
60
146
|
layout_height: number,
|
|
61
|
-
): number => menu_y + Math.min(0, layout_height - (menu_y + menu_height));
|
|
147
|
+
): number => Math.max(0, menu_y + Math.min(0, layout_height - (menu_y + menu_height)));
|
|
148
|
+
|
|
149
|
+
export interface ContextmenuSubmenuTranslateOptions {
|
|
150
|
+
/** The submenu's untranslated viewport x - the parent menu's left edge. */
|
|
151
|
+
base_x: number;
|
|
152
|
+
/** The submenu's untranslated viewport y. */
|
|
153
|
+
base_y: number;
|
|
154
|
+
/** The submenu's width. */
|
|
155
|
+
width: number;
|
|
156
|
+
/** The submenu's height. */
|
|
157
|
+
height: number;
|
|
158
|
+
/** The parent menu's width. */
|
|
159
|
+
parent_width: number;
|
|
160
|
+
/** The layout's width. */
|
|
161
|
+
layout_width: number;
|
|
162
|
+
/** The layout's height. */
|
|
163
|
+
layout_height: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Calculates a submenu flyout's translation so it fits the layout.
|
|
168
|
+
*
|
|
169
|
+
* Preference order on the x axis: fly out to the right of the parent menu,
|
|
170
|
+
* else flip fully to the left of it, else shift to pin whichever side
|
|
171
|
+
* overflows less. The y axis shifts up only as far as needed to fit the
|
|
172
|
+
* bottom edge.
|
|
173
|
+
*
|
|
174
|
+
* Used by `ContextmenuSubmenu.svelte`.
|
|
175
|
+
*/
|
|
176
|
+
export const contextmenu_calculate_submenu_translate = (
|
|
177
|
+
options: ContextmenuSubmenuTranslateOptions,
|
|
178
|
+
): {x: number; y: number} => {
|
|
179
|
+
const {base_x, base_y, width, height, parent_width, layout_width, layout_height} = options;
|
|
180
|
+
let x: number;
|
|
181
|
+
const overflow_right = base_x + width + parent_width - layout_width;
|
|
182
|
+
if (overflow_right <= 0) {
|
|
183
|
+
x = parent_width;
|
|
184
|
+
} else {
|
|
185
|
+
const overflow_left = width - base_x;
|
|
186
|
+
if (overflow_left <= 0) {
|
|
187
|
+
x = -width;
|
|
188
|
+
} else if (overflow_left > overflow_right) {
|
|
189
|
+
x = parent_width - overflow_right;
|
|
190
|
+
} else {
|
|
191
|
+
x = overflow_left - width;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const y = Math.min(0, layout_height - (base_y + height));
|
|
195
|
+
return {x, y};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export interface ContextmenuOpenFromEventOptions extends ContextmenuOpenOptions {
|
|
199
|
+
/**
|
|
200
|
+
* The number of pixels to offset from the pointer X position when opened.
|
|
201
|
+
*/
|
|
202
|
+
open_offset_x?: number;
|
|
203
|
+
/**
|
|
204
|
+
* The number of pixels to offset from the pointer Y position when opened.
|
|
205
|
+
*/
|
|
206
|
+
open_offset_y?: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Handles a `contextmenu` event by opening the contextmenu from the event target,
|
|
211
|
+
* unless the target is invalid or inside the open menu element.
|
|
212
|
+
* Swallows the event when the menu opens.
|
|
213
|
+
*
|
|
214
|
+
* @param e - the `contextmenu` event
|
|
215
|
+
* @param contextmenu - the contextmenu state
|
|
216
|
+
* @param menu_el - the open menu element, if any, so events inside it are ignored
|
|
217
|
+
* @param options - offsets and entry filtering forwarded to `contextmenu_open`
|
|
218
|
+
* @returns whether the contextmenu was opened
|
|
219
|
+
*/
|
|
220
|
+
export const contextmenu_open_from_event = (
|
|
221
|
+
e: MouseEvent,
|
|
222
|
+
contextmenu: ContextmenuState,
|
|
223
|
+
menu_el: HTMLElement | undefined,
|
|
224
|
+
options?: ContextmenuOpenFromEventOptions,
|
|
225
|
+
): boolean => {
|
|
226
|
+
const {
|
|
227
|
+
open_offset_x = CONTEXTMENU_DEFAULT_OPEN_OFFSET_X,
|
|
228
|
+
open_offset_y = CONTEXTMENU_DEFAULT_OPEN_OFFSET_Y,
|
|
229
|
+
} = options ?? EMPTY_OBJECT;
|
|
230
|
+
const {target} = e;
|
|
231
|
+
if (!contextmenu_is_valid_target(target, e.shiftKey)) return false;
|
|
232
|
+
// don't open the contextmenu when clicking on the menu itself
|
|
233
|
+
if (menu_el?.contains(target)) return false;
|
|
234
|
+
if (
|
|
235
|
+
!contextmenu_open(
|
|
236
|
+
target,
|
|
237
|
+
e.clientX + open_offset_x,
|
|
238
|
+
e.clientY + open_offset_y,
|
|
239
|
+
contextmenu,
|
|
240
|
+
options,
|
|
241
|
+
)
|
|
242
|
+
) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
swallow(e);
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolves a `contextmenu` event: opens the menu from the event target when possible
|
|
251
|
+
* (arming `open_guard` against the gesture's residual events), otherwise lets the
|
|
252
|
+
* native contextmenu show - closing ours unless the press was on the menu itself.
|
|
253
|
+
*
|
|
254
|
+
* Shared by `ContextmenuRoot.svelte` and `ContextmenuRootForSafariCompatibility.svelte`,
|
|
255
|
+
* after their root-specific bypass and longpress handling.
|
|
256
|
+
*
|
|
257
|
+
* @param e - the `contextmenu` event
|
|
258
|
+
* @param contextmenu - the contextmenu state
|
|
259
|
+
* @param menu_el - the open menu element, if any
|
|
260
|
+
* @param open_guard - guard that identifies presses belonging to the gesture that opened the menu
|
|
261
|
+
* @param options - offsets and entry filtering forwarded to `contextmenu_open`
|
|
262
|
+
* @returns whether the contextmenu was opened
|
|
263
|
+
*/
|
|
264
|
+
export const contextmenu_resolve_contextmenu_event = (
|
|
265
|
+
e: MouseEvent,
|
|
266
|
+
contextmenu: ContextmenuState,
|
|
267
|
+
menu_el: HTMLElement | undefined,
|
|
268
|
+
open_guard: ContextmenuOpenGuard,
|
|
269
|
+
options?: ContextmenuOpenFromEventOptions,
|
|
270
|
+
): boolean => {
|
|
271
|
+
if (contextmenu_open_from_event(e, contextmenu, menu_el, options)) {
|
|
272
|
+
// guard the menu from the residual events of the gesture that opened it
|
|
273
|
+
open_guard.opened();
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
// The right-click didn't (re)open the menu - a press on the menu itself, an invalid
|
|
277
|
+
// target, shift bypass, or no entries - so the native contextmenu shows.
|
|
278
|
+
if (contextmenu.opened && menu_el && !menu_el.contains(e.target as Node | null)) {
|
|
279
|
+
// Close ours and let the native contextmenu show. Unmodified secondary-button
|
|
280
|
+
// presses never close via the mousedown handler; this is where their gesture
|
|
281
|
+
// resolves (shift+rightclick closes on the press - Firefox suppresses its
|
|
282
|
+
// `contextmenu` event entirely).
|
|
283
|
+
contextmenu.close();
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Creates a `mousedown` handler that closes the contextmenu when pressing outside of
|
|
290
|
+
* the menu element, deferring to `open_guard` for presses that belong to the gesture
|
|
291
|
+
* that opened the menu: gesture presses outside don't close, and gesture presses on the
|
|
292
|
+
* menu arm the click blocker instead of activating the entry under the pointer.
|
|
293
|
+
*
|
|
294
|
+
* Secondary-button presses outside the menu never close here - their own `contextmenu`
|
|
295
|
+
* event resolves the menu in the roots' handlers (reopening it elsewhere, or closing it
|
|
296
|
+
* when there's nothing to open) - with one exception: shift+rightclick, an explicit
|
|
297
|
+
* native-menu request whose `contextmenu` event Firefox suppresses entirely, closes on
|
|
298
|
+
* the press itself.
|
|
299
|
+
*
|
|
300
|
+
* Registered on the window during the bubble phase deliberately -
|
|
301
|
+
* consumers keep the menu open through a press by swallowing the event
|
|
302
|
+
* (e.g. menu controller buttons that use `onmousedowncapture` + `swallow`).
|
|
303
|
+
*
|
|
304
|
+
* @param contextmenu - the contextmenu state
|
|
305
|
+
* @param get_menu_el - getter for the open menu element, if any
|
|
306
|
+
* @param open_guard - guard that identifies presses belonging to the gesture that opened the menu
|
|
307
|
+
*/
|
|
308
|
+
// TODO consider `popover="auto"` instead of `"manual"` (see `contextmenu_popover_attachment`) -
|
|
309
|
+
// its light dismiss would replace this handler and fix Escape ordering over dialogs
|
|
310
|
+
// via close watchers, but it would break consumers that swallow `mousedown` to keep
|
|
311
|
+
// the menu open through presses on their own controls
|
|
312
|
+
export const contextmenu_create_mousedown_handler =
|
|
313
|
+
(
|
|
314
|
+
contextmenu: ContextmenuState,
|
|
315
|
+
get_menu_el: () => HTMLElement | undefined,
|
|
316
|
+
open_guard?: ContextmenuOpenGuard,
|
|
317
|
+
) =>
|
|
318
|
+
(e: MouseEvent): void => {
|
|
319
|
+
const menu_el = get_menu_el();
|
|
320
|
+
if (!menu_el) return;
|
|
321
|
+
if (menu_el.contains(e.target as Node | null)) {
|
|
322
|
+
// a press on the menu belonging to the opening gesture must not click-activate
|
|
323
|
+
open_guard?.mousedown_on_menu(e);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// A shift+rightclick is an explicit native-menu request that our handlers may
|
|
327
|
+
// never see resolve - Firefox suppresses its `contextmenu` event entirely - so
|
|
328
|
+
// close on the press itself, matching the unsuppressed outcome in other browsers.
|
|
329
|
+
if (e.button === 2 && e.shiftKey) {
|
|
330
|
+
contextmenu.close();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Presses belonging to the opening gesture must not close the menu. This includes
|
|
334
|
+
// all secondary-button presses - their own `contextmenu` event resolves the menu
|
|
335
|
+
// (reopening it elsewhere, or closing it when there's nothing to open).
|
|
336
|
+
if (open_guard?.press_belongs_to_open_gesture(e)) return;
|
|
337
|
+
contextmenu.close();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolves the element that must host the contextmenu popover for it to stay
|
|
342
|
+
* interactive: the modal `<dialog>` containing `target`, if any.
|
|
343
|
+
*
|
|
344
|
+
* A modal dialog makes every element outside its subtree inert, and the top layer
|
|
345
|
+
* fixes painting order, not inertness - so a popover shown from outside the dialog
|
|
346
|
+
* paints above it but receives no pointer or focus events (hit-testing skips inert
|
|
347
|
+
* elements). Only the topmost modal dialog's subtree is interactive, so the dialog
|
|
348
|
+
* containing the menu's open target is by construction the one the menu must join.
|
|
349
|
+
*
|
|
350
|
+
* @param target - the element the menu was opened from
|
|
351
|
+
* @returns the modal dialog to reparent the menu into, or `null` when the menu
|
|
352
|
+
* doesn't need a host
|
|
353
|
+
*/
|
|
354
|
+
export const contextmenu_resolve_popover_host = (
|
|
355
|
+
target: HTMLElement | SVGElement | undefined,
|
|
356
|
+
): HTMLDialogElement | null =>
|
|
357
|
+
(target?.closest('dialog:modal') as HTMLDialogElement | null) ?? null;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Creates an attachment that shows the contextmenu element as a manual popover,
|
|
361
|
+
* promoting it into the top layer so it paints above modal `<dialog>` elements
|
|
362
|
+
* (e.g. `Dialog.svelte`) - top-layer elements paint in insertion order.
|
|
363
|
+
*
|
|
364
|
+
* Painting is only half of it: a modal dialog also makes everything outside its
|
|
365
|
+
* subtree inert. When the menu opens from inside a modal dialog, the menu element
|
|
366
|
+
* is reparented into that dialog (see `contextmenu_resolve_popover_host`) so it
|
|
367
|
+
* escapes the inert-ness and stays interactive - top-layer positioning is relative
|
|
368
|
+
* to the viewport regardless of ancestors, so the menu's fixed coordinates are
|
|
369
|
+
* unaffected. The host dialog's `close` also closes the menu, since the menu's DOM
|
|
370
|
+
* node vanishes with the dialog.
|
|
371
|
+
*
|
|
372
|
+
* No-ops where the Popover API is unavailable (older browsers, jsdom),
|
|
373
|
+
* falling back to `--contextmenu_z_index` stacking - the menu then renders
|
|
374
|
+
* beneath and inert to any open modal dialog.
|
|
375
|
+
*
|
|
376
|
+
* Used with the `popover="manual"` attribute - `"manual"` rather than `"auto"`
|
|
377
|
+
* so opening, closing, and keyboard handling stay fully owned by `ContextmenuState`.
|
|
378
|
+
*/
|
|
379
|
+
export const contextmenu_popover_attachment =
|
|
380
|
+
(contextmenu: ContextmenuState): Attachment<HTMLElement> =>
|
|
381
|
+
(el) => {
|
|
382
|
+
if (!el.showPopover) return;
|
|
383
|
+
const host = contextmenu_resolve_popover_host(contextmenu.target);
|
|
384
|
+
// reparent before showing - moving a shown popover would hide it
|
|
385
|
+
if (host) host.appendChild(el);
|
|
386
|
+
// guard the reactive re-run when the `contextmenu` prop changes identity -
|
|
387
|
+
// `showPopover()` throws on an already-shown popover
|
|
388
|
+
if (!el.matches(':popover-open')) el.showPopover();
|
|
389
|
+
if (!host) return;
|
|
390
|
+
const onclose = () => {
|
|
391
|
+
contextmenu.close();
|
|
392
|
+
};
|
|
393
|
+
host.addEventListener('close', onclose);
|
|
394
|
+
return () => {
|
|
395
|
+
host.removeEventListener('close', onclose);
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Guards the menu from the residual events of the gesture that opened it,
|
|
401
|
+
* using exact gesture causality - no timing heuristics or tunable windows.
|
|
402
|
+
*
|
|
403
|
+
* Touch: when the menu opens during a touch (the native longpress `contextmenu` event,
|
|
404
|
+
* or the Safari-compat custom longpress), the release of that same gesture must not
|
|
405
|
+
* interact with the menu - an unprevented `touchend` lets the browser synthesize
|
|
406
|
+
* compatibility mouse events (`mousedown`/`mouseup`/`click`) at the touch point, and the
|
|
407
|
+
* open offsets place the first menu item exactly there, activating it immediately.
|
|
408
|
+
* `touchend` swallows the release to stop the synthesis, and `consume_blocked_click`
|
|
409
|
+
* blocks the next click on the menu as belt and braces (iOS can synthesize the click
|
|
410
|
+
* regardless).
|
|
411
|
+
*
|
|
412
|
+
* Mouse: tap-style input devices (e.g. some touchpads) can register an overlapping
|
|
413
|
+
* primary-button press during the right-click gesture that opened the menu - the
|
|
414
|
+
* compositor serializes the event stream, but the press's hardware `timeStamp` falls
|
|
415
|
+
* inside the gesture, and its delivery can lag long after the menu opened. Because the
|
|
416
|
+
* open offsets place the first menu item under the pointer, that press's `click` would
|
|
417
|
+
* activate the entry. `press_belongs_to_open_gesture` identifies overlap presses
|
|
418
|
+
* exactly: the press's own `buttons` bitmask shows the secondary button still down
|
|
419
|
+
* at generation time, or its `timeStamp` predates the release tracked by
|
|
420
|
+
* `track_mouseup`. No pressed-state is tracked across events deliberately: the native
|
|
421
|
+
* contextmenu's pointer grab swallows the secondary `mouseup` whenever it opens
|
|
422
|
+
* (right-click on our menu, shift+rightclick - which in Firefox doesn't even fire
|
|
423
|
+
* the `contextmenu` event), so a tracked flag wedges stuck, while the judged press's
|
|
424
|
+
* own `buttons` self-corrects. A deliberate right-then-left click - however fast -
|
|
425
|
+
* is sequential in hardware time and is never blocked.
|
|
426
|
+
*
|
|
427
|
+
* Plain DOM-event bookkeeping with no reactive state - used only inside event handlers
|
|
428
|
+
* by `ContextmenuRoot.svelte` and `ContextmenuRootForSafariCompatibility.svelte`.
|
|
429
|
+
*/
|
|
430
|
+
export class ContextmenuOpenGuard {
|
|
431
|
+
#touch_active = false;
|
|
432
|
+
#opened_by_touch = false;
|
|
433
|
+
#block_next_click = false;
|
|
434
|
+
#last_secondary_up_time = -Infinity;
|
|
435
|
+
#last_touch_end_time = -Infinity;
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Begins a new gesture on `touchstart`, clearing stale flags from the previous one.
|
|
439
|
+
*/
|
|
440
|
+
touchstart(): void {
|
|
441
|
+
this.#touch_active = true;
|
|
442
|
+
this.#opened_by_touch = false;
|
|
443
|
+
this.#block_next_click = false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Arms the touch release guard for the gesture that just opened the menu.
|
|
448
|
+
* Call after a successful open.
|
|
449
|
+
*/
|
|
450
|
+
opened(): void {
|
|
451
|
+
if (this.#touch_active) this.#opened_by_touch = true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Tracks the physical secondary button's release - call from an always-on
|
|
456
|
+
* window `mouseup` capture listener. The release may never be delivered
|
|
457
|
+
* (the native contextmenu's pointer grab eats it), which is why
|
|
458
|
+
* `press_belongs_to_open_gesture` never depends on it alone.
|
|
459
|
+
*/
|
|
460
|
+
track_mouseup(e: MouseEvent): void {
|
|
461
|
+
if (e.button === 2) {
|
|
462
|
+
this.#last_secondary_up_time = e.timeStamp;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Returns `true` when a press belongs to the gesture that opened the menu
|
|
468
|
+
* rather than being a deliberate response to it:
|
|
469
|
+
* secondary-button presses (their own `contextmenu` event resolves them),
|
|
470
|
+
* presses during or predating an active touch (synthesized compatibility events),
|
|
471
|
+
* and presses overlapping the secondary button in hardware time - the press's
|
|
472
|
+
* own `buttons` bitmask shows the secondary button still down at generation
|
|
473
|
+
* time, or its `timeStamp` predates the release tracked by `track_mouseup`.
|
|
474
|
+
*/
|
|
475
|
+
press_belongs_to_open_gesture(e: MouseEvent): boolean {
|
|
476
|
+
if (e.button === 2) return true;
|
|
477
|
+
if (this.#touch_active || e.timeStamp <= this.#last_touch_end_time) return true;
|
|
478
|
+
return (e.buttons & 2) !== 0 || e.timeStamp <= this.#last_secondary_up_time;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Handles a `mousedown` that landed on the menu element: when a primary-button
|
|
483
|
+
* press belongs to the opening gesture, its `click` is blocked from activating the
|
|
484
|
+
* entry that the open offsets placed under the pointer. Non-primary presses never
|
|
485
|
+
* arm the blocker - they produce no `click`, so an armed blocker would linger and
|
|
486
|
+
* eat the next deliberate click on the menu.
|
|
487
|
+
*/
|
|
488
|
+
mousedown_on_menu(e: MouseEvent): void {
|
|
489
|
+
if (e.button === 0 && this.press_belongs_to_open_gesture(e)) {
|
|
490
|
+
this.#block_next_click = true;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Handles `touchend`: when the release belongs to the gesture that opened the menu,
|
|
496
|
+
* swallows it (stopping mouse event synthesis) and arms the click blocker,
|
|
497
|
+
* returning `true`.
|
|
498
|
+
*/
|
|
499
|
+
touchend(e: TouchEvent): boolean {
|
|
500
|
+
this.#touch_active = e.touches.length > 0;
|
|
501
|
+
this.#last_touch_end_time = e.timeStamp;
|
|
502
|
+
if (!this.#opened_by_touch) return false;
|
|
503
|
+
this.#opened_by_touch = false;
|
|
504
|
+
swallow(e);
|
|
505
|
+
this.#block_next_click = true;
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Clears gesture state, including any armed click blocker. Call on `touchcancel`.
|
|
511
|
+
* The secondary release timestamp persists - it mirrors hardware history,
|
|
512
|
+
* not gesture state.
|
|
513
|
+
*/
|
|
514
|
+
reset(): void {
|
|
515
|
+
this.#touch_active = false;
|
|
516
|
+
this.#opened_by_touch = false;
|
|
517
|
+
this.#block_next_click = false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Consumes an armed click blocker, returning `true` if the click should be swallowed.
|
|
522
|
+
* Call from the menu element's `click` capture handler.
|
|
523
|
+
*/
|
|
524
|
+
consume_blocked_click(): boolean {
|
|
525
|
+
if (!this.#block_next_click) return false;
|
|
526
|
+
this.#block_next_click = false;
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Tracks the tap-then-longpress gesture that bypasses the Fuz contextmenu,
|
|
533
|
+
* letting users reach the system contextmenu by tapping once
|
|
534
|
+
* then longpressing/rightclicking within a time window.
|
|
535
|
+
*
|
|
536
|
+
* Plain DOM-event bookkeeping with no reactive state - used only inside event handlers
|
|
537
|
+
* by `ContextmenuRoot.svelte` and `ContextmenuRootForSafariCompatibility.svelte`.
|
|
538
|
+
*/
|
|
539
|
+
export class ContextmenuBypassTracker {
|
|
540
|
+
#first_tap_time: number | null = null;
|
|
541
|
+
#touch_x = 0;
|
|
542
|
+
#touch_y = 0;
|
|
543
|
+
#timeout: NodeJS.Timeout | null = null;
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Set when a tap-then-longpress is detected,
|
|
547
|
+
* telling the next `contextmenu` event handler to let the system contextmenu through.
|
|
548
|
+
*/
|
|
549
|
+
bypassed = false;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Records a single-touch `touchstart` at the given coordinates.
|
|
553
|
+
* Detects a tap-then-longpress when the previous tap happened within `bypass_window`
|
|
554
|
+
* milliseconds and moved less than `bypass_move_tolerance` pixels,
|
|
555
|
+
* setting `bypassed` and returning `true`.
|
|
556
|
+
* Otherwise records the tap for future detection and returns `false`.
|
|
557
|
+
*/
|
|
558
|
+
track(x: number, y: number, bypass_window: number, bypass_move_tolerance: number): boolean {
|
|
559
|
+
if (
|
|
560
|
+
this.#first_tap_time !== null &&
|
|
561
|
+
performance.now() - this.#first_tap_time < bypass_window &&
|
|
562
|
+
Math.hypot(x - this.#touch_x, y - this.#touch_y) < bypass_move_tolerance
|
|
563
|
+
) {
|
|
564
|
+
this.bypassed = true;
|
|
565
|
+
this.#clear_tap_tracking();
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
this.#first_tap_time = performance.now();
|
|
569
|
+
this.#touch_x = x;
|
|
570
|
+
this.#touch_y = y;
|
|
571
|
+
// clear stale tap tracking after the detection window expires
|
|
572
|
+
if (this.#timeout !== null) clearTimeout(this.#timeout);
|
|
573
|
+
this.#timeout = setTimeout(() => {
|
|
574
|
+
this.reset();
|
|
575
|
+
}, bypass_window);
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Consumes a pending bypass, returning `true` and resetting all state if `bypassed` was set,
|
|
581
|
+
* otherwise returning `false` with no effect so tap tracking is preserved.
|
|
582
|
+
*/
|
|
583
|
+
consume(): boolean {
|
|
584
|
+
if (!this.bypassed) return false;
|
|
585
|
+
this.reset();
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Clears all tracking state including any pending bypass.
|
|
591
|
+
*/
|
|
592
|
+
reset(): void {
|
|
593
|
+
this.bypassed = false;
|
|
594
|
+
this.#clear_tap_tracking();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#clear_tap_tracking(): void {
|
|
598
|
+
this.#first_tap_time = null;
|
|
599
|
+
if (this.#timeout !== null) {
|
|
600
|
+
clearTimeout(this.#timeout);
|
|
601
|
+
this.#timeout = null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|