@kahitsan/ksui 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -102,4 +102,39 @@ describe("FlowGraph", () => {
|
|
|
102
102
|
));
|
|
103
103
|
expect(getByTestId("fg-node-a").getAttribute("role")).toBeNull();
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
it("renders pan/zoom controls only when interactive", () => {
|
|
107
|
+
const staticGraph = render(() => (
|
|
108
|
+
<FlowGraph testId="s" nodes={[{ id: "a", label: "A" }]} edges={[]} />
|
|
109
|
+
));
|
|
110
|
+
expect(staticGraph.queryByTestId("s-controls")).toBeNull();
|
|
111
|
+
|
|
112
|
+
const canvas = render(() => (
|
|
113
|
+
<FlowGraph testId="c" interactive nodes={[{ id: "a", label: "A" }]} edges={[]} />
|
|
114
|
+
));
|
|
115
|
+
expect(canvas.getByTestId("c-controls")).toBeTruthy();
|
|
116
|
+
expect(canvas.getByTestId("c-reset")).toBeTruthy();
|
|
117
|
+
// interactive svg fills the viewport rather than sizing to content
|
|
118
|
+
expect(canvas.getByTestId("c-svg").getAttribute("width")).toBe("100%");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("animates edges with the flow class only when animated", () => {
|
|
122
|
+
const { container } = render(() => (
|
|
123
|
+
<FlowGraph
|
|
124
|
+
testId="fg"
|
|
125
|
+
animated
|
|
126
|
+
nodes={[{ id: "a", label: "A" }, { id: "b", label: "B" }]}
|
|
127
|
+
edges={[{ from: "a", to: "b" }]}
|
|
128
|
+
/>
|
|
129
|
+
));
|
|
130
|
+
expect(container.querySelector(".ksui-fg-edge.flow")).toBeTruthy();
|
|
131
|
+
|
|
132
|
+
const plain = render(() => (
|
|
133
|
+
<FlowGraph
|
|
134
|
+
nodes={[{ id: "a", label: "A" }, { id: "b", label: "B" }]}
|
|
135
|
+
edges={[{ from: "a", to: "b" }]}
|
|
136
|
+
/>
|
|
137
|
+
));
|
|
138
|
+
expect(plain.container.querySelector(".ksui-fg-edge.flow")).toBeNull();
|
|
139
|
+
});
|
|
105
140
|
});
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
// FlowGraph (Vision §9 companion to FlowRunner): a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// FlowGraph (Vision §9 companion to FlowRunner): a renderer for a DECLARATIVE
|
|
2
|
+
// node graph. Where FlowRunner *executes* a server-driven flow, FlowGraph
|
|
3
|
+
// *draws* it — a flow's trigger→form→call→effect chain, a plugin-connection
|
|
4
|
+
// map, any directed graph. With `interactive` it becomes a pan/zoom CANVAS;
|
|
5
|
+
// with `animated` the edges show flow direction as marching dashes.
|
|
5
6
|
//
|
|
6
7
|
// Composite because it composes the pure graph model (utils/graph) with SVG
|
|
7
8
|
// layout + interaction. Domain-free: it knows nothing about plugins or roles;
|
|
8
|
-
// the host supplies typed nodes/edges and
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// the host supplies typed nodes/edges and optional handlers. Self-contained CSS
|
|
10
|
+
// (ksui-fg-* unscoped classes + CSS custom props); no Tailwind, no host-brand
|
|
11
|
+
// classes (standalone-library rule). No graph/canvas library — pan/zoom is a
|
|
12
|
+
// plain SVG group transform, animation is a CSS keyframe.
|
|
11
13
|
|
|
12
14
|
import type { Component, JSX } from "solid-js";
|
|
13
|
-
import { For, Show, createMemo } from "solid-js";
|
|
15
|
+
import { For, Show, createMemo, createSignal } from "solid-js";
|
|
14
16
|
import {
|
|
15
17
|
DEFAULT_METRICS,
|
|
16
18
|
layoutGraph,
|
|
@@ -28,8 +30,11 @@ function ensureStyle(): void {
|
|
|
28
30
|
const style = document.createElement("style");
|
|
29
31
|
style.id = STYLE_ID;
|
|
30
32
|
style.textContent = `
|
|
31
|
-
.ksui-fg-wrap{width:100%;overflow:auto;}
|
|
33
|
+
.ksui-fg-wrap{width:100%;overflow:auto;position:relative;}
|
|
34
|
+
.ksui-fg-wrap.interactive{overflow:hidden;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.12));border-radius:8px;background:var(--ksui-fg-canvas,rgba(0,0,0,0.18));cursor:grab;touch-action:none;}
|
|
35
|
+
.ksui-fg-wrap.interactive.grabbing{cursor:grabbing;}
|
|
32
36
|
.ksui-fg-svg{display:block;max-width:100%;height:auto;font-family:inherit;}
|
|
37
|
+
.ksui-fg-wrap.interactive .ksui-fg-svg{max-width:none;width:100%;height:100%;}
|
|
33
38
|
.ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.22));stroke-width:1.5;}
|
|
34
39
|
.ksui-fg-edge.dashed{stroke-dasharray:4 4;}
|
|
35
40
|
.ksui-fg-edge.primary{stroke:var(--ksui-fg-primary,#c9a961);}
|
|
@@ -37,6 +42,9 @@ function ensureStyle(): void {
|
|
|
37
42
|
.ksui-fg-edge.success{stroke:#22c55e;}
|
|
38
43
|
.ksui-fg-edge.danger{stroke:#ef4444;}
|
|
39
44
|
.ksui-fg-edge.muted{stroke:rgba(255,255,255,0.14);}
|
|
45
|
+
.ksui-fg-edge.flow{stroke-dasharray:5 5;animation:ksui-fg-march .7s linear infinite;}
|
|
46
|
+
@keyframes ksui-fg-march{to{stroke-dashoffset:-10;}}
|
|
47
|
+
@media (prefers-reduced-motion:reduce){.ksui-fg-edge.flow{animation:none;}}
|
|
40
48
|
.ksui-fg-elabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.7));font-size:9px;}
|
|
41
49
|
.ksui-fg-elabel-bg{fill:var(--ksui-fg-bg,#18181b);opacity:0.82;}
|
|
42
50
|
.ksui-fg-box{fill:var(--ksui-fg-node-bg,rgba(255,255,255,0.04));stroke:var(--ksui-fg-node-border,rgba(255,255,255,0.16));stroke-width:1;}
|
|
@@ -50,8 +58,12 @@ function ensureStyle(): void {
|
|
|
50
58
|
.ksui-fg-node.clickable:focus{outline:none;}
|
|
51
59
|
.ksui-fg-node.clickable:focus-visible .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);stroke-width:2;}
|
|
52
60
|
.ksui-fg-label{fill:var(--ksui-fg-fg,#e4e4e7);font-size:12px;font-weight:600;}
|
|
53
|
-
.ksui-fg-sublabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.55));font-size:9.5px;}
|
|
61
|
+
.ksui-fg-sublabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.55));font-size:9.5px;text-transform:uppercase;letter-spacing:0.04em;}
|
|
54
62
|
.ksui-fg-empty{padding:1.75rem 1rem;text-align:center;font-size:0.82rem;color:var(--ksui-fg-muted,rgba(255,255,255,0.55));}
|
|
63
|
+
.ksui-fg-controls{position:absolute;right:8px;bottom:8px;display:flex;gap:4px;z-index:1;}
|
|
64
|
+
.ksui-fg-ctrl{width:24px;height:24px;display:grid;place-items:center;border-radius:5px;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.18));background:var(--ksui-fg-bg,#18181b);color:var(--ksui-fg-fg,#e4e4e7);font-size:14px;line-height:1;cursor:pointer;user-select:none;}
|
|
65
|
+
.ksui-fg-ctrl:hover{background:rgba(255,255,255,0.08);}
|
|
66
|
+
.ksui-fg-hint{position:absolute;left:8px;bottom:8px;font-size:9.5px;color:var(--ksui-fg-muted,rgba(255,255,255,0.4));pointer-events:none;}
|
|
55
67
|
`;
|
|
56
68
|
document.head.appendChild(style);
|
|
57
69
|
}
|
|
@@ -67,6 +79,12 @@ export interface FlowGraphProps {
|
|
|
67
79
|
ariaLabel?: string;
|
|
68
80
|
/** When supplied, nodes become buttons that fire this with the node id. */
|
|
69
81
|
onNodeSelect?: (id: string) => void;
|
|
82
|
+
/** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas. */
|
|
83
|
+
interactive?: boolean;
|
|
84
|
+
/** Animate edges as marching dashes flowing toward the arrowhead. */
|
|
85
|
+
animated?: boolean;
|
|
86
|
+
/** Canvas viewport height in px when interactive (default 360). */
|
|
87
|
+
height?: number;
|
|
70
88
|
testId?: string;
|
|
71
89
|
}
|
|
72
90
|
|
|
@@ -76,14 +94,69 @@ function clip(text: string, max: number): string {
|
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
const { nodeW, nodeH } = DEFAULT_METRICS;
|
|
97
|
+
const ZOOM_MIN = 0.3;
|
|
98
|
+
const ZOOM_MAX = 3;
|
|
79
99
|
|
|
80
100
|
export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
81
101
|
ensureStyle();
|
|
82
102
|
const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
|
|
83
103
|
|
|
84
|
-
const laid = createMemo(() =>
|
|
85
|
-
|
|
86
|
-
)
|
|
104
|
+
const laid = createMemo(() => layoutGraph(props.nodes, props.edges, props.layout ?? "layered"));
|
|
105
|
+
|
|
106
|
+
// Pan/zoom view transform (interactive mode only).
|
|
107
|
+
const [view, setView] = createSignal({ x: 0, y: 0, k: 1 });
|
|
108
|
+
const [grabbing, setGrabbing] = createSignal(false);
|
|
109
|
+
let svgRef: SVGSVGElement | undefined;
|
|
110
|
+
let dragging = false;
|
|
111
|
+
let lastX = 0;
|
|
112
|
+
let lastY = 0;
|
|
113
|
+
let moved = false; // distinguishes a pan from a node click
|
|
114
|
+
|
|
115
|
+
const clampK = (k: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k));
|
|
116
|
+
|
|
117
|
+
const zoomBy = (factor: number, cx?: number, cy?: number) => {
|
|
118
|
+
const v = view();
|
|
119
|
+
const k = clampK(v.k * factor);
|
|
120
|
+
// Keep the point (cx,cy) under the cursor fixed while zooming.
|
|
121
|
+
const px = cx ?? (svgRef?.clientWidth ?? 0) / 2;
|
|
122
|
+
const py = cy ?? (svgRef?.clientHeight ?? 0) / 2;
|
|
123
|
+
setView({ x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k), k });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onWheel = (e: WheelEvent) => {
|
|
127
|
+
if (!props.interactive) return;
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
const rect = svgRef?.getBoundingClientRect();
|
|
130
|
+
zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - (rect?.left ?? 0), e.clientY - (rect?.top ?? 0));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
134
|
+
if (!props.interactive) return;
|
|
135
|
+
dragging = true;
|
|
136
|
+
moved = false;
|
|
137
|
+
setGrabbing(true);
|
|
138
|
+
lastX = e.clientX;
|
|
139
|
+
lastY = e.clientY;
|
|
140
|
+
svgRef?.setPointerCapture(e.pointerId);
|
|
141
|
+
};
|
|
142
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
143
|
+
if (!dragging) return;
|
|
144
|
+
const dx = e.clientX - lastX;
|
|
145
|
+
const dy = e.clientY - lastY;
|
|
146
|
+
if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
|
|
147
|
+
lastX = e.clientX;
|
|
148
|
+
lastY = e.clientY;
|
|
149
|
+
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
|
|
150
|
+
};
|
|
151
|
+
const endDrag = (e: PointerEvent) => {
|
|
152
|
+
dragging = false;
|
|
153
|
+
setGrabbing(false);
|
|
154
|
+
try {
|
|
155
|
+
svgRef?.releasePointerCapture(e.pointerId);
|
|
156
|
+
} catch {
|
|
157
|
+
/* pointer already released */
|
|
158
|
+
}
|
|
159
|
+
};
|
|
87
160
|
|
|
88
161
|
// A cubic bezier from a source node's right edge to a target's left edge.
|
|
89
162
|
const edgePath = (s: PositionedNode, t: PositionedNode): string => {
|
|
@@ -95,6 +168,10 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
95
168
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
96
169
|
};
|
|
97
170
|
|
|
171
|
+
const selectNode = (id: string) => {
|
|
172
|
+
if (moved) return; // a pan ended over the node — don't treat it as a click
|
|
173
|
+
props.onNodeSelect?.(id);
|
|
174
|
+
};
|
|
98
175
|
const activate = (e: KeyboardEvent, id: string) => {
|
|
99
176
|
if (e.key === "Enter" || e.key === " ") {
|
|
100
177
|
e.preventDefault();
|
|
@@ -102,8 +179,16 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
102
179
|
}
|
|
103
180
|
};
|
|
104
181
|
|
|
182
|
+
const groupTransform = () =>
|
|
183
|
+
props.interactive ? `translate(${view().x} ${view().y}) scale(${view().k})` : undefined;
|
|
184
|
+
|
|
105
185
|
return (
|
|
106
|
-
<div
|
|
186
|
+
<div
|
|
187
|
+
class="ksui-fg-wrap"
|
|
188
|
+
classList={{ interactive: !!props.interactive, grabbing: grabbing() }}
|
|
189
|
+
style={props.interactive ? { height: `${props.height ?? 360}px` } : undefined}
|
|
190
|
+
data-testid={tid("root")}
|
|
191
|
+
>
|
|
107
192
|
<Show
|
|
108
193
|
when={laid().nodes.length > 0}
|
|
109
194
|
fallback={
|
|
@@ -113,13 +198,19 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
113
198
|
}
|
|
114
199
|
>
|
|
115
200
|
<svg
|
|
201
|
+
ref={svgRef}
|
|
116
202
|
class="ksui-fg-svg"
|
|
117
|
-
viewBox={`0 0 ${laid().width} ${laid().height}`}
|
|
118
|
-
width={laid().width}
|
|
119
|
-
height={laid().height}
|
|
203
|
+
viewBox={props.interactive ? undefined : `0 0 ${laid().width} ${laid().height}`}
|
|
204
|
+
width={props.interactive ? "100%" : laid().width}
|
|
205
|
+
height={props.interactive ? "100%" : laid().height}
|
|
120
206
|
role="img"
|
|
121
207
|
aria-label={props.ariaLabel ?? "Relationship graph"}
|
|
122
208
|
data-testid={tid("svg")}
|
|
209
|
+
onWheel={onWheel}
|
|
210
|
+
onPointerDown={onPointerDown}
|
|
211
|
+
onPointerMove={onPointerMove}
|
|
212
|
+
onPointerUp={endDrag}
|
|
213
|
+
onPointerCancel={endDrag}
|
|
123
214
|
>
|
|
124
215
|
<defs>
|
|
125
216
|
<marker
|
|
@@ -135,79 +226,110 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
|
135
226
|
</marker>
|
|
136
227
|
</defs>
|
|
137
228
|
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
{(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
class="ksui-fg-elabel-bg"
|
|
160
|
-
x={mx - clip(e.label!, 18).length * 2.6 - 3}
|
|
161
|
-
y={my - 7}
|
|
162
|
-
width={clip(e.label!, 18).length * 5.2 + 6}
|
|
163
|
-
height={12}
|
|
164
|
-
rx={2}
|
|
229
|
+
<g transform={groupTransform()}>
|
|
230
|
+
{/* Edges first so nodes paint on top of the connectors. */}
|
|
231
|
+
<For each={props.edges}>
|
|
232
|
+
{(e) => {
|
|
233
|
+
const s = () => laid().byId.get(e.from);
|
|
234
|
+
const t = () => laid().byId.get(e.to);
|
|
235
|
+
return (
|
|
236
|
+
<Show when={s() && t()}>
|
|
237
|
+
{(() => {
|
|
238
|
+
const src = s() as PositionedNode;
|
|
239
|
+
const dst = t() as PositionedNode;
|
|
240
|
+
const mx = (src.x + nodeW + dst.x) / 2;
|
|
241
|
+
const my = (src.y + dst.y) / 2 + nodeH / 2;
|
|
242
|
+
return (
|
|
243
|
+
<g>
|
|
244
|
+
<path
|
|
245
|
+
class={`ksui-fg-edge ${e.accent ?? ""} ${e.dashed ? "dashed" : ""} ${
|
|
246
|
+
props.animated ? "flow" : ""
|
|
247
|
+
}`}
|
|
248
|
+
d={edgePath(src, dst)}
|
|
249
|
+
marker-end="url(#ksui-fg-arrow)"
|
|
165
250
|
/>
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
251
|
+
<Show when={e.label}>
|
|
252
|
+
<rect
|
|
253
|
+
class="ksui-fg-elabel-bg"
|
|
254
|
+
x={mx - clip(e.label!, 18).length * 2.6 - 3}
|
|
255
|
+
y={my - 7}
|
|
256
|
+
width={clip(e.label!, 18).length * 5.2 + 6}
|
|
257
|
+
height={12}
|
|
258
|
+
rx={2}
|
|
259
|
+
/>
|
|
260
|
+
<text class="ksui-fg-elabel" x={mx} y={my + 2} text-anchor="middle">
|
|
261
|
+
{clip(e.label!, 18)}
|
|
262
|
+
</text>
|
|
263
|
+
</Show>
|
|
264
|
+
</g>
|
|
265
|
+
);
|
|
266
|
+
})()}
|
|
267
|
+
</Show>
|
|
268
|
+
);
|
|
269
|
+
}}
|
|
270
|
+
</For>
|
|
271
|
+
|
|
272
|
+
{/* Nodes */}
|
|
273
|
+
<For each={laid().nodes}>
|
|
274
|
+
{(n) => {
|
|
275
|
+
const interactiveNode = () => typeof props.onNodeSelect === "function";
|
|
276
|
+
return (
|
|
277
|
+
<g
|
|
278
|
+
class={`ksui-fg-node ${n.accent ?? ""} ${interactiveNode() ? "clickable" : ""}`}
|
|
279
|
+
transform={`translate(${n.x} ${n.y})`}
|
|
280
|
+
data-testid={tid(`node-${n.id}`)}
|
|
281
|
+
role={interactiveNode() ? "button" : undefined}
|
|
282
|
+
tabindex={interactiveNode() ? 0 : undefined}
|
|
283
|
+
aria-label={n.sublabel ? `${n.label} — ${n.sublabel}` : n.label}
|
|
284
|
+
onClick={interactiveNode() ? () => selectNode(n.id) : undefined}
|
|
285
|
+
onKeyDown={interactiveNode() ? (ev) => activate(ev, n.id) : undefined}
|
|
198
286
|
>
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
<text class="ksui-fg-sublabel" x={12} y={34}>
|
|
203
|
-
{clip(n.sublabel!, 28)}
|
|
287
|
+
<rect class="ksui-fg-box" width={nodeW} height={nodeH} rx={8} />
|
|
288
|
+
<text class="ksui-fg-label" x={12} y={n.sublabel ? 20 : nodeH / 2 + 4}>
|
|
289
|
+
{clip(n.label, 24)}
|
|
204
290
|
</text>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
291
|
+
<Show when={n.sublabel}>
|
|
292
|
+
<text class="ksui-fg-sublabel" x={12} y={34}>
|
|
293
|
+
{clip(n.sublabel!, 28)}
|
|
294
|
+
</text>
|
|
295
|
+
</Show>
|
|
296
|
+
</g>
|
|
297
|
+
);
|
|
298
|
+
}}
|
|
299
|
+
</For>
|
|
300
|
+
</g>
|
|
210
301
|
</svg>
|
|
302
|
+
|
|
303
|
+
<Show when={props.interactive}>
|
|
304
|
+
<div class="ksui-fg-controls" data-testid={tid("controls")}>
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
class="ksui-fg-ctrl"
|
|
308
|
+
aria-label="Zoom in"
|
|
309
|
+
onClick={() => zoomBy(1.2)}
|
|
310
|
+
>
|
|
311
|
+
+
|
|
312
|
+
</button>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
class="ksui-fg-ctrl"
|
|
316
|
+
aria-label="Zoom out"
|
|
317
|
+
onClick={() => zoomBy(1 / 1.2)}
|
|
318
|
+
>
|
|
319
|
+
−
|
|
320
|
+
</button>
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
class="ksui-fg-ctrl"
|
|
324
|
+
aria-label="Reset view"
|
|
325
|
+
data-testid={tid("reset")}
|
|
326
|
+
onClick={() => setView({ x: 0, y: 0, k: 1 })}
|
|
327
|
+
>
|
|
328
|
+
⤢
|
|
329
|
+
</button>
|
|
330
|
+
</div>
|
|
331
|
+
<span class="ksui-fg-hint">drag to pan · scroll to zoom</span>
|
|
332
|
+
</Show>
|
|
211
333
|
</Show>
|
|
212
334
|
</div>
|
|
213
335
|
) as JSX.Element;
|