@celestia-island/hikari 0.34.0 → 0.35.1
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/HkBoard.scss +83 -0
- package/src/components/HkBoard.tsx +465 -0
- package/src/components/HkIconButton.scss +0 -1
- package/src/components/HkMinimap.scss +4 -0
- package/src/composables/useBoardCamera.ts +235 -0
- package/src/index.ts +4 -0
- package/src/theme/tokenGroups.test.ts +7 -2
- package/src/theme/useTheme.test.ts +67 -0
- package/src/theme/useTheme.ts +71 -3
- package/src/utils/boardCamera.test.ts +177 -0
- package/src/utils/boardCamera.ts +215 -0
- package/src/utils/boardEdges.test.ts +120 -0
- package/src/utils/boardEdges.ts +208 -0
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// HkBoard — the shared node-and-edge board surface.
|
|
2
|
+
// Geometry semantics live in utils/boardCamera.ts + utils/boardEdges.ts;
|
|
3
|
+
// this file only styles the layers (grid, world, edges, node shells,
|
|
4
|
+
// minimap dock).
|
|
5
|
+
|
|
6
|
+
.hk-board {
|
|
7
|
+
position: relative;
|
|
8
|
+
width: 100%;
|
|
9
|
+
height: 100%;
|
|
10
|
+
min-height: 0;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
background: rgb(var(--color-surface));
|
|
13
|
+
// gestures own the surface: no native scroll/selection interference
|
|
14
|
+
touch-action: none;
|
|
15
|
+
user-select: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.hk-board-grid {
|
|
19
|
+
position: absolute;
|
|
20
|
+
inset: 0;
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hk-board-world {
|
|
25
|
+
position: absolute;
|
|
26
|
+
top: 0;
|
|
27
|
+
left: 0;
|
|
28
|
+
transform-origin: 0 0;
|
|
29
|
+
will-change: transform;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.hk-board-edges {
|
|
33
|
+
position: absolute;
|
|
34
|
+
top: 0;
|
|
35
|
+
left: 0;
|
|
36
|
+
overflow: visible;
|
|
37
|
+
pointer-events: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.hk-board-edge {
|
|
41
|
+
fill: none;
|
|
42
|
+
stroke-linecap: round;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-board-node {
|
|
46
|
+
position: absolute;
|
|
47
|
+
box-sizing: border-box;
|
|
48
|
+
|
|
49
|
+
&--draggable {
|
|
50
|
+
cursor: grab;
|
|
51
|
+
|
|
52
|
+
&:active {
|
|
53
|
+
cursor: grabbing;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.hk-board-node-shell {
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: 100%;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
padding: 0 var(--space-6);
|
|
65
|
+
box-sizing: border-box;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
text-overflow: ellipsis;
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
font-size: var(--text-2xs);
|
|
70
|
+
color: rgb(var(--color-text));
|
|
71
|
+
background: rgb(var(--color-surface));
|
|
72
|
+
border: 1px solid rgb(var(--color-primary) / 45%);
|
|
73
|
+
border-radius: var(--radius-sm);
|
|
74
|
+
box-shadow: var(--shadow-elevated);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.hk-board-minimap {
|
|
78
|
+
position: absolute;
|
|
79
|
+
right: var(--space-10);
|
|
80
|
+
bottom: var(--space-10);
|
|
81
|
+
z-index: 6;
|
|
82
|
+
pointer-events: auto;
|
|
83
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkBoard — the shared node-and-edge board surface.
|
|
3
|
+
*
|
|
4
|
+
* ONE world-camera canvas that every graph-like page builds on (history
|
|
5
|
+
* topic maps, SCADA scenes, PLC panels, and whatever comes next). The
|
|
6
|
+
* board owns the motion feel and the chrome; the page owns the layout:
|
|
7
|
+
*
|
|
8
|
+
* - CAMERA: 5% geometric zoom ladder with an ANIMATED settle (no snapping),
|
|
9
|
+
* wheel zoom anchored at the pointer (desktop), two-finger pinch
|
|
10
|
+
* (touch, continuous while pinching → animated rung on release),
|
|
11
|
+
* clamped background pan, fit/reset;
|
|
12
|
+
* - GRID: the engineering dot grid is always on and world-anchored —
|
|
13
|
+
* it scales and translates with the camera so it doubles as an
|
|
14
|
+
* alignment aid;
|
|
15
|
+
* - NODES: DOM shells positioned in world coords, rendered through the
|
|
16
|
+
* `node` scoped slot (a labeled shell is provided when the slot is
|
|
17
|
+
* omitted). `draggable` nodes move in world coords and emit
|
|
18
|
+
* `node-move`; `hidden` nodes paint NOTHING but still route edges —
|
|
19
|
+
* that is how a page bakes a right-angle corner into its graph;
|
|
20
|
+
* - EDGES: SVG paths with independently configurable anchor modes
|
|
21
|
+
* (center / nearest / top / top-left / fan-天女散花) and route styles
|
|
22
|
+
* (straight / bezier / orthogonal / spine), optionally routed through
|
|
23
|
+
* `via` junction nodes (see utils/boardEdges.ts);
|
|
24
|
+
* - MINIMAP: HkMinimap pinned bottom-right, wired to the same camera.
|
|
25
|
+
*
|
|
26
|
+
* The page supplies `nodes` (world rects, reactive) and `edges`; node
|
|
27
|
+
* LAYOUT stays the page's business — the board never forces an
|
|
28
|
+
* arrangement, it only standardizes how things connect and move.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
computed,
|
|
33
|
+
defineComponent,
|
|
34
|
+
onBeforeUnmount,
|
|
35
|
+
onMounted,
|
|
36
|
+
ref,
|
|
37
|
+
toRef,
|
|
38
|
+
watch,
|
|
39
|
+
type PropType,
|
|
40
|
+
type Ref,
|
|
41
|
+
} from "vue";
|
|
42
|
+
|
|
43
|
+
import { useBoardCamera } from "../composables/useBoardCamera";
|
|
44
|
+
import HkMinimap, { type MinimapBox } from "./HkMinimap";
|
|
45
|
+
import {
|
|
46
|
+
boardBBox,
|
|
47
|
+
boardStepRung,
|
|
48
|
+
type BoardCamera,
|
|
49
|
+
type BoardPoint,
|
|
50
|
+
type BoardRect,
|
|
51
|
+
type BoardViewport,
|
|
52
|
+
} from "../utils/boardCamera";
|
|
53
|
+
import {
|
|
54
|
+
boardAnchor,
|
|
55
|
+
boardEdgePath,
|
|
56
|
+
boardFanOrdinals,
|
|
57
|
+
boardViaPath,
|
|
58
|
+
type BoardAnchorMode,
|
|
59
|
+
type BoardEdgeStyle,
|
|
60
|
+
} from "../utils/boardEdges";
|
|
61
|
+
import "./HkBoard.scss";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A node on the board. `hidden` nodes are junction-only (invisible).
|
|
65
|
+
* During a node drag the board mutates the node objects IN PLACE and
|
|
66
|
+
* emits the live reference through `node-move` / `nodeClick` — pass
|
|
67
|
+
* deep-reactive, mutable node objects, not frozen copies.
|
|
68
|
+
*/
|
|
69
|
+
export interface BoardNodeInput {
|
|
70
|
+
id: string;
|
|
71
|
+
x: number;
|
|
72
|
+
y: number;
|
|
73
|
+
w: number;
|
|
74
|
+
h: number;
|
|
75
|
+
label?: string;
|
|
76
|
+
kind?: string;
|
|
77
|
+
/** Invisible junction node — routes edges, paints nothing. */
|
|
78
|
+
hidden?: boolean;
|
|
79
|
+
/** Opt in to pointer dragging (world-coord moves + `node-move` events). */
|
|
80
|
+
draggable?: boolean;
|
|
81
|
+
/** Page payload, handed back through the scoped slot. */
|
|
82
|
+
data?: Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BoardEdgeInput {
|
|
86
|
+
id: string;
|
|
87
|
+
from: string;
|
|
88
|
+
to: string;
|
|
89
|
+
/** Junction node ids the edge routes through (their centers). */
|
|
90
|
+
via?: string[];
|
|
91
|
+
style?: BoardEdgeStyle;
|
|
92
|
+
anchorFrom?: BoardAnchorMode;
|
|
93
|
+
anchorTo?: BoardAnchorMode;
|
|
94
|
+
ink?: string;
|
|
95
|
+
dashed?: boolean;
|
|
96
|
+
width?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_INK = "rgb(var(--color-primary) / 38%)";
|
|
100
|
+
|
|
101
|
+
export default defineComponent({
|
|
102
|
+
name: "HkBoard",
|
|
103
|
+
props: {
|
|
104
|
+
nodes: { type: Array as PropType<BoardNodeInput[]>, default: () => [] },
|
|
105
|
+
edges: { type: Array as PropType<BoardEdgeInput[]>, default: () => [] },
|
|
106
|
+
/** Wire wheel / pinch / pan gestures (node clicks always work). */
|
|
107
|
+
interactive: { type: Boolean, default: true },
|
|
108
|
+
grid: { type: Boolean, default: true },
|
|
109
|
+
gridSize: { type: Number, default: 24 },
|
|
110
|
+
minimap: { type: Boolean, default: true },
|
|
111
|
+
minK: { type: Number, default: 0.2 },
|
|
112
|
+
maxK: { type: Number, default: 4 },
|
|
113
|
+
animated: { type: Boolean, default: true },
|
|
114
|
+
fitOnMount: { type: Boolean, default: true },
|
|
115
|
+
},
|
|
116
|
+
emits: {
|
|
117
|
+
nodeClick: (_node: BoardNodeInput) => true,
|
|
118
|
+
nodeMove: (_node: BoardNodeInput, _x: number, _y: number) => true,
|
|
119
|
+
viewportChange: (_cam: BoardCamera) => true,
|
|
120
|
+
},
|
|
121
|
+
setup(props, { emit, expose, slots }) {
|
|
122
|
+
const viewportRef = ref<HTMLElement | null>(null);
|
|
123
|
+
const viewportSize = ref<BoardViewport>({ w: 800, h: 600 });
|
|
124
|
+
|
|
125
|
+
/** Content bbox (world) — nodes plus a small margin. */
|
|
126
|
+
const content = computed<BoardRect>(() => {
|
|
127
|
+
const box = boardBBox(props.nodes.map((n) => ({ x: n.x, y: n.y, w: n.w, h: n.h })));
|
|
128
|
+
return { x: box.x - 20, y: box.y - 20, w: box.w + 40, h: box.h + 40 };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const cam: ReturnType<typeof useBoardCamera> = useBoardCamera({
|
|
132
|
+
viewportSize,
|
|
133
|
+
content,
|
|
134
|
+
minK: props.minK,
|
|
135
|
+
maxK: props.maxK,
|
|
136
|
+
animated: props.animated,
|
|
137
|
+
});
|
|
138
|
+
const camera = cam.camera as Ref<BoardCamera>;
|
|
139
|
+
|
|
140
|
+
// ── geometry ────────────────────────────────────────────────────────
|
|
141
|
+
const rectOf = computed(() => {
|
|
142
|
+
const map = new Map<string, BoardRect>();
|
|
143
|
+
for (const n of props.nodes) map.set(n.id, { x: n.x, y: n.y, w: n.w, h: n.h });
|
|
144
|
+
return map;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** Edges sharing a `from` node fan across its border (天女散花). */
|
|
148
|
+
const fanOrdinal = computed(() => boardFanOrdinals(props.edges));
|
|
149
|
+
|
|
150
|
+
const edgePaths = computed(() => {
|
|
151
|
+
const rects = rectOf.value;
|
|
152
|
+
const out: { id: string; d: string; ink: string; dashed: boolean; width: number }[] = [];
|
|
153
|
+
for (const e of props.edges) {
|
|
154
|
+
const from = rects.get(e.from);
|
|
155
|
+
const to = rects.get(e.to);
|
|
156
|
+
if (!from || !to) continue;
|
|
157
|
+
const toCenter = { x: to.x + to.w / 2, y: to.y + to.h / 2 };
|
|
158
|
+
const fromCenter = { x: from.x + from.w / 2, y: from.y + from.h / 2 };
|
|
159
|
+
const fan = fanOrdinal.value.get(e.id) ?? { index: 0, count: 1 };
|
|
160
|
+
const a = boardAnchor(from, e.anchorFrom ?? "center", toCenter, fan.index, fan.count);
|
|
161
|
+
const b = boardAnchor(to, e.anchorTo ?? "center", fromCenter, fan.index, fan.count);
|
|
162
|
+
const viaCenters = (e.via ?? [])
|
|
163
|
+
.map((id) => rects.get(id))
|
|
164
|
+
.filter((r): r is BoardRect => Boolean(r))
|
|
165
|
+
.map((r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }));
|
|
166
|
+
out.push({
|
|
167
|
+
id: e.id,
|
|
168
|
+
d: boardViaPath(a, viaCenters, b, e.style ?? "orthogonal"),
|
|
169
|
+
ink: e.ink ?? DEFAULT_INK,
|
|
170
|
+
dashed: Boolean(e.dashed),
|
|
171
|
+
width: e.width ?? 1.5,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const contentBounds = computed(() => content.value);
|
|
178
|
+
|
|
179
|
+
const worldStyle = computed(() => ({
|
|
180
|
+
transform: `translate(${camera.value.x}px, ${camera.value.y}px) scale(${camera.value.k})`,
|
|
181
|
+
width: `${contentBounds.value.w}px`,
|
|
182
|
+
height: `${contentBounds.value.h}px`,
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
const gridStyle = computed(() => {
|
|
186
|
+
if (!props.grid) return { display: "none" };
|
|
187
|
+
const s = props.gridSize * camera.value.k;
|
|
188
|
+
return {
|
|
189
|
+
backgroundImage: "radial-gradient(rgb(var(--color-text) / 12%) 1px, transparent 1px)",
|
|
190
|
+
backgroundSize: `${s}px ${s}px`,
|
|
191
|
+
backgroundPosition: `${camera.value.x}px ${camera.value.y}px`,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const minimapBoxes = computed<MinimapBox[]>(() =>
|
|
196
|
+
props.nodes
|
|
197
|
+
.filter((n) => !n.hidden)
|
|
198
|
+
.map((n) => ({
|
|
199
|
+
id: n.id,
|
|
200
|
+
bounds: { x: n.x, y: n.y, w: n.w, h: n.h },
|
|
201
|
+
color: "rgb(var(--color-primary) / 40%)",
|
|
202
|
+
})),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// ── gestures ────────────────────────────────────────────────────────
|
|
206
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
207
|
+
let panState: { px: number; py: number } | null = null;
|
|
208
|
+
let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
|
|
209
|
+
let dragState: { node: BoardNodeInput; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
|
210
|
+
// Every node press is tracked (draggable or not) so read-only boards
|
|
211
|
+
// — SCADA scenes, mind maps — still get `nodeClick` on a short tap.
|
|
212
|
+
let pressed: { id: string; pointerId: number; x: number; y: number } | null = null;
|
|
213
|
+
|
|
214
|
+
const localPoint = (e: PointerEvent | WheelEvent): BoardPoint => {
|
|
215
|
+
const rect = viewportRef.value?.getBoundingClientRect();
|
|
216
|
+
return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function onWheel(e: WheelEvent): void {
|
|
220
|
+
if (!props.interactive) return;
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
const factor = e.deltaY < 0 ? 1.05 : 1 / 1.05;
|
|
223
|
+
cam.zoomByFactor(factor, localPoint(e));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function refreshSize(): void {
|
|
227
|
+
const el = viewportRef.value;
|
|
228
|
+
if (!el) return;
|
|
229
|
+
viewportSize.value = { w: el.clientWidth, h: el.clientHeight };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let resizeObs: ResizeObserver | null = null;
|
|
233
|
+
|
|
234
|
+
/** Snapshot the camera + current finger pair as the pinch baseline —
|
|
235
|
+
* at gesture start, or whenever the pair's composition changes — so
|
|
236
|
+
* the scale keeps following the fingers 1:1 without a jump. */
|
|
237
|
+
function rearmPinch(): void {
|
|
238
|
+
const [a, b] = [...pointers.values()];
|
|
239
|
+
if (!a || !b) return;
|
|
240
|
+
const rect = viewportRef.value?.getBoundingClientRect();
|
|
241
|
+
pinchBase = {
|
|
242
|
+
cam: { ...camera.value },
|
|
243
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
|
|
244
|
+
mid: {
|
|
245
|
+
x: (a.x + b.x) / 2 - (rect?.left ?? 0),
|
|
246
|
+
y: (a.y + b.y) / 2 - (rect?.top ?? 0),
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function onPointerDown(e: PointerEvent): void {
|
|
252
|
+
if (!props.interactive) return;
|
|
253
|
+
// Defensive prune: tracked pointers no gesture is using are stale
|
|
254
|
+
// (their up/cancel AND capture loss were both lost) — drop them so
|
|
255
|
+
// the next single-finger touch cannot become a phantom pinch.
|
|
256
|
+
if (pointers.size > 0 && !pinchBase && !panState && !dragState) pointers.clear();
|
|
257
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
258
|
+
// Capture EVERY finger: up/cancel are only guaranteed while capture
|
|
259
|
+
// holds, and an uncaptured pinch finger would never leave `pointers`.
|
|
260
|
+
viewportRef.value?.setPointerCapture(e.pointerId);
|
|
261
|
+
if (pointers.size === 1) {
|
|
262
|
+
panState = { px: e.clientX, py: e.clientY };
|
|
263
|
+
} else if (pointers.size === 2) {
|
|
264
|
+
// pinch begins: snapshot camera + finger geometry, suspend panning
|
|
265
|
+
rearmPinch();
|
|
266
|
+
panState = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function onPointerMove(e: PointerEvent): void {
|
|
271
|
+
if (!props.interactive) return;
|
|
272
|
+
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
273
|
+
if (pinchBase && pointers.size >= 2) {
|
|
274
|
+
const [a, b] = [...pointers.values()];
|
|
275
|
+
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
|
|
276
|
+
const rect = viewportRef.value?.getBoundingClientRect();
|
|
277
|
+
const mid = {
|
|
278
|
+
x: (a.x + b.x) / 2 - (rect?.left ?? 0),
|
|
279
|
+
y: (a.y + b.y) / 2 - (rect?.top ?? 0),
|
|
280
|
+
};
|
|
281
|
+
pinchBase.mid = mid;
|
|
282
|
+
cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (dragState) {
|
|
286
|
+
const k = camera.value.k || 1;
|
|
287
|
+
const dx = (e.clientX - dragState.sx) / k;
|
|
288
|
+
const dy = (e.clientY - dragState.sy) / k;
|
|
289
|
+
if (Math.abs(e.clientX - dragState.sx) + Math.abs(e.clientY - dragState.sy) > 3) dragState.moved = true;
|
|
290
|
+
dragState.node.x = Math.round(dragState.ox + dx);
|
|
291
|
+
dragState.node.y = Math.round(dragState.oy + dy);
|
|
292
|
+
emit("nodeMove", dragState.node, dragState.node.x, dragState.node.y);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (panState) {
|
|
296
|
+
cam.panBy(e.clientX - panState.px, e.clientY - panState.py);
|
|
297
|
+
panState = { px: e.clientX, py: e.clientY };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function onPointerUp(e: PointerEvent): void {
|
|
302
|
+
const press = pressed;
|
|
303
|
+
pressed = null;
|
|
304
|
+
dragState = null;
|
|
305
|
+
retirePointer(e.pointerId);
|
|
306
|
+
// nodeClick: a press that belongs to THIS pointer and stayed within
|
|
307
|
+
// the drag slop clicks — regardless of `draggable`.
|
|
308
|
+
if (press && press.pointerId === e.pointerId
|
|
309
|
+
&& Math.abs(e.clientX - press.x) + Math.abs(e.clientY - press.y) < 3) {
|
|
310
|
+
const node = props.nodes.find((n) => n.id === press.id);
|
|
311
|
+
if (node) emit("nodeClick", node);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function onPointerCancel(e: PointerEvent): void {
|
|
316
|
+
// An aborted gesture must never synthesize a click: prune state only.
|
|
317
|
+
pressed = null;
|
|
318
|
+
dragState = null;
|
|
319
|
+
retirePointer(e.pointerId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A pointer left the gesture (up, cancel, or capture loss): prune it,
|
|
323
|
+
* re-baseline or settle the pinch, and hand panning to any surviving
|
|
324
|
+
* pointer so a pinch that ends with one finger down keeps panning. */
|
|
325
|
+
function retirePointer(pointerId: number): void {
|
|
326
|
+
if (!pointers.delete(pointerId)) return;
|
|
327
|
+
if (pinchBase && pointers.size >= 2) {
|
|
328
|
+
// The pinch pair composition changed (a finger lifted while two
|
|
329
|
+
// or more remain): re-baseline onto the surviving pair.
|
|
330
|
+
rearmPinch();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (pinchBase) {
|
|
334
|
+
const mid = pinchBase.mid;
|
|
335
|
+
pinchBase = null;
|
|
336
|
+
cam.settlePinch(mid);
|
|
337
|
+
}
|
|
338
|
+
const rest = pointers.values().next().value;
|
|
339
|
+
panState = rest ? { px: rest.x, py: rest.y } : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Capture-loss backstop: up/cancel are only guaranteed while pointer
|
|
343
|
+
* capture holds; a pointer that was released without an up is pruned
|
|
344
|
+
* here — otherwise the next single-finger touch would silently become
|
|
345
|
+
* a phantom pinch. The event fires on the capture target and does not
|
|
346
|
+
* bubble, hence the capture-phase listener; after a normal up it is a
|
|
347
|
+
* no-op (the pointer was already pruned there). */
|
|
348
|
+
function onLostPointerCapture(e: PointerEvent): void {
|
|
349
|
+
pressed = null;
|
|
350
|
+
dragState = null;
|
|
351
|
+
retirePointer(e.pointerId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function onNodePointerDown(e: PointerEvent, node: BoardNodeInput): void {
|
|
355
|
+
pressed = { id: node.id, pointerId: e.pointerId, x: e.clientX, y: e.clientY };
|
|
356
|
+
// Draggable nodes own the gesture (no board pan); every other press
|
|
357
|
+
// bubbles on so the board can pan — and still click on a short tap.
|
|
358
|
+
if (!props.interactive || !node.draggable) return;
|
|
359
|
+
e.stopPropagation();
|
|
360
|
+
dragState = { node, sx: e.clientX, sy: e.clientY, ox: node.x, oy: node.y, moved: false };
|
|
361
|
+
viewportRef.value?.setPointerCapture(e.pointerId);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
watch(() => camera.value, (c) => emit("viewportChange", { ...c }), { deep: true });
|
|
365
|
+
watch(toRef(props, "nodes"), () => {
|
|
366
|
+
// content growth invalidates clamping — re-clamp against the new bbox
|
|
367
|
+
cam.setCamera({ ...camera.value });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
onMounted(() => {
|
|
371
|
+
refreshSize();
|
|
372
|
+
resizeObs = new ResizeObserver(refreshSize);
|
|
373
|
+
if (viewportRef.value) resizeObs.observe(viewportRef.value);
|
|
374
|
+
viewportRef.value?.addEventListener("wheel", onWheel, { passive: false });
|
|
375
|
+
// Capture phase: lostpointercapture does not bubble and fires on the
|
|
376
|
+
// element that held capture.
|
|
377
|
+
viewportRef.value?.addEventListener("lostpointercapture", onLostPointerCapture, true);
|
|
378
|
+
if (props.fitOnMount) cam.fit();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
onBeforeUnmount(() => {
|
|
382
|
+
cam.stop(); // kill any in-flight tween rAF
|
|
383
|
+
resizeObs?.disconnect();
|
|
384
|
+
viewportRef.value?.removeEventListener("wheel", onWheel);
|
|
385
|
+
viewportRef.value?.removeEventListener("lostpointercapture", onLostPointerCapture, true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expose({
|
|
389
|
+
fit: () => cam.fit(),
|
|
390
|
+
zoomIn: () => cam.zoomIn(),
|
|
391
|
+
zoomOut: () => cam.zoomOut(),
|
|
392
|
+
zoomToPercent: (p: number, anchor?: BoardPoint) => cam.zoomToPercent(p, anchor),
|
|
393
|
+
reset: () => cam.fit(),
|
|
394
|
+
camera,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return () => (
|
|
398
|
+
<div
|
|
399
|
+
class="hk-board"
|
|
400
|
+
ref={viewportRef}
|
|
401
|
+
onPointerdown={onPointerDown}
|
|
402
|
+
onPointermove={onPointerMove}
|
|
403
|
+
onPointerup={onPointerUp}
|
|
404
|
+
onPointercancel={onPointerCancel}
|
|
405
|
+
>
|
|
406
|
+
<div class="hk-board-grid" style={gridStyle.value} />
|
|
407
|
+
<div class="hk-board-world" style={worldStyle.value}>
|
|
408
|
+
<svg class="hk-board-edges" width={contentBounds.value.w} height={contentBounds.value.h}>
|
|
409
|
+
{edgePaths.value.map((e) => (
|
|
410
|
+
<path
|
|
411
|
+
key={e.id}
|
|
412
|
+
class="hk-board-edge"
|
|
413
|
+
d={e.d}
|
|
414
|
+
stroke={e.ink}
|
|
415
|
+
stroke-width={e.width}
|
|
416
|
+
stroke-dasharray={e.dashed ? "6 5" : undefined}
|
|
417
|
+
fill="none"
|
|
418
|
+
stroke-linecap="round"
|
|
419
|
+
/>
|
|
420
|
+
))}
|
|
421
|
+
</svg>
|
|
422
|
+
{props.nodes.filter((n) => !n.hidden).map((n) => (
|
|
423
|
+
<div
|
|
424
|
+
key={n.id}
|
|
425
|
+
class={[
|
|
426
|
+
"hk-board-node",
|
|
427
|
+
{ "hk-board-node--draggable": Boolean(n.draggable) },
|
|
428
|
+
n.kind ? `hk-board-node--${n.kind}` : undefined,
|
|
429
|
+
]}
|
|
430
|
+
style={{ left: `${n.x}px`, top: `${n.y}px`, width: `${n.w}px`, height: `${n.h}px` }}
|
|
431
|
+
onPointerdown={(e: PointerEvent) => onNodePointerDown(e, n)}
|
|
432
|
+
>
|
|
433
|
+
{slots.node
|
|
434
|
+
? slots.node({ node: n })
|
|
435
|
+
: <div class="hk-board-node-shell">{n.label ?? ""}</div>}
|
|
436
|
+
</div>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
{props.minimap && (
|
|
440
|
+
<div class="hk-board-minimap">
|
|
441
|
+
<HkMinimap
|
|
442
|
+
boxes={minimapBoxes.value}
|
|
443
|
+
zoom={camera.value.k}
|
|
444
|
+
panX={camera.value.x}
|
|
445
|
+
panY={camera.value.y}
|
|
446
|
+
viewportWidth={viewportSize.value.w}
|
|
447
|
+
viewportHeight={viewportSize.value.h}
|
|
448
|
+
contentBounds={contentBounds.value}
|
|
449
|
+
zoomPercent={Math.round(camera.value.k * 100)}
|
|
450
|
+
canZoomIn={camera.value.k < props.maxK}
|
|
451
|
+
canZoomOut={camera.value.k > props.minK}
|
|
452
|
+
zoomStepPercent={5}
|
|
453
|
+
minZoomPercent={Math.round(props.minK * 100)}
|
|
454
|
+
maxZoomPercent={Math.round(props.maxK * 100)}
|
|
455
|
+
showReset
|
|
456
|
+
onZoomTo={(percent: number) => cam.zoomToK(boardStepRung(camera.value.k, percent / 100))}
|
|
457
|
+
onReset={() => cam.fit()}
|
|
458
|
+
onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
|
|
459
|
+
/>
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
</div>
|
|
463
|
+
);
|
|
464
|
+
},
|
|
465
|
+
});
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
cursor: pointer;
|
|
12
12
|
overflow: hidden;
|
|
13
13
|
pointer-events: auto;
|
|
14
|
+
/* The minimap owns its own drag gestures — the surrounding pannable
|
|
15
|
+
surface must never claim them (mobile browsers would otherwise fire
|
|
16
|
+
pointercancel and kill the drag). */
|
|
17
|
+
touch-action: none;
|
|
14
18
|
|
|
15
19
|
&:hover {
|
|
16
20
|
border-color: rgb(var(--color-primary) / 0.3);
|