@celestia-island/hikari 0.55.40 → 0.55.42
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/package.json +1 -1
- package/src/components/HkContextMenuProvider.tsx +137 -0
- package/src/components/HkModal.scss +6 -1
- package/src/components/HkModal.sheetgap.test.ts +12 -0
- package/src/components/HkModal.sheetmorph.test.ts +10 -0
- package/src/components/HkModal.tsx +19 -12
- package/src/components/HkNodeCanvas.edges.test.tsx +336 -0
- package/src/components/HkNodeCanvas.scss +41 -0
- package/src/components/HkNodeCanvas.tsx +338 -40
- package/src/components/HkStepFlow.crossfade.contract.test.ts +61 -0
- package/src/components/HkStepFlow.scss +36 -73
- package/src/components/HkStepFlow.test.tsx +41 -18
- package/src/components/HkStepFlow.tsx +165 -23
- package/src/components/nodeCanvasTypes.routing.test.ts +100 -0
- package/src/components/nodeCanvasTypes.ts +130 -10
- package/src/composables/useContextMenu.test.tsx +536 -0
- package/src/composables/useContextMenu.ts +226 -0
- package/src/index.ts +17 -0
- package/src/components/HkStepFlow.phonesnap.test.ts +0 -105
package/package.json
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkContextMenuProvider.tsx — mount-once host of the app's context menu.
|
|
3
|
+
*
|
|
4
|
+
* Renders nothing of its own except the default slot plus ONE menu: a
|
|
5
|
+
* `HkMenu` (popup variant) whose anchor is a zero-size synthetic rect at
|
|
6
|
+
* the pointer that opened it. Everything a menu needs — desktop popout
|
|
7
|
+
* vs mobile bottom sheet, viewport flipping, popup z-stack, outside
|
|
8
|
+
* click, Escape, keyboard navigation, cascading submenus — is the shared
|
|
9
|
+
* select-panel machinery, so a context menu is styled and behaves like
|
|
10
|
+
* every other menu in the family.
|
|
11
|
+
*
|
|
12
|
+
* The placement picks the quadrant the pointer sits in (menus grow away
|
|
13
|
+
* from the nearest edge); HkSelectPanel still auto-flips when its side
|
|
14
|
+
* cannot host the panel, so the quadrant choice is a preference, not a
|
|
15
|
+
* constraint.
|
|
16
|
+
*
|
|
17
|
+
* Mount once near the app root:
|
|
18
|
+
*
|
|
19
|
+
* <HContextMenuProvider> <App/> </HContextMenuProvider>
|
|
20
|
+
*
|
|
21
|
+
* Consumers open menus through `useContextMenu()` /
|
|
22
|
+
* `useContextMenuTrigger()` (composables/useContextMenu).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { computed, defineComponent, provide, ref, shallowRef } from "vue";
|
|
26
|
+
|
|
27
|
+
import HkMenu from "./HkMenu";
|
|
28
|
+
import {
|
|
29
|
+
CONTEXT_MENU_KEY,
|
|
30
|
+
type ContextMenuApi,
|
|
31
|
+
type ContextMenuRequest,
|
|
32
|
+
} from "../composables/useContextMenu";
|
|
33
|
+
|
|
34
|
+
/** Pick the HkMenu placement that grows away from the pointer's nearest
|
|
35
|
+
* edges: `bottom-*` when there is more room below, `*-start` when there
|
|
36
|
+
* is more room to the right. */
|
|
37
|
+
function quadrantPlacement(x: number, y: number): "bottom-start" | "bottom-end" | "top-start" | "top-end" {
|
|
38
|
+
const below = y <= (typeof window !== "undefined" ? window.innerHeight : 0) / 2;
|
|
39
|
+
const right = x <= (typeof window !== "undefined" ? window.innerWidth : 0) / 2;
|
|
40
|
+
if (below && right) return "bottom-start";
|
|
41
|
+
if (below) return "bottom-end";
|
|
42
|
+
return right ? "top-start" : "top-end";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Zero-size client rect at the open point — the same synthetic-anchor
|
|
46
|
+
* trick HkMenu's cascades use, so the shared panel positions a menu at a
|
|
47
|
+
* POINTER exactly like it positions a submenu at a row. */
|
|
48
|
+
function pointAnchor(x: number, y: number): HTMLElement {
|
|
49
|
+
return {
|
|
50
|
+
getBoundingClientRect: () =>
|
|
51
|
+
({
|
|
52
|
+
x, y,
|
|
53
|
+
left: x, top: y, right: x, bottom: y,
|
|
54
|
+
width: 0, height: 0,
|
|
55
|
+
toJSON: () => ({}),
|
|
56
|
+
}) as DOMRect,
|
|
57
|
+
contains: () => false,
|
|
58
|
+
} as unknown as HTMLElement;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default defineComponent({
|
|
62
|
+
name: "HkContextMenuProvider",
|
|
63
|
+
setup(_, { slots }) {
|
|
64
|
+
const open = ref(false);
|
|
65
|
+
const request = shallowRef<ContextMenuRequest | null>(null);
|
|
66
|
+
/** Remount identity: every open() swaps the HkMenu instance, so a
|
|
67
|
+
* replacement menu starts from a CLEAN slate — cascade levels,
|
|
68
|
+
* back-guard, hovered submenus — without racing a
|
|
69
|
+
* close-then-reopen toggle through Vue's scheduler (a same-tick
|
|
70
|
+
* double open, or the reactive-proxy identity trap, both ride
|
|
71
|
+
* that race; R2 NEW-MAJOR-1/NEW-MINOR-1). */
|
|
72
|
+
const menuEpoch = ref(0);
|
|
73
|
+
/** The select handler just ran: HkMenu follows `select` with
|
|
74
|
+
* `update:open(false)` (select first, then closeAll — HkMenu's
|
|
75
|
+
* onItem). That trailing close belongs to the FINISHED menu: it
|
|
76
|
+
* must not fire the new request's onClose nor close a menu the
|
|
77
|
+
* onSelect handler opened in the meantime. One-shot flag. */
|
|
78
|
+
let justSelected = false;
|
|
79
|
+
|
|
80
|
+
/** Close hooks fire exactly once per open. */
|
|
81
|
+
function close(announce: boolean) {
|
|
82
|
+
if (!open.value) return;
|
|
83
|
+
open.value = false;
|
|
84
|
+
if (announce) request.value?.onClose?.();
|
|
85
|
+
request.value = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const api: ContextMenuApi = {
|
|
89
|
+
open(next: ContextMenuRequest) {
|
|
90
|
+
request.value = next;
|
|
91
|
+
menuEpoch.value += 1;
|
|
92
|
+
open.value = true;
|
|
93
|
+
},
|
|
94
|
+
close() {
|
|
95
|
+
close(true);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
provide(CONTEXT_MENU_KEY, api);
|
|
99
|
+
|
|
100
|
+
const anchor = computed(() =>
|
|
101
|
+
pointAnchor(request.value?.x ?? 0, request.value?.y ?? 0),
|
|
102
|
+
);
|
|
103
|
+
const placement = computed(() =>
|
|
104
|
+
quadrantPlacement(request.value?.x ?? 0, request.value?.y ?? 0),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return () => (
|
|
108
|
+
<>
|
|
109
|
+
{slots.default?.()}
|
|
110
|
+
<HkMenu
|
|
111
|
+
key={menuEpoch.value}
|
|
112
|
+
variant="popup"
|
|
113
|
+
items={request.value?.items ?? []}
|
|
114
|
+
open={open.value}
|
|
115
|
+
onUpdate:open={(v: boolean) => {
|
|
116
|
+
if (!v && justSelected) {
|
|
117
|
+
justSelected = false;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!v) close(true);
|
|
121
|
+
}}
|
|
122
|
+
onSelect={(key: string, item: { key: string }) => {
|
|
123
|
+
const current = request.value;
|
|
124
|
+
if (!current) return;
|
|
125
|
+
justSelected = true;
|
|
126
|
+
open.value = false;
|
|
127
|
+
request.value = null;
|
|
128
|
+
current.onSelect?.(key, item as Parameters<NonNullable<ContextMenuRequest["onSelect"]>>[1]);
|
|
129
|
+
}}
|
|
130
|
+
anchorRef={anchor.value}
|
|
131
|
+
placement={placement.value}
|
|
132
|
+
title={request.value?.title ?? ""}
|
|
133
|
+
/>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
@@ -475,8 +475,13 @@
|
|
|
475
475
|
// clip-path sweep (the only transition left on phones).
|
|
476
476
|
//
|
|
477
477
|
// Desktop keeps the full base list (height + max-height + clip-path).
|
|
478
|
+
// 0.3s = the stepflow crossfade's family standard (2026-09-21 user
|
|
479
|
+
// spec, round 7): the clip sweep, the riding body and the fade must
|
|
480
|
+
// land together. Keyboard-driven height changes never animate
|
|
481
|
+
// through this list (height/max-height are not transitioned), so
|
|
482
|
+
// only content morphs ride this duration.
|
|
478
483
|
transition:
|
|
479
|
-
clip-path var(--duration
|
|
484
|
+
clip-path var(--hk-modal-morph-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
480
485
|
// !important beats the inline maxWidth (width prop); base width: 100%
|
|
481
486
|
// already applies at every viewport, so only the cap is re-asserted.
|
|
482
487
|
max-width: 100% !important;
|
|
@@ -121,3 +121,15 @@ describe("HkModal mobile sheet spacing contract", () => {
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
// 2026-09-21 user spec, round 7: the phone sheet's clip sweep shares the
|
|
126
|
+
// stepflow crossfade's 0.3s family duration (the pair must land together).
|
|
127
|
+
describe("HkModal phone morph duration contract", () => {
|
|
128
|
+
it("rides the clip sweep on --hk-modal-morph-duration (0.3s default)", () => {
|
|
129
|
+
const block = src.slice(src.indexOf("@media (max-width: 767px)"));
|
|
130
|
+
const content = block.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
|
|
131
|
+
expect(content).toMatch(
|
|
132
|
+
/clip-path var\(--hk-modal-morph-duration, 0\.3s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\)/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -63,6 +63,16 @@ describe("sheet family morph-performance contract", () => {
|
|
|
63
63
|
);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it("slows the phone sheet's clip sweep to the crossfade's 0.3s", () => {
|
|
67
|
+
// 2026-09-21 user spec, round 7: the clip sweep, the riding body and
|
|
68
|
+
// the fade must land together — the phone block overrides the fast
|
|
69
|
+
// desktop timing with the stepflow family standard. Desktop keeps
|
|
70
|
+
// the fast pair above.
|
|
71
|
+
expect(mobileModal).toMatch(
|
|
72
|
+
/^[ \t]*clip-path var\(--hk-modal-morph-duration, 0\.3s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
66
76
|
it("never backdrop-filters the docked modal sheet or its scrim", () => {
|
|
67
77
|
expect(mobileModal).toMatch(
|
|
68
78
|
/^[ \t]*backdrop-filter:[ \t]*var\(--hk-modal-blur-mobile, none\);$/m,
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
computed,
|
|
3
|
-
defineComponent,
|
|
4
|
-
nextTick,
|
|
5
|
-
onBeforeUnmount,
|
|
6
|
-
onMounted,
|
|
7
|
-
ref,
|
|
8
|
-
shallowRef,
|
|
9
|
-
Teleport,
|
|
10
|
-
watch,
|
|
11
|
-
type PropType,
|
|
12
|
-
} from "vue";
|
|
1
|
+
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, Teleport, watch, type PropType, } from "vue";
|
|
13
2
|
|
|
14
3
|
import { useI18n } from "../i18n/context";
|
|
15
4
|
import "./HkModal.scss";
|
|
@@ -23,6 +12,8 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
|
23
12
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
24
13
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
25
14
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
15
|
+
|
|
16
|
+
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
26
17
|
import HButton from "./HkButton";
|
|
27
18
|
import HFab from "./HkFab";
|
|
28
19
|
import HSpinner from "./HkSpinner";
|
|
@@ -218,6 +209,22 @@ export default defineComponent({
|
|
|
218
209
|
let previouslyFocused: HTMLElement | null = null;
|
|
219
210
|
let unmounted = false;
|
|
220
211
|
|
|
212
|
+
// HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) from the
|
|
213
|
+
// flow root the moment the entering body owns the flow height. The
|
|
214
|
+
// morph's settle debounce is tuned for streaming bursts, but a step
|
|
215
|
+
// swap is one clean change whose sheet sweep must start on the SAME
|
|
216
|
+
// frames as the crossfade (2026-09-21 user spec, round 7) — so the
|
|
217
|
+
// event short-circuits straight into remeasure().
|
|
218
|
+
const onStepflowSwap = (): void => {
|
|
219
|
+
if (machine.phase.value === "open") morph.remeasure();
|
|
220
|
+
};
|
|
221
|
+
onMounted(() => {
|
|
222
|
+
bodyRef.value?.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
223
|
+
});
|
|
224
|
+
onBeforeUnmount(() => {
|
|
225
|
+
bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
226
|
+
});
|
|
227
|
+
|
|
221
228
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
222
229
|
const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
|
|
223
230
|
const resolvedWidth = computed(() =>
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkNodeCanvas from "./HkNodeCanvas";
|
|
5
|
+
import type { NodeCanvasEdge } from "./nodeCanvasTypes";
|
|
6
|
+
|
|
7
|
+
/** Mount a component into a detached div appended to the body. */
|
|
8
|
+
function mountToDom(component: ReturnType<typeof defineComponent>): HTMLElement {
|
|
9
|
+
const el = document.createElement("div");
|
|
10
|
+
document.body.appendChild(el);
|
|
11
|
+
const app = createApp(component);
|
|
12
|
+
app.mount(el);
|
|
13
|
+
return el;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function pointerEvent(type: string, init: PointerEventInit = {}): PointerEvent {
|
|
17
|
+
return new PointerEvent(type, {
|
|
18
|
+
bubbles: true,
|
|
19
|
+
cancelable: true,
|
|
20
|
+
pointerId: 1,
|
|
21
|
+
pointerType: "touch",
|
|
22
|
+
buttons: 1,
|
|
23
|
+
...init,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Direct-child paths of the visible edge groups (the ink strokes). */
|
|
28
|
+
function el2StrokeCount(el: HTMLElement): number {
|
|
29
|
+
return el.querySelectorAll(".hk-node-canvas-edge > path:not(.hk-node-canvas-edge-halo)").length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EDGES: NodeCanvasEdge[] = [
|
|
33
|
+
{ id: "e1", from: { x: 0, y: 0 }, to: { x: 100, y: 100 }, type: "rpc", width: 1.4 },
|
|
34
|
+
{ id: "e2", from: { x: 0, y: 50 }, to: { x: 100, y: 150 }, type: "proxy", width: 1.6 },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
interface EdgeHostProps {
|
|
38
|
+
interactive?: boolean;
|
|
39
|
+
selectedEdgeId?: string | null;
|
|
40
|
+
hoverDimming?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Host wiring the slice of the canvas API the events describe. */
|
|
44
|
+
function makeEdgeHost(collected: {
|
|
45
|
+
hover: Array<string | null>;
|
|
46
|
+
click: string[];
|
|
47
|
+
context: string[];
|
|
48
|
+
prevented: boolean[];
|
|
49
|
+
}) {
|
|
50
|
+
return defineComponent({
|
|
51
|
+
props: {
|
|
52
|
+
interactive: { type: Boolean, default: true },
|
|
53
|
+
selectedEdgeId: { type: String as () => string | null, default: null },
|
|
54
|
+
hoverDimming: { type: Boolean, default: true },
|
|
55
|
+
},
|
|
56
|
+
setup(props: EdgeHostProps) {
|
|
57
|
+
return () =>
|
|
58
|
+
h(HkNodeCanvas, {
|
|
59
|
+
edges: EDGES,
|
|
60
|
+
interactiveEdges: props.interactive ?? true,
|
|
61
|
+
selectedEdgeId: props.selectedEdgeId ?? null,
|
|
62
|
+
hoverDimming: props.hoverDimming ?? true,
|
|
63
|
+
minimap: false,
|
|
64
|
+
onEdgeHover: (edge: NodeCanvasEdge | null) => collected.hover.push(edge ? edge.id : null),
|
|
65
|
+
onEdgeClick: (edge: NodeCanvasEdge) => collected.click.push(edge.id),
|
|
66
|
+
onEdgeContextmenu: (edge: NodeCanvasEdge, event: { preventDefault(): void }) => {
|
|
67
|
+
collected.context.push(edge.id);
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
collected.prevented.push(true);
|
|
70
|
+
},
|
|
71
|
+
}, { default: () => h("div", "content") });
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* HkNodeCanvas interactive edge layer — the hover/click/contextmenu
|
|
78
|
+
* contract topology hosts consume:
|
|
79
|
+
*
|
|
80
|
+
* - off by default (no hit strokes, no halo paths);
|
|
81
|
+
* - hit strokes re-enable pointer events per stroke and sit on top;
|
|
82
|
+
* - hover highlights the edge and dims the rest (dimming optional);
|
|
83
|
+
* - selection outlives hover; a vanished hovered edge clears the hover;
|
|
84
|
+
* - click after a pan capture is the gesture's tail, not a pick;
|
|
85
|
+
* - contextmenu is prevented and reported, and a touch hold
|
|
86
|
+
* synthesizes one at the press point;
|
|
87
|
+
* - explicit subpaths render (and hit) one stroke per subpath.
|
|
88
|
+
*/
|
|
89
|
+
describe("HkNodeCanvas interactive edges", () => {
|
|
90
|
+
it("renders no hit strokes or halos when not interactive", () => {
|
|
91
|
+
const Host = defineComponent({
|
|
92
|
+
setup() {
|
|
93
|
+
return () =>
|
|
94
|
+
h(HkNodeCanvas, { edges: EDGES, minimap: false }, { default: () => h("div", "x") });
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const el = mountToDom(Host);
|
|
98
|
+
expect(el.querySelectorAll(".hk-node-canvas-edge-hit")).toHaveLength(0);
|
|
99
|
+
expect(el.querySelectorAll(".hk-node-canvas-edge-halo")).toHaveLength(0);
|
|
100
|
+
// Visible strokes still render.
|
|
101
|
+
expect(el2StrokeCount(el)).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("highlights the hovered edge and dims the rest", async () => {
|
|
105
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
106
|
+
const el = mountToDom(makeEdgeHost(collected));
|
|
107
|
+
const hits = el.querySelectorAll(".hk-node-canvas-edge-hit[data-edge-id='e1']");
|
|
108
|
+
expect(hits.length).toBe(1);
|
|
109
|
+
hits[0].dispatchEvent(pointerEvent("pointerenter", { pointerType: "mouse" }));
|
|
110
|
+
await nextTick();
|
|
111
|
+
expect(collected.hover).toEqual(["e1"]);
|
|
112
|
+
const groups = el.querySelectorAll(".hk-node-canvas-edge");
|
|
113
|
+
expect(groups[0].classList.contains("is-hovered")).toBe(true);
|
|
114
|
+
expect(groups[1].classList.contains("is-dimmed")).toBe(true);
|
|
115
|
+
hits[0].dispatchEvent(pointerEvent("pointerleave", { pointerType: "mouse" }));
|
|
116
|
+
await nextTick();
|
|
117
|
+
expect(collected.hover).toEqual(["e1", null]);
|
|
118
|
+
expect(groups[1].classList.contains("is-dimmed")).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps a selected edge lit through hover changes", async () => {
|
|
122
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
123
|
+
const Host = defineComponent({
|
|
124
|
+
setup() {
|
|
125
|
+
return () =>
|
|
126
|
+
h(makeEdgeHost(collected), { selectedEdgeId: "e2" });
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const el = mountToDom(Host);
|
|
130
|
+
el.querySelector(".hk-node-canvas-edge-hit[data-edge-id='e1']")
|
|
131
|
+
?.dispatchEvent(pointerEvent("pointerenter", { pointerType: "mouse" }));
|
|
132
|
+
await nextTick();
|
|
133
|
+
const groups = el.querySelectorAll(".hk-node-canvas-edge");
|
|
134
|
+
expect(groups[1].classList.contains("is-selected")).toBe(true);
|
|
135
|
+
// The selected edge does NOT dim while another is hovered.
|
|
136
|
+
expect(groups[1].classList.contains("is-dimmed")).toBe(false);
|
|
137
|
+
expect(groups[0].classList.contains("is-hovered")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("reports edge clicks unless a pan was captured", async () => {
|
|
141
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
142
|
+
const el = mountToDom(makeEdgeHost(collected));
|
|
143
|
+
const root = el.querySelector(".hk-node-canvas") as HTMLElement;
|
|
144
|
+
const hit = el.querySelector(".hk-node-canvas-edge-hit[data-edge-id='e1']") as HTMLElement;
|
|
145
|
+
|
|
146
|
+
hit.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
147
|
+
await nextTick();
|
|
148
|
+
expect(collected.click).toEqual(["e1"]);
|
|
149
|
+
|
|
150
|
+
// A pan: pointerdown on the root, move past the slop (capture), up.
|
|
151
|
+
root.dispatchEvent(pointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0, pointerType: "mouse" }));
|
|
152
|
+
root.dispatchEvent(pointerEvent("pointermove", { clientX: 140, clientY: 100, pointerType: "mouse" }));
|
|
153
|
+
root.dispatchEvent(pointerEvent("pointerup", { pointerType: "mouse" }));
|
|
154
|
+
hit.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
155
|
+
await nextTick();
|
|
156
|
+
expect(collected.click).toEqual(["e1"]); // the post-pan click was eaten
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("prevents and reports contextmenu on a hit stroke", async () => {
|
|
160
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
161
|
+
const el = mountToDom(makeEdgeHost(collected));
|
|
162
|
+
const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
|
163
|
+
el.querySelector(".hk-node-canvas-edge-hit[data-edge-id='e2']")
|
|
164
|
+
?.dispatchEvent(event);
|
|
165
|
+
await nextTick();
|
|
166
|
+
expect(collected.context).toEqual(["e2"]);
|
|
167
|
+
expect(event.defaultPrevented).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("synthesizes contextmenu from a touch long-press", () => {
|
|
171
|
+
vi.useFakeTimers();
|
|
172
|
+
try {
|
|
173
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
174
|
+
const el = mountToDom(makeEdgeHost(collected));
|
|
175
|
+
const hit = el.querySelector(".hk-node-canvas-edge-hit[data-edge-id='e1']") as HTMLElement;
|
|
176
|
+
hit.dispatchEvent(pointerEvent("pointerdown", { clientX: 40, clientY: 40 }));
|
|
177
|
+
vi.advanceTimersByTime(500);
|
|
178
|
+
expect(collected.context).toEqual(["e1"]);
|
|
179
|
+
// Moving past the slop before the hold fires cancels it.
|
|
180
|
+
hit.dispatchEvent(pointerEvent("pointerdown", { clientX: 40, clientY: 40 }));
|
|
181
|
+
hit.dispatchEvent(pointerEvent("pointermove", { clientX: 70, clientY: 40 }));
|
|
182
|
+
vi.advanceTimersByTime(500);
|
|
183
|
+
expect(collected.context).toEqual(["e1"]); // no second open
|
|
184
|
+
hit.dispatchEvent(pointerEvent("pointerup"));
|
|
185
|
+
} finally {
|
|
186
|
+
vi.useRealTimers();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("suppresses the native contextmenu that follows a synthesized long-press", () => {
|
|
191
|
+
vi.useFakeTimers();
|
|
192
|
+
try {
|
|
193
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
194
|
+
const el = mountToDom(makeEdgeHost(collected));
|
|
195
|
+
const hit = el.querySelector(".hk-node-canvas-edge-hit[data-edge-id='e1']") as HTMLElement;
|
|
196
|
+
hit.dispatchEvent(pointerEvent("pointerdown", { clientX: 40, clientY: 40 }));
|
|
197
|
+
vi.advanceTimersByTime(500);
|
|
198
|
+
expect(collected.context).toEqual(["e1"]);
|
|
199
|
+
// The platform synthesizes a contextmenu right after the hold at
|
|
200
|
+
// the SAME spot: suppressed (prevented), not emitted again.
|
|
201
|
+
const native = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 40, clientY: 40 });
|
|
202
|
+
hit.dispatchEvent(native);
|
|
203
|
+
expect(collected.context).toEqual(["e1"]);
|
|
204
|
+
expect(native.defaultPrevented).toBe(true);
|
|
205
|
+
// A real right-click ELSEWHERE inside the window is a new
|
|
206
|
+
// gesture — it must emit (R2 NEW-MINOR-2).
|
|
207
|
+
const elsewhere = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 400, clientY: 300 });
|
|
208
|
+
hit.dispatchEvent(elsewhere);
|
|
209
|
+
expect(collected.context).toEqual(["e1", "e1"]);
|
|
210
|
+
// …and at the same spot once the window has expired.
|
|
211
|
+
vi.advanceTimersByTime(600);
|
|
212
|
+
const later = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 40, clientY: 40 });
|
|
213
|
+
hit.dispatchEvent(later);
|
|
214
|
+
expect(collected.context).toEqual(["e1", "e1", "e1"]);
|
|
215
|
+
hit.dispatchEvent(pointerEvent("pointerup"));
|
|
216
|
+
} finally {
|
|
217
|
+
vi.useRealTimers();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("exports every explicit subpath of an edge (bus trunk + stubs)", () => {
|
|
222
|
+
const busEdge: NodeCanvasEdge = {
|
|
223
|
+
id: "bus",
|
|
224
|
+
from: { x: 0, y: 0 },
|
|
225
|
+
to: { x: 100, y: 0 },
|
|
226
|
+
subpaths: [
|
|
227
|
+
{ d: "M 0 0 L 0 20", width: 1.4 },
|
|
228
|
+
{ d: "M 0 20 L 100 20", width: 5 },
|
|
229
|
+
{ d: "M 100 20 L 100 0", width: 1.4 },
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
const exposed: { exportSVG?: () => SVGElement | undefined } = {};
|
|
233
|
+
const Host = defineComponent({
|
|
234
|
+
setup() {
|
|
235
|
+
const canvas = ref<{ exportSVG: () => SVGElement } | null>(null);
|
|
236
|
+
exposed.exportSVG = () => canvas.value?.exportSVG();
|
|
237
|
+
return () =>
|
|
238
|
+
h(HkNodeCanvas, {
|
|
239
|
+
ref: canvas,
|
|
240
|
+
edges: [busEdge],
|
|
241
|
+
minimap: false,
|
|
242
|
+
}, { default: () => h("div", "x") });
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
mountToDom(Host);
|
|
246
|
+
const svg = exposed.exportSVG?.();
|
|
247
|
+
expect(svg).toBeDefined();
|
|
248
|
+
const paths = svg!.querySelectorAll("path");
|
|
249
|
+
// R2 V4: the whole bundle exports, not just the first run.
|
|
250
|
+
expect(paths).toHaveLength(3);
|
|
251
|
+
expect(paths[1].getAttribute("stroke-width")).toBe("5");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("cancels the hold when the pan captures the pointer (R1 MAJOR-1)", () => {
|
|
255
|
+
vi.useFakeTimers();
|
|
256
|
+
try {
|
|
257
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
258
|
+
const el = mountToDom(makeEdgeHost(collected));
|
|
259
|
+
const root = el.querySelector(".hk-node-canvas") as HTMLElement;
|
|
260
|
+
const hit = el.querySelector(".hk-node-canvas-edge-hit[data-edge-id='e1']") as HTMLElement;
|
|
261
|
+
// Touch press on the edge, then the press becomes a PAN: the root
|
|
262
|
+
// captures the pointer once the travel passes its slop — from
|
|
263
|
+
// then on the hit stroke hears nothing more. The hold must die
|
|
264
|
+
// with the capture, or a long drag would end in a menu.
|
|
265
|
+
hit.dispatchEvent(pointerEvent("pointerdown", { clientX: 100, clientY: 100 }));
|
|
266
|
+
root.dispatchEvent(pointerEvent("pointerdown", { clientX: 100, clientY: 100, button: 0 }));
|
|
267
|
+
root.dispatchEvent(pointerEvent("pointermove", { clientX: 160, clientY: 100 }));
|
|
268
|
+
vi.advanceTimersByTime(800);
|
|
269
|
+
expect(collected.context).toEqual([]);
|
|
270
|
+
root.dispatchEvent(pointerEvent("pointerup"));
|
|
271
|
+
} finally {
|
|
272
|
+
vi.useRealTimers();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("renders and hits explicit subpaths (bus trunk + stubs)", async () => {
|
|
277
|
+
const busEdge: NodeCanvasEdge = {
|
|
278
|
+
id: "bus",
|
|
279
|
+
from: { x: 0, y: 0 },
|
|
280
|
+
to: { x: 100, y: 0 },
|
|
281
|
+
subpaths: [
|
|
282
|
+
{ d: "M 0 0 L 0 20", width: 1.4 },
|
|
283
|
+
{ d: "M 0 20 L 100 20", width: 5 },
|
|
284
|
+
{ d: "M 100 20 L 100 0", width: 1.4 },
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
const Host = defineComponent({
|
|
288
|
+
setup() {
|
|
289
|
+
return () =>
|
|
290
|
+
h(HkNodeCanvas, {
|
|
291
|
+
edges: [busEdge],
|
|
292
|
+
interactiveEdges: true,
|
|
293
|
+
minimap: false,
|
|
294
|
+
}, { default: () => h("div", "x") });
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const el = mountToDom(Host);
|
|
298
|
+
const group = el.querySelector(".hk-node-canvas-edge[data-edge-id='bus']") as HTMLElement;
|
|
299
|
+
// 3 halo + 3 ink strokes.
|
|
300
|
+
expect(group.querySelectorAll("path")).toHaveLength(6);
|
|
301
|
+
// ONE combined hit path per edge (all subpath runs concatenated —
|
|
302
|
+
// crossing a stub→trunk boundary inside the same edge must not
|
|
303
|
+
// chatter enter/leave), its width the fattest of the members'.
|
|
304
|
+
const hits = el.querySelectorAll(".hk-node-canvas-edge-hit[data-edge-id='bus']");
|
|
305
|
+
expect(hits).toHaveLength(1);
|
|
306
|
+
const hit = hits[0] as SVGPathElement;
|
|
307
|
+
expect((hit.getAttribute("d") ?? "").split("M").length - 1).toBe(3);
|
|
308
|
+
expect(Number(hit.getAttribute("stroke-width"))).toBeGreaterThan(5);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("clears the hover when the hovered edge disappears", async () => {
|
|
312
|
+
const collected = { hover: [] as Array<string | null>, click: [] as string[], context: [] as string[], prevented: [] as boolean[] };
|
|
313
|
+
const edges = ref<NodeCanvasEdge[]>([...EDGES]);
|
|
314
|
+
const Host = defineComponent({
|
|
315
|
+
setup() {
|
|
316
|
+
return () =>
|
|
317
|
+
h(HkNodeCanvas, {
|
|
318
|
+
edges: edges.value,
|
|
319
|
+
interactiveEdges: true,
|
|
320
|
+
minimap: false,
|
|
321
|
+
onEdgeHover: (edge: NodeCanvasEdge | null) => collected.hover.push(edge ? edge.id : null),
|
|
322
|
+
}, { default: () => h("div", "x") });
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
const el = mountToDom(Host);
|
|
326
|
+
el.querySelector(".hk-node-canvas-edge-hit[data-edge-id='e1']")
|
|
327
|
+
?.dispatchEvent(pointerEvent("pointerenter", { pointerType: "mouse" }));
|
|
328
|
+
await nextTick();
|
|
329
|
+
expect(collected.hover).toEqual(["e1"]);
|
|
330
|
+
edges.value = EDGES.filter((e) => e.id !== "e1");
|
|
331
|
+
await nextTick();
|
|
332
|
+
// The vanish reported a leave so no stale hover class persists.
|
|
333
|
+
expect(collected.hover).toEqual(["e1", null]);
|
|
334
|
+
expect(el.querySelector(".hk-node-canvas-edge.is-hovered")).toBeNull();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -64,3 +64,44 @@
|
|
|
64
64
|
top: var(--space-12);
|
|
65
65
|
inset-inline-start: var(--space-12);
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/* ── Interactive edges ───────────────────────────────────────────────
|
|
69
|
+
The halo paths are always in the DOM when `interactiveEdges` is on but
|
|
70
|
+
invisible until their edge is hovered or selected, so highlighting
|
|
71
|
+
costs no re-layout — only a visibility/opacity flip. Dimming fades the
|
|
72
|
+
strokes that are neither hovered nor selected while one is hovered,
|
|
73
|
+
which is what makes "which line am I on" readable on a busy canvas. */
|
|
74
|
+
.hk-node-canvas-edge-halo {
|
|
75
|
+
visibility: hidden;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.hk-node-canvas-edge.is-hovered .hk-node-canvas-edge-halo,
|
|
79
|
+
.hk-node-canvas-edge.is-selected .hk-node-canvas-edge-halo {
|
|
80
|
+
visibility: visible;
|
|
81
|
+
opacity: 0.28;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.hk-node-canvas-edge.is-selected .hk-node-canvas-edge-halo {
|
|
85
|
+
opacity: 0.4;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.hk-node-canvas-edge.is-dimmed > path:not(.hk-node-canvas-edge-halo) {
|
|
89
|
+
opacity: 0.25;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.hk-node-canvas-edge.is-dimmed text {
|
|
93
|
+
opacity: 0.4;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
/* The invisible fat strokes that carry the pointer events. Transparent
|
|
98
|
+
(not none) so hit-testing still sees them; the cursor tells the user
|
|
99
|
+
the line is a target. A press-and-hold must not also summon the
|
|
100
|
+
platform's text-selection UI (iOS magnifier / callout). Hosts binding
|
|
101
|
+
their own long-press targets outside the canvas need the same
|
|
102
|
+
properties on those elements — see bindContextMenu's doc note. */
|
|
103
|
+
.hk-node-canvas-edge-hit {
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
user-select: none;
|
|
106
|
+
-webkit-touch-callout: none;
|
|
107
|
+
}
|