@celestia-island/hikari 0.35.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.tsx +101 -32
- package/src/components/HkIconButton.scss +0 -1
- package/src/components/HkMinimap.scss +4 -0
- package/src/composables/useBoardCamera.ts +28 -11
- 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 +55 -0
- package/src/utils/boardCamera.ts +28 -7
- package/src/utils/boardEdges.test.ts +34 -0
- package/src/utils/boardEdges.ts +23 -0
package/package.json
CHANGED
|
@@ -44,6 +44,7 @@ import { useBoardCamera } from "../composables/useBoardCamera";
|
|
|
44
44
|
import HkMinimap, { type MinimapBox } from "./HkMinimap";
|
|
45
45
|
import {
|
|
46
46
|
boardBBox,
|
|
47
|
+
boardStepRung,
|
|
47
48
|
type BoardCamera,
|
|
48
49
|
type BoardPoint,
|
|
49
50
|
type BoardRect,
|
|
@@ -52,13 +53,19 @@ import {
|
|
|
52
53
|
import {
|
|
53
54
|
boardAnchor,
|
|
54
55
|
boardEdgePath,
|
|
56
|
+
boardFanOrdinals,
|
|
55
57
|
boardViaPath,
|
|
56
58
|
type BoardAnchorMode,
|
|
57
59
|
type BoardEdgeStyle,
|
|
58
60
|
} from "../utils/boardEdges";
|
|
59
61
|
import "./HkBoard.scss";
|
|
60
62
|
|
|
61
|
-
/**
|
|
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
|
+
*/
|
|
62
69
|
export interface BoardNodeInput {
|
|
63
70
|
id: string;
|
|
64
71
|
x: number;
|
|
@@ -138,16 +145,7 @@ export default defineComponent({
|
|
|
138
145
|
});
|
|
139
146
|
|
|
140
147
|
/** Edges sharing a `from` node fan across its border (天女散花). */
|
|
141
|
-
const fanOrdinal = computed(() =>
|
|
142
|
-
const counters = new Map<string, number>();
|
|
143
|
-
const ordinals = new Map<string, { index: number; count: number }>();
|
|
144
|
-
for (const e of props.edges) {
|
|
145
|
-
const next = (counters.get(e.from) ?? 0) + 1;
|
|
146
|
-
counters.set(e.from, next);
|
|
147
|
-
ordinals.set(e.id, { index: next - 1, count: counters.get(e.from)! });
|
|
148
|
-
}
|
|
149
|
-
return ordinals;
|
|
150
|
-
});
|
|
148
|
+
const fanOrdinal = computed(() => boardFanOrdinals(props.edges));
|
|
151
149
|
|
|
152
150
|
const edgePaths = computed(() => {
|
|
153
151
|
const rects = rectOf.value;
|
|
@@ -209,6 +207,9 @@ export default defineComponent({
|
|
|
209
207
|
let panState: { px: number; py: number } | null = null;
|
|
210
208
|
let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
|
|
211
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;
|
|
212
213
|
|
|
213
214
|
const localPoint = (e: PointerEvent | WheelEvent): BoardPoint => {
|
|
214
215
|
const rect = viewportRef.value?.getBoundingClientRect();
|
|
@@ -230,23 +231,39 @@ export default defineComponent({
|
|
|
230
231
|
|
|
231
232
|
let resizeObs: ResizeObserver | null = null;
|
|
232
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
|
+
|
|
233
251
|
function onPointerDown(e: PointerEvent): void {
|
|
234
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();
|
|
235
257
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
pinchBase = {
|
|
240
|
-
cam: { ...camera.value },
|
|
241
|
-
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
|
|
242
|
-
mid: localPoint(e),
|
|
243
|
-
};
|
|
244
|
-
panState = null;
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
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);
|
|
247
261
|
if (pointers.size === 1) {
|
|
248
262
|
panState = { px: e.clientX, py: e.clientY };
|
|
249
|
-
|
|
263
|
+
} else if (pointers.size === 2) {
|
|
264
|
+
// pinch begins: snapshot camera + finger geometry, suspend panning
|
|
265
|
+
rearmPinch();
|
|
266
|
+
panState = null;
|
|
250
267
|
}
|
|
251
268
|
}
|
|
252
269
|
|
|
@@ -256,10 +273,12 @@ export default defineComponent({
|
|
|
256
273
|
if (pinchBase && pointers.size >= 2) {
|
|
257
274
|
const [a, b] = [...pointers.values()];
|
|
258
275
|
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
|
|
276
|
+
const rect = viewportRef.value?.getBoundingClientRect();
|
|
259
277
|
const mid = {
|
|
260
|
-
x: (a.x + b.x) / 2 - (
|
|
261
|
-
y: (a.y + b.y) / 2 - (
|
|
278
|
+
x: (a.x + b.x) / 2 - (rect?.left ?? 0),
|
|
279
|
+
y: (a.y + b.y) / 2 - (rect?.top ?? 0),
|
|
262
280
|
};
|
|
281
|
+
pinchBase.mid = mid;
|
|
263
282
|
cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
|
|
264
283
|
return;
|
|
265
284
|
}
|
|
@@ -280,17 +299,62 @@ export default defineComponent({
|
|
|
280
299
|
}
|
|
281
300
|
|
|
282
301
|
function onPointerUp(e: PointerEvent): void {
|
|
283
|
-
|
|
284
|
-
|
|
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;
|
|
285
335
|
pinchBase = null;
|
|
286
|
-
cam.settlePinch();
|
|
336
|
+
cam.settlePinch(mid);
|
|
287
337
|
}
|
|
288
|
-
|
|
289
|
-
|
|
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;
|
|
290
350
|
dragState = null;
|
|
351
|
+
retirePointer(e.pointerId);
|
|
291
352
|
}
|
|
292
353
|
|
|
293
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.
|
|
294
358
|
if (!props.interactive || !node.draggable) return;
|
|
295
359
|
e.stopPropagation();
|
|
296
360
|
dragState = { node, sx: e.clientX, sy: e.clientY, ox: node.x, oy: node.y, moved: false };
|
|
@@ -308,12 +372,17 @@ export default defineComponent({
|
|
|
308
372
|
resizeObs = new ResizeObserver(refreshSize);
|
|
309
373
|
if (viewportRef.value) resizeObs.observe(viewportRef.value);
|
|
310
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);
|
|
311
378
|
if (props.fitOnMount) cam.fit();
|
|
312
379
|
});
|
|
313
380
|
|
|
314
381
|
onBeforeUnmount(() => {
|
|
382
|
+
cam.stop(); // kill any in-flight tween rAF
|
|
315
383
|
resizeObs?.disconnect();
|
|
316
384
|
viewportRef.value?.removeEventListener("wheel", onWheel);
|
|
385
|
+
viewportRef.value?.removeEventListener("lostpointercapture", onLostPointerCapture, true);
|
|
317
386
|
});
|
|
318
387
|
|
|
319
388
|
expose({
|
|
@@ -332,7 +401,7 @@ export default defineComponent({
|
|
|
332
401
|
onPointerdown={onPointerDown}
|
|
333
402
|
onPointermove={onPointerMove}
|
|
334
403
|
onPointerup={onPointerUp}
|
|
335
|
-
onPointercancel={
|
|
404
|
+
onPointercancel={onPointerCancel}
|
|
336
405
|
>
|
|
337
406
|
<div class="hk-board-grid" style={gridStyle.value} />
|
|
338
407
|
<div class="hk-board-world" style={worldStyle.value}>
|
|
@@ -384,7 +453,7 @@ export default defineComponent({
|
|
|
384
453
|
minZoomPercent={Math.round(props.minK * 100)}
|
|
385
454
|
maxZoomPercent={Math.round(props.maxK * 100)}
|
|
386
455
|
showReset
|
|
387
|
-
onZoomTo={(percent: number) => cam.
|
|
456
|
+
onZoomTo={(percent: number) => cam.zoomToK(boardStepRung(camera.value.k, percent / 100))}
|
|
388
457
|
onReset={() => cam.fit()}
|
|
389
458
|
onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
|
|
390
459
|
/>
|
|
@@ -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);
|
|
@@ -63,12 +63,15 @@ export interface UseBoardCameraReturn {
|
|
|
63
63
|
zoomOut: () => void;
|
|
64
64
|
/** Raw (unquantized, unanimated) pinch zoom — call per pointermove. */
|
|
65
65
|
pinchZoom: (base: BoardCamera, ratio: number, anchor: BoardPoint) => void;
|
|
66
|
-
/** Snap the post-pinch camera onto the nearest rung (animated)
|
|
67
|
-
|
|
66
|
+
/** Snap the post-pinch camera onto the nearest rung (animated), keeping
|
|
67
|
+
* the world point under `anchor` (the pinch midpoint) fixed. */
|
|
68
|
+
settlePinch: (anchor?: BoardPoint) => void;
|
|
68
69
|
panBy: (dx: number, dy: number) => void;
|
|
69
70
|
setCamera: (cam: BoardCamera, opts?: { animate?: boolean }) => void;
|
|
70
71
|
fit: () => void;
|
|
71
72
|
reset: () => void;
|
|
73
|
+
/** Cancel any in-flight tween and its pending rAF (call on unmount). */
|
|
74
|
+
stop: () => void;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraReturn {
|
|
@@ -83,6 +86,12 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
83
86
|
const camera = ref<BoardCamera>({ x: 0, y: 0, k: 1 });
|
|
84
87
|
const isAnimating = ref(false);
|
|
85
88
|
|
|
89
|
+
// fit() may park the camera BELOW the interaction minimum (show the
|
|
90
|
+
// whole board wins over the zoom ladder, as in HkImageViewer) — the
|
|
91
|
+
// clamp floor follows it down so the fitted framing survives; any
|
|
92
|
+
// quantized gesture lands back on the ladder at ≥ minK.
|
|
93
|
+
let kFloor = minK;
|
|
94
|
+
|
|
86
95
|
let raf = 0;
|
|
87
96
|
let tweenFrom: BoardCamera = { x: 0, y: 0, k: 1 };
|
|
88
97
|
let tweenTo: BoardCamera = { x: 0, y: 0, k: 1 };
|
|
@@ -100,7 +109,7 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
100
109
|
const contentRect = (): BoardRect => toValue(content);
|
|
101
110
|
|
|
102
111
|
const clampCam = (cam: BoardCamera): BoardCamera => {
|
|
103
|
-
const k = Math.min(maxK, Math.max(
|
|
112
|
+
const k = Math.min(maxK, Math.max(kFloor, cam.k));
|
|
104
113
|
return boardClampPan({ ...cam, k }, contentRect(), viewport());
|
|
105
114
|
};
|
|
106
115
|
|
|
@@ -135,13 +144,16 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
135
144
|
};
|
|
136
145
|
|
|
137
146
|
function zoomByFactor(factor: number, anchor?: BoardPoint): void {
|
|
138
|
-
|
|
139
|
-
|
|
147
|
+
// Quantize FIRST, then solve the anchored pan for the QUANTIZED k —
|
|
148
|
+
// anchoring on the raw k and swapping in the quantized one afterwards
|
|
149
|
+
// lets the anchored world point jump by the raw/quantized ratio.
|
|
150
|
+
const k = quantizeBoardK(camera.value.k * factor);
|
|
151
|
+
animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
function zoomToK(targetK: number, anchor?: BoardPoint): void {
|
|
143
|
-
const
|
|
144
|
-
animateTo(
|
|
155
|
+
const k = quantizeBoardK(targetK);
|
|
156
|
+
animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
function zoomToPercent(percent: number, anchor?: BoardPoint): void {
|
|
@@ -162,9 +174,11 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
162
174
|
camera.value = clampCam(boardZoomAt(base, base.k * ratio, anchor));
|
|
163
175
|
}
|
|
164
176
|
|
|
165
|
-
function settlePinch(): void {
|
|
166
|
-
|
|
167
|
-
|
|
177
|
+
function settlePinch(anchor?: BoardPoint): void {
|
|
178
|
+
// Same quantize-then-anchor discipline as the wheel path: the pinch
|
|
179
|
+
// midpoint stays pinned to its world point while k snaps onto the rung.
|
|
180
|
+
const k = quantizeBoardK(camera.value.k);
|
|
181
|
+
animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
function panBy(dx: number, dy: number): void {
|
|
@@ -181,7 +195,9 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
181
195
|
}
|
|
182
196
|
|
|
183
197
|
function fit(): void {
|
|
184
|
-
|
|
198
|
+
const target = boardFit(contentRect(), viewport());
|
|
199
|
+
kFloor = Math.min(minK, target.k);
|
|
200
|
+
animateTo(target);
|
|
185
201
|
}
|
|
186
202
|
|
|
187
203
|
function reset(): void {
|
|
@@ -214,5 +230,6 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
214
230
|
setCamera,
|
|
215
231
|
fit,
|
|
216
232
|
reset,
|
|
233
|
+
stop: stopTween,
|
|
217
234
|
};
|
|
218
235
|
}
|
|
@@ -343,14 +343,19 @@ describe("late registration", () => {
|
|
|
343
343
|
setMode("dark");
|
|
344
344
|
initTheme();
|
|
345
345
|
const el = document.documentElement;
|
|
346
|
+
// Theme vars live in the managed :root block (lean injection), so the
|
|
347
|
+
// html inline style stays clean until the late group lands.
|
|
348
|
+
const block = () => document.head.querySelector("style[data-hikari-theme-vars]")?.textContent ?? "";
|
|
346
349
|
expect(el.style.getPropertyValue("--late-wires-a")).toBe("");
|
|
350
|
+
expect(block()).not.toContain("--late-wires-a");
|
|
347
351
|
|
|
348
352
|
registerTokenGroup(LATE_GROUP);
|
|
349
353
|
await flushMicrotasks();
|
|
350
354
|
|
|
351
355
|
// The injected hook re-applied the current dark theme, so the late
|
|
352
|
-
// group's registry defaults are already
|
|
353
|
-
expect(
|
|
356
|
+
// group's registry defaults are already in the managed block.
|
|
357
|
+
expect(block()).toContain("--late-wires-a:1 2 3");
|
|
358
|
+
expect(el.style.getPropertyValue("--late-wires-a")).toBe("");
|
|
354
359
|
});
|
|
355
360
|
|
|
356
361
|
it("coalesces same-tick registrations into a single re-apply", async () => {
|
|
@@ -70,3 +70,70 @@ describe("useTheme theme clock", () => {
|
|
|
70
70
|
expect(theme.useTheme().geo.value).toEqual({ lat: 1, lng: 2 });
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
describe("useTheme lean cssvar injection", () => {
|
|
75
|
+
let theme: ThemeModule;
|
|
76
|
+
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
vi.resetModules();
|
|
79
|
+
vi.unstubAllGlobals();
|
|
80
|
+
localStorage.clear();
|
|
81
|
+
document.documentElement.style.cssText = "";
|
|
82
|
+
document.documentElement.removeAttribute("data-theme");
|
|
83
|
+
document.documentElement.removeAttribute("data-mode");
|
|
84
|
+
document.head.querySelectorAll("style[data-hikari-theme-vars]").forEach((el) => el.remove());
|
|
85
|
+
vi.stubGlobal("fetch", vi.fn(async () => {
|
|
86
|
+
throw new Error("offline");
|
|
87
|
+
}));
|
|
88
|
+
theme = await import("./useTheme");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
theme.stopThemeClock();
|
|
93
|
+
vi.unstubAllGlobals();
|
|
94
|
+
vi.restoreAllMocks();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("pickThemeVarDeltas keeps only values differing from the static cascade", () => {
|
|
98
|
+
const vars = {
|
|
99
|
+
"--a": "1 2 3",
|
|
100
|
+
"--b": "4 5 6",
|
|
101
|
+
"--c": "7 8 9",
|
|
102
|
+
"--d": " 10 11 12 ",
|
|
103
|
+
};
|
|
104
|
+
const baseline = {
|
|
105
|
+
"--a": "1 2 3",
|
|
106
|
+
"--b": "4 5 6",
|
|
107
|
+
"--c": "",
|
|
108
|
+
"--d": "10 11 12",
|
|
109
|
+
};
|
|
110
|
+
// Whitespace-only differences collapse (normalization), so only the
|
|
111
|
+
// genuinely absent/divergent values are injected.
|
|
112
|
+
expect(theme.pickThemeVarDeltas(vars, baseline)).toEqual({
|
|
113
|
+
"--c": "7 8 9",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("writes theme vars into a managed :root style block, never inline", () => {
|
|
118
|
+
theme.initTheme();
|
|
119
|
+
const styleEl = document.head.querySelector("style[data-hikari-theme-vars]");
|
|
120
|
+
expect(styleEl).not.toBeNull();
|
|
121
|
+
// Real browsers compute the static defaults, so the block holds only the
|
|
122
|
+
// true deltas; either way it is a :root block, not an inline attribute.
|
|
123
|
+
expect(styleEl!.textContent).toMatch(/^:root\{/);
|
|
124
|
+
expect(styleEl!.textContent).toContain("--color-primary");
|
|
125
|
+
// The html inline style attribute stays clean — no token vars on it.
|
|
126
|
+
expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("");
|
|
127
|
+
// The epoch attributes that consumers watch are still written.
|
|
128
|
+
expect(document.documentElement.getAttribute("data-theme")).toBeTruthy();
|
|
129
|
+
expect(document.documentElement.getAttribute("data-mode")).toBeTruthy();
|
|
130
|
+
// Swapping theme keeps ONE managed block (no duplicates per apply) and
|
|
131
|
+
// actually re-applies: the block content changes with the preset.
|
|
132
|
+
const before = document.head.querySelector("style[data-hikari-theme-vars]")!.textContent;
|
|
133
|
+
theme.useTheme().setTheme("nord");
|
|
134
|
+
const blocks = document.head.querySelectorAll("style[data-hikari-theme-vars]");
|
|
135
|
+
expect(blocks).toHaveLength(1);
|
|
136
|
+
expect(blocks[0].textContent).toMatch(/^:root\{/);
|
|
137
|
+
expect(blocks[0].textContent).not.toBe(before);
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/theme/useTheme.ts
CHANGED
|
@@ -148,6 +148,76 @@ function resolveEffectiveMode(mode: ThemeMode): "dark" | "light" {
|
|
|
148
148
|
const THEME_TRANSITION_DURATION = 300;
|
|
149
149
|
let transitionTimer: CronHandle | null = null;
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Theme vars live in ONE managed stylesheet block (`:root` overrides)
|
|
153
|
+
* instead of a hundred inline declarations on `<html style="...">`.
|
|
154
|
+
* - Inline style attributes bloat the DOM, show up as giant devtools
|
|
155
|
+
* noise on the root element, defeat caching of the token set and make
|
|
156
|
+
* every style recalc parse the whole attribute again.
|
|
157
|
+
* - A stylesheet block is a single atomic replacement (one recalc for
|
|
158
|
+
* the whole theme apply) and inspectable/serializable.
|
|
159
|
+
* - Only the DELTAS are written: hikari's static stylesheet already
|
|
160
|
+
* seeds :root with the full default token set (including pure
|
|
161
|
+
* derivations like `--hi-color-primary: rgb(var(--color-primary))`),
|
|
162
|
+
* so a token whose value already resolves identically needs no
|
|
163
|
+
* override at all — a default-ish theme injects a handful of vars
|
|
164
|
+
* instead of ~80.
|
|
165
|
+
*/
|
|
166
|
+
const THEME_VARS_STYLE_ATTR = "data-hikari-theme-vars";
|
|
167
|
+
|
|
168
|
+
function normalizeVarValue(value: string): string {
|
|
169
|
+
return value.replace(/\s+/g, " ").trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Keep only the vars whose value differs from the static cascade. */
|
|
173
|
+
export function pickThemeVarDeltas(
|
|
174
|
+
vars: Record<string, string>,
|
|
175
|
+
baseline: Record<string, string>,
|
|
176
|
+
): Record<string, string> {
|
|
177
|
+
const deltas: Record<string, string> = {};
|
|
178
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
179
|
+
const normalized = normalizeVarValue(value);
|
|
180
|
+
if (normalizeVarValue(baseline[key] ?? "") !== normalized) {
|
|
181
|
+
deltas[key] = normalized;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return deltas;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function themeVarsStyleElement(): HTMLStyleElement {
|
|
188
|
+
let styleEl = document.head.querySelector<HTMLStyleElement>(
|
|
189
|
+
`style[${THEME_VARS_STYLE_ATTR}]`,
|
|
190
|
+
);
|
|
191
|
+
if (!styleEl) {
|
|
192
|
+
styleEl = document.createElement("style");
|
|
193
|
+
styleEl.setAttribute(THEME_VARS_STYLE_ATTR, "");
|
|
194
|
+
document.head.appendChild(styleEl);
|
|
195
|
+
}
|
|
196
|
+
return styleEl;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function injectThemeVars(el: HTMLElement, vars: Record<string, string>): void {
|
|
200
|
+
const styleEl = themeVarsStyleElement();
|
|
201
|
+
// Measure the cascade WITHOUT our own previous overrides: disable the
|
|
202
|
+
// managed block, read every candidate, re-enable. One recalc serves
|
|
203
|
+
// all reads, and applyTheme is a rare switch-time action.
|
|
204
|
+
const baseline: Record<string, string> = {};
|
|
205
|
+
try {
|
|
206
|
+
styleEl.disabled = true;
|
|
207
|
+
const computed = getComputedStyle(el);
|
|
208
|
+
for (const key of Object.keys(vars)) {
|
|
209
|
+
baseline[key] = computed.getPropertyValue(key);
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
styleEl.disabled = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const deltas = pickThemeVarDeltas(vars, baseline);
|
|
216
|
+
styleEl.textContent = Object.keys(deltas).length > 0
|
|
217
|
+
? `:root{${Object.entries(deltas).map(([key, value]) => `${key}:${value}`).join(";")}}`
|
|
218
|
+
: "";
|
|
219
|
+
}
|
|
220
|
+
|
|
151
221
|
function applyTheme() {
|
|
152
222
|
const el = document.documentElement;
|
|
153
223
|
const all = getAllThemePresets();
|
|
@@ -168,9 +238,7 @@ function applyTheme() {
|
|
|
168
238
|
|
|
169
239
|
invalidateLuminanceCache();
|
|
170
240
|
|
|
171
|
-
|
|
172
|
-
el.style.setProperty(key, value);
|
|
173
|
-
}
|
|
241
|
+
injectThemeVars(el, vars);
|
|
174
242
|
|
|
175
243
|
el.setAttribute("data-theme", currentTheme.value);
|
|
176
244
|
el.setAttribute("data-mode", effectiveMode);
|
|
@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
BOARD_FIT_CAP,
|
|
5
|
+
BOARD_K_MAX,
|
|
5
6
|
BOARD_ZOOM_FACTOR,
|
|
6
7
|
boardBBox,
|
|
7
8
|
boardClampPan,
|
|
8
9
|
boardEaseOutCubic,
|
|
9
10
|
boardFit,
|
|
10
11
|
boardLevelOf,
|
|
12
|
+
boardStepRung,
|
|
11
13
|
boardToScreen,
|
|
12
14
|
boardTweenCam,
|
|
13
15
|
boardToWorld,
|
|
@@ -51,6 +53,20 @@ describe("boardCamera — anchor zoom", () => {
|
|
|
51
53
|
expect(zoomed.k).toBe(1.5);
|
|
52
54
|
});
|
|
53
55
|
|
|
56
|
+
it("keeps the anchor world point fixed when zooming to a QUANTIZED rung", () => {
|
|
57
|
+
// The settle path (wheel notch / pinch release) must quantize k FIRST
|
|
58
|
+
// and then solve the anchored pan for the quantized k — anchoring on
|
|
59
|
+
// the raw k and swapping the rung in afterwards jumps the world point.
|
|
60
|
+
const cam = { x: 40, y: -20, k: 2.3 };
|
|
61
|
+
const anchor = { x: 300, y: 200 };
|
|
62
|
+
const worldBefore = boardToWorld(cam, anchor.x, anchor.y);
|
|
63
|
+
const settled = boardZoomAt(cam, quantizeBoardK(cam.k), anchor);
|
|
64
|
+
const worldAfter = boardToWorld(settled, anchor.x, anchor.y);
|
|
65
|
+
expect(worldAfter.x).toBeCloseTo(worldBefore.x, 6);
|
|
66
|
+
expect(worldAfter.y).toBeCloseTo(worldBefore.y, 6);
|
|
67
|
+
expect(settled.k).toBe(quantizeBoardK(cam.k));
|
|
68
|
+
});
|
|
69
|
+
|
|
54
70
|
it("round-trips screen→world→screen", () => {
|
|
55
71
|
const cam = { x: -130, y: 88, k: 1.3 };
|
|
56
72
|
const p = boardToScreen(cam, 42, -7);
|
|
@@ -83,6 +99,45 @@ describe("boardCamera — pan clamp + fit", () => {
|
|
|
83
99
|
const tiny = boardFit({ x: 0, y: 0, w: 60, h: 40 }, viewport);
|
|
84
100
|
expect(tiny.k).toBeLessThanOrEqual(BOARD_FIT_CAP);
|
|
85
101
|
});
|
|
102
|
+
|
|
103
|
+
it("shrinks below the interaction minimum to fit very wide boards", () => {
|
|
104
|
+
// 8000 world px in an 800 px viewport: fit k = 720/8000 = 0.09, far
|
|
105
|
+
// below the old 0.2 floor that cropped such boards on open.
|
|
106
|
+
const wide = { x: 0, y: 0, w: 8000, h: 600 };
|
|
107
|
+
const cam = boardFit(wide, viewport);
|
|
108
|
+
expect(cam.k).toBeCloseTo((800 - 80) / 8000, 6);
|
|
109
|
+
expect(cam.k).toBeLessThan(0.2);
|
|
110
|
+
// the whole board lands inside the viewport
|
|
111
|
+
const tl = boardToScreen(cam, 0, 0);
|
|
112
|
+
const br = boardToScreen(cam, 8000, 600);
|
|
113
|
+
expect(tl.x).toBeGreaterThanOrEqual(0);
|
|
114
|
+
expect(br.x).toBeLessThanOrEqual(viewport.w);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("boardCamera — minimap step rungs", () => {
|
|
119
|
+
const lastRung = BOARD_ZOOM_FACTOR ** 28; // ≈3.92 — last rung below the cap
|
|
120
|
+
|
|
121
|
+
it("pushes a linear +5% step that nearest-rounds onto the current rung", () => {
|
|
122
|
+
// from 392%, the + button targets 397% — nearest rounding falls back
|
|
123
|
+
// onto rung 28 (the dead band), so the step resolves one rung further
|
|
124
|
+
// and clamps to the cap.
|
|
125
|
+
expect(quantizeBoardK(3.97)).toBeCloseTo(lastRung, 5);
|
|
126
|
+
expect(boardStepRung(lastRung, 3.97)).toBe(BOARD_K_MAX);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("pushes a linear −5% step onto the rung below, not the current one", () => {
|
|
130
|
+
expect(boardStepRung(lastRung, 3.87)).toBeCloseTo(BOARD_ZOOM_FACTOR ** 27, 5);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves steps that already resolve to a different rung untouched", () => {
|
|
134
|
+
expect(boardStepRung(1, 1.05)).toBeCloseTo(BOARD_ZOOM_FACTOR, 5);
|
|
135
|
+
expect(boardStepRung(1, 0.95)).toBeCloseTo(BOARD_ZOOM_FACTOR ** -1, 5);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("steps from the ladder floor up onto the nearest live rung", () => {
|
|
139
|
+
expect(boardStepRung(0.2, 0.25)).toBeCloseTo(BOARD_ZOOM_FACTOR ** -28, 5);
|
|
140
|
+
});
|
|
86
141
|
});
|
|
87
142
|
|
|
88
143
|
describe("boardCamera — animated tween", () => {
|
package/src/utils/boardCamera.ts
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* - pan is CLAMPED so the content bbox can never be pushed fully out of
|
|
15
15
|
* view (half a viewport of slack on each side); content smaller than
|
|
16
16
|
* the viewport re-centers;
|
|
17
|
-
* - `boardFit` fits the whole bbox (pad +
|
|
17
|
+
* - `boardFit` fits the whole bbox (pad + cap) and may shrink as far as
|
|
18
|
+
* needed to show the whole board — fit sits below the interaction
|
|
19
|
+
* ladder freely;
|
|
18
20
|
* - `boardTweenCam` eases between two cameras with an ease-out curve and
|
|
19
21
|
* GEOMETRIC k interpolation (log-space lerp) so animated zooms still
|
|
20
22
|
* land exactly on ladder rungs.
|
|
@@ -51,8 +53,6 @@ export interface BoardViewport {
|
|
|
51
53
|
export const BOARD_ZOOM_FACTOR = 1.05;
|
|
52
54
|
export const BOARD_K_MIN = 0.2;
|
|
53
55
|
export const BOARD_K_MAX = 4;
|
|
54
|
-
/** fit() may shrink as far as needed to show the whole board. */
|
|
55
|
-
export const BOARD_FIT_FLOOR = 0.2;
|
|
56
56
|
/** fit() never blows above this (nearly-empty boards). */
|
|
57
57
|
export const BOARD_FIT_CAP = 1.25;
|
|
58
58
|
/** Padding around the bbox used by fit(), world px. */
|
|
@@ -87,6 +87,26 @@ export function quantizeBoardStep(base: number, ratio: number): number {
|
|
|
87
87
|
return boardClampK(base * boardKOf(level));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Quantize a fixed-step zoom target (the minimap ± button: linear ±5% on
|
|
92
|
+
* a geometric ladder) into a rung, in the step's DIRECTION. Nearest-rung
|
|
93
|
+
* rounding alone can collapse onto the rung the camera already sits on —
|
|
94
|
+
* from the last reachable rung below the cap, a +5% percent target rounds
|
|
95
|
+
* straight back to it and the press does nothing. A target that resolves
|
|
96
|
+
* to the current rung is therefore pushed ONE rung further in the press
|
|
97
|
+
* direction (clamped to the global bounds); targets that already resolve
|
|
98
|
+
* elsewhere are untouched.
|
|
99
|
+
*/
|
|
100
|
+
export function boardStepRung(currentK: number, targetK: number): number {
|
|
101
|
+
const clamped = boardClampK(targetK);
|
|
102
|
+
const rung = quantizeBoardK(clamped);
|
|
103
|
+
const currentLevel = Math.round(boardLevelOf(boardClampK(currentK)));
|
|
104
|
+
const rungLevel = Math.round(boardLevelOf(rung));
|
|
105
|
+
if (rungLevel !== currentLevel) return rung;
|
|
106
|
+
const dir = clamped >= currentK ? 1 : -1;
|
|
107
|
+
return boardClampK(boardKOf(currentLevel + dir));
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
export function boardToScreen(cam: BoardCamera, wx: number, wy: number): BoardPoint {
|
|
91
111
|
return { x: wx * cam.k + cam.x, y: wy * cam.k + cam.y };
|
|
92
112
|
}
|
|
@@ -136,19 +156,20 @@ export function boardClampPan(
|
|
|
136
156
|
|
|
137
157
|
/**
|
|
138
158
|
* Fit the whole content bbox into the viewport (centered, padded). Wide
|
|
139
|
-
* boards shrink
|
|
159
|
+
* boards shrink as far as needed to show everything — fit is allowed
|
|
160
|
+
* below the interaction minimum (the ladder is for gestures, not for
|
|
161
|
+
* the initial framing); tiny boards never blow up past the cap.
|
|
140
162
|
*/
|
|
141
163
|
export function boardFit(
|
|
142
164
|
content: BoardRect,
|
|
143
165
|
viewport: BoardViewport,
|
|
144
|
-
opts: { pad?: number;
|
|
166
|
+
opts: { pad?: number; cap?: number } = {},
|
|
145
167
|
): BoardCamera {
|
|
146
168
|
const pad = opts.pad ?? BOARD_FIT_PAD;
|
|
147
|
-
const floor = opts.floor ?? BOARD_FIT_FLOOR;
|
|
148
169
|
const cap = opts.cap ?? BOARD_FIT_CAP;
|
|
149
170
|
const kw = content.w > 0 ? (viewport.w - pad * 2) / content.w : cap;
|
|
150
171
|
const kh = content.h > 0 ? (viewport.h - pad * 2) / content.h : cap;
|
|
151
|
-
const k = Math.min(cap, Math.
|
|
172
|
+
const k = Math.min(cap, Math.min(kw, kh));
|
|
152
173
|
return {
|
|
153
174
|
k,
|
|
154
175
|
x: (viewport.w - content.w * k) / 2 - content.x * k,
|
|
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
boardAnchor,
|
|
5
5
|
boardEdgePath,
|
|
6
|
+
boardFanOrdinals,
|
|
6
7
|
boardViaPath,
|
|
7
8
|
type BoardNodeRect,
|
|
8
9
|
} from "./boardEdges";
|
|
@@ -39,6 +40,39 @@ describe("boardEdges — anchor modes", () => {
|
|
|
39
40
|
});
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
describe("boardEdges — fan ordinals", () => {
|
|
44
|
+
it("gives a single edge the full fan (count = 1, no spread)", () => {
|
|
45
|
+
const ord = boardFanOrdinals([{ id: "e1", from: "a" }]);
|
|
46
|
+
expect(ord.get("e1")).toEqual({ index: 0, count: 1 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("spreads three edges from one node as {0,3} {1,3} {2,3}", () => {
|
|
50
|
+
const ord = boardFanOrdinals([
|
|
51
|
+
{ id: "e1", from: "a" },
|
|
52
|
+
{ id: "e2", from: "a" },
|
|
53
|
+
{ id: "e3", from: "a" },
|
|
54
|
+
]);
|
|
55
|
+
expect(ord.get("e1")).toEqual({ index: 0, count: 3 });
|
|
56
|
+
expect(ord.get("e2")).toEqual({ index: 1, count: 3 });
|
|
57
|
+
expect(ord.get("e3")).toEqual({ index: 2, count: 3 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("counts each source node independently", () => {
|
|
61
|
+
const ord = boardFanOrdinals([
|
|
62
|
+
{ id: "e1", from: "a" },
|
|
63
|
+
{ id: "e2", from: "b" },
|
|
64
|
+
{ id: "e3", from: "a" },
|
|
65
|
+
]);
|
|
66
|
+
expect(ord.get("e1")).toEqual({ index: 0, count: 2 });
|
|
67
|
+
expect(ord.get("e2")).toEqual({ index: 0, count: 1 });
|
|
68
|
+
expect(ord.get("e3")).toEqual({ index: 1, count: 2 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns an empty map for empty input", () => {
|
|
72
|
+
expect(boardFanOrdinals([]).size).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
42
76
|
describe("boardEdges — route styles", () => {
|
|
43
77
|
const a = { x: 220, y: 130 };
|
|
44
78
|
const b = { x: 400, y: 260 };
|
package/src/utils/boardEdges.ts
CHANGED
|
@@ -35,6 +35,29 @@ export interface BoardPoint {
|
|
|
35
35
|
y: number;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Fan ordinals for a set of edges: every edge sharing a `from` node gets
|
|
40
|
+
* its `{ index, count }` slot in the 天女散花 spread across that node's
|
|
41
|
+
* exit border. Two passes — totals first, then positions — so `count` is
|
|
42
|
+
* the FINAL total per node (a running count would feed the first edges
|
|
43
|
+
* count = 1 and collapse the whole fan onto one anchor point). Edges from
|
|
44
|
+
* different nodes are counted independently.
|
|
45
|
+
*/
|
|
46
|
+
export function boardFanOrdinals(
|
|
47
|
+
edges: ReadonlyArray<{ id: string; from: string }>,
|
|
48
|
+
): Map<string, { index: number; count: number }> {
|
|
49
|
+
const totals = new Map<string, number>();
|
|
50
|
+
for (const e of edges) totals.set(e.from, (totals.get(e.from) ?? 0) + 1);
|
|
51
|
+
const seen = new Map<string, number>();
|
|
52
|
+
const ordinals = new Map<string, { index: number; count: number }>();
|
|
53
|
+
for (const e of edges) {
|
|
54
|
+
const index = seen.get(e.from) ?? 0;
|
|
55
|
+
seen.set(e.from, index + 1);
|
|
56
|
+
ordinals.set(e.id, { index, count: totals.get(e.from)! });
|
|
57
|
+
}
|
|
58
|
+
return ordinals;
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
export interface BoardNodeRect {
|
|
39
62
|
x: number;
|
|
40
63
|
y: number;
|