@pauloricca/spagbol 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +338 -2
- package/dist/controls.css +122 -0
- package/dist/core/controller.d.ts +89 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +732 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/graph.d.ts +30 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +318 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/grouping.d.ts +10 -0
- package/dist/core/grouping.d.ts.map +1 -0
- package/dist/core/grouping.js +130 -0
- package/dist/core/grouping.js.map +1 -0
- package/dist/core/groups.d.ts +40 -0
- package/dist/core/groups.d.ts.map +1 -0
- package/dist/core/groups.js +164 -0
- package/dist/core/groups.js.map +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +8 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/model.d.ts +182 -0
- package/dist/core/model.d.ts.map +1 -0
- package/dist/core/model.js +149 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/observations.d.ts +10 -0
- package/dist/core/observations.d.ts.map +1 -0
- package/dist/core/observations.js +36 -0
- package/dist/core/observations.js.map +1 -0
- package/dist/core/serialization.d.ts +9 -0
- package/dist/core/serialization.d.ts.map +1 -0
- package/dist/core/serialization.js +148 -0
- package/dist/core/serialization.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/link-controls.css +123 -0
- package/dist/react/Controls.d.ts +18 -0
- package/dist/react/Controls.d.ts.map +1 -0
- package/dist/react/Controls.js +301 -0
- package/dist/react/Controls.js.map +1 -0
- package/dist/react/SpagbolCanvas.d.ts +38 -0
- package/dist/react/SpagbolCanvas.d.ts.map +1 -0
- package/dist/react/SpagbolCanvas.js +1248 -0
- package/dist/react/SpagbolCanvas.js.map +1 -0
- package/dist/react/SpagbolCanvasControls.d.ts +36 -0
- package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasControls.js +113 -0
- package/dist/react/SpagbolCanvasControls.js.map +1 -0
- package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
- package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasFlow.js +6 -0
- package/dist/react/SpagbolCanvasFlow.js.map +1 -0
- package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
- package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasLinkControls.js +7 -0
- package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
- package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
- package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasMenu.js +65 -0
- package/dist/react/SpagbolCanvasMenu.js.map +1 -0
- package/dist/react/SpagbolControlNumber.d.ts +25 -0
- package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
- package/dist/react/SpagbolControlNumber.js +209 -0
- package/dist/react/SpagbolControlNumber.js.map +1 -0
- package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
- package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
- package/dist/react/SpagbolControlNumberLink.js +159 -0
- package/dist/react/SpagbolControlNumberLink.js.map +1 -0
- package/dist/react/SpagbolControlZoom.d.ts +11 -0
- package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
- package/dist/react/SpagbolControlZoom.js +24 -0
- package/dist/react/SpagbolControlZoom.js.map +1 -0
- package/dist/react/SpagbolNodeFrame.d.ts +35 -0
- package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
- package/dist/react/SpagbolNodeFrame.js +171 -0
- package/dist/react/SpagbolNodeFrame.js.map +1 -0
- package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
- package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
- package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
- package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
- package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
- package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
- package/dist/react/SpagbolNodeGroupPort.js +62 -0
- package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
- package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
- package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
- package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
- package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
- package/dist/react/context.d.ts +84 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +43 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/duplicateDrag.d.ts +19 -0
- package/dist/react/duplicateDrag.d.ts.map +1 -0
- package/dist/react/duplicateDrag.js +71 -0
- package/dist/react/duplicateDrag.js.map +1 -0
- package/dist/react/flowPresentation.d.ts +11 -0
- package/dist/react/flowPresentation.d.ts.map +1 -0
- package/dist/react/flowPresentation.js +50 -0
- package/dist/react/flowPresentation.js.map +1 -0
- package/dist/react/geometry.d.ts +13 -0
- package/dist/react/geometry.d.ts.map +1 -0
- package/dist/react/geometry.js +43 -0
- package/dist/react/geometry.js.map +1 -0
- package/dist/react/index.d.ts +11 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +11 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/nodeDrag.d.ts +7 -0
- package/dist/react/nodeDrag.d.ts.map +1 -0
- package/dist/react/nodeDrag.js +27 -0
- package/dist/react/nodeDrag.js.map +1 -0
- package/dist/react/portState.d.ts +10 -0
- package/dist/react/portState.d.ts.map +1 -0
- package/dist/react/portState.js +35 -0
- package/dist/react/portState.js.map +1 -0
- package/dist/react/viewport.d.ts +24 -0
- package/dist/react/viewport.d.ts.map +1 -0
- package/dist/react/viewport.js +55 -0
- package/dist/react/viewport.js.map +1 -0
- package/dist/styles.css +748 -0
- package/package.json +58 -4
- package/src/core/controller.ts +844 -0
- package/src/core/graph.ts +451 -0
- package/src/core/grouping.ts +159 -0
- package/src/core/groups.ts +125 -0
- package/src/core/index.ts +7 -0
- package/src/core/model.ts +304 -0
- package/src/core/observations.ts +31 -0
- package/src/core/serialization.ts +184 -0
- package/src/index.ts +9 -0
- package/src/react/Controls.tsx +355 -0
- package/src/react/SpagbolCanvas.tsx +1893 -0
- package/src/react/SpagbolCanvasControls.tsx +255 -0
- package/src/react/SpagbolCanvasFlow.tsx +38 -0
- package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
- package/src/react/SpagbolCanvasMenu.tsx +121 -0
- package/src/react/SpagbolControlNumber.tsx +297 -0
- package/src/react/SpagbolControlNumberLink.tsx +209 -0
- package/src/react/SpagbolControlZoom.tsx +102 -0
- package/src/react/SpagbolNodeFrame.tsx +330 -0
- package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
- package/src/react/SpagbolNodeGroupPort.tsx +111 -0
- package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
- package/src/react/context.tsx +146 -0
- package/src/react/controls.css +122 -0
- package/src/react/duplicateDrag.ts +97 -0
- package/src/react/flowPresentation.ts +56 -0
- package/src/react/geometry.ts +53 -0
- package/src/react/index.ts +61 -0
- package/src/react/link-controls.css +123 -0
- package/src/react/nodeDrag.ts +33 -0
- package/src/react/portState.ts +24 -0
- package/src/react/styles.css +748 -0
- package/src/react/viewport.ts +90 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useReactFlow } from "@xyflow/react";
|
|
2
|
+
import { isGroupBoundary } from "../core/index.js";
|
|
3
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { SpagbolControlZoom } from "./SpagbolControlZoom.js";
|
|
5
|
+
import { SpagbolCanvasMenu } from "./SpagbolCanvasMenu.js";
|
|
6
|
+
import { useEditorSnapshot, useSpagbol } from "./context.js";
|
|
7
|
+
|
|
8
|
+
export interface SpagbolCanvasAction {
|
|
9
|
+
id: string;
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
ariaLabel?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
checked?: boolean;
|
|
15
|
+
onSelect(): void | Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface SpagbolCanvasMenuSeparator {
|
|
18
|
+
id: string;
|
|
19
|
+
separator: true;
|
|
20
|
+
}
|
|
21
|
+
export type SpagbolCanvasMenuItem =
|
|
22
|
+
| SpagbolCanvasAction
|
|
23
|
+
| SpagbolCanvasMenuSeparator;
|
|
24
|
+
export interface SpagbolCanvasControlsOptions {
|
|
25
|
+
/** Host actions beside the menu; history buttons follow these by default. */
|
|
26
|
+
buttons?: readonly SpagbolCanvasAction[];
|
|
27
|
+
menuItems?: readonly SpagbolCanvasMenuItem[];
|
|
28
|
+
menuLabel?: string;
|
|
29
|
+
leading?: ReactNode;
|
|
30
|
+
zoomExtra?: ReactNode;
|
|
31
|
+
showZoom?: boolean;
|
|
32
|
+
showHistory?: boolean;
|
|
33
|
+
showLayoutLock?: boolean;
|
|
34
|
+
showBreadcrumbs?: boolean;
|
|
35
|
+
onActionError?(error: unknown, action: SpagbolCanvasAction): void;
|
|
36
|
+
}
|
|
37
|
+
const labelFor = (action: SpagbolCanvasAction) =>
|
|
38
|
+
action.ariaLabel ??
|
|
39
|
+
(typeof action.label === "string" ? action.label : action.id);
|
|
40
|
+
|
|
41
|
+
/** Standard zoom stack and percentage/reset button, independent of app actions. */
|
|
42
|
+
export function SpagbolCanvasControlsZoom({ extra }: { extra?: ReactNode }) {
|
|
43
|
+
const { zoom } = useSpagbol();
|
|
44
|
+
return (
|
|
45
|
+
<SpagbolControlZoom
|
|
46
|
+
className="spagbol-canvas-controls-zoom"
|
|
47
|
+
zoom={zoom}
|
|
48
|
+
extra={extra}
|
|
49
|
+
layout="corner"
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Teia/visual-visual corner menu. Domain actions stay in the consumer. */
|
|
55
|
+
export function SpagbolCanvasActions(options: SpagbolCanvasControlsOptions) {
|
|
56
|
+
const flow = useReactFlow();
|
|
57
|
+
const scale = (factor: number) => editor.scaleSelection(factor, new Map(flow.getNodes().filter(node => node.id.startsWith("node:")).map(node => [node.id.slice(5), { width: node.measured?.width ?? node.width ?? 180, height: node.measured?.height ?? node.height ?? 100 }])));
|
|
58
|
+
const { editor } = useSpagbol();
|
|
59
|
+
const snapshot = useEditorSnapshot(editor);
|
|
60
|
+
const [busy, setBusy] = useState<ReadonlySet<string>>(new Set());
|
|
61
|
+
const busyRef = useRef(new Set<string>());
|
|
62
|
+
const [error, setError] = useState<string | null>(null);
|
|
63
|
+
const buttons: SpagbolCanvasAction[] = [
|
|
64
|
+
...(snapshot.path.length
|
|
65
|
+
? [
|
|
66
|
+
{
|
|
67
|
+
id: "spagbol:exit",
|
|
68
|
+
label: "EXIT",
|
|
69
|
+
ariaLabel: "Exit nested graph",
|
|
70
|
+
onSelect: () => editor.exit(),
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
: []),
|
|
74
|
+
...(options.buttons ?? []),
|
|
75
|
+
...(options.showHistory === false
|
|
76
|
+
? []
|
|
77
|
+
: [
|
|
78
|
+
{
|
|
79
|
+
id: "spagbol:undo",
|
|
80
|
+
label: "UNDO",
|
|
81
|
+
ariaLabel: "Undo",
|
|
82
|
+
title: "Undo (Cmd/Ctrl+Z)",
|
|
83
|
+
disabled: !snapshot.canUndo || !editor.editing,
|
|
84
|
+
onSelect: () => editor.undo(),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "spagbol:redo",
|
|
88
|
+
label: "REDO",
|
|
89
|
+
ariaLabel: "Redo",
|
|
90
|
+
title: "Redo (Cmd/Ctrl+Shift+Z)",
|
|
91
|
+
disabled: !snapshot.canRedo || !editor.editing,
|
|
92
|
+
onSelect: () => editor.redo(),
|
|
93
|
+
},
|
|
94
|
+
]),
|
|
95
|
+
];
|
|
96
|
+
const members = editor.graph.nodes.filter(node => snapshot.selection.nodeIds.includes(node.id) && !isGroupBoundary(node));
|
|
97
|
+
const exposed = editor.graph.data?.exposedNodeIds;
|
|
98
|
+
const exposureChecked = members.length > 0 && Array.isArray(exposed) && members.every(node => exposed.includes(node.id));
|
|
99
|
+
const items: SpagbolCanvasMenuItem[] = [
|
|
100
|
+
{ id: 'spagbol:group', label: 'GROUP TO SUBPATCH', ariaLabel: 'Group to subpatch', disabled: !editor.editing || !members.length, onSelect: () => { editor.groupSelection(); } },
|
|
101
|
+
|
|
102
|
+
{ id: 'spagbol:scale-up', label: 'SCALE UP SELECTION', disabled: !editor.editing || !snapshot.selection.nodeIds.length, onSelect: () => scale(2) },
|
|
103
|
+
{ id: 'spagbol:scale-down', label: 'SCALE DOWN SELECTION', disabled: !editor.editing || !snapshot.selection.nodeIds.length, onSelect: () => scale(0.5) },
|
|
104
|
+
...(options.menuItems ?? []),
|
|
105
|
+
...(options.showLayoutLock === false
|
|
106
|
+
? []
|
|
107
|
+
: [
|
|
108
|
+
{
|
|
109
|
+
id: "spagbol:lock",
|
|
110
|
+
label: "LOCK UI",
|
|
111
|
+
ariaLabel: snapshot.mode === "edit" ? "Lock UI" : "Unlock UI",
|
|
112
|
+
checked: snapshot.mode !== "edit",
|
|
113
|
+
disabled: snapshot.mode === "readonly",
|
|
114
|
+
onSelect: () =>
|
|
115
|
+
editor.setMode(editor.editing ? "controls" : "edit"),
|
|
116
|
+
},
|
|
117
|
+
]),
|
|
118
|
+
];
|
|
119
|
+
const hasMenu = items.some((item) => !("separator" in item));
|
|
120
|
+
async function invoke(action: SpagbolCanvasAction, fromMenu: boolean) {
|
|
121
|
+
if (action.disabled || busyRef.current.has(action.id)) return;
|
|
122
|
+
busyRef.current.add(action.id);
|
|
123
|
+
setBusy(new Set(busyRef.current));
|
|
124
|
+
setError(null);
|
|
125
|
+
try {
|
|
126
|
+
await action.onSelect();
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
129
|
+
options.onActionError?.(cause, action);
|
|
130
|
+
} finally {
|
|
131
|
+
busyRef.current.delete(action.id);
|
|
132
|
+
setBusy(new Set(busyRef.current));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function actionButton(action: SpagbolCanvasAction, fromMenu = false) {
|
|
136
|
+
return (
|
|
137
|
+
<button
|
|
138
|
+
key={action.id}
|
|
139
|
+
type="button"
|
|
140
|
+
role={
|
|
141
|
+
fromMenu
|
|
142
|
+
? action.checked === undefined
|
|
143
|
+
? "menuitem"
|
|
144
|
+
: "menuitemcheckbox"
|
|
145
|
+
: undefined
|
|
146
|
+
}
|
|
147
|
+
aria-label={labelFor(action)}
|
|
148
|
+
title={action.title ?? labelFor(action)}
|
|
149
|
+
disabled={action.disabled || busy.has(action.id)}
|
|
150
|
+
aria-busy={busy.has(action.id) || undefined}
|
|
151
|
+
aria-checked={fromMenu ? action.checked : undefined}
|
|
152
|
+
aria-pressed={fromMenu ? undefined : action.checked}
|
|
153
|
+
onClick={() => {
|
|
154
|
+
void invoke(action, fromMenu);
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{action.checked !== undefined && fromMenu ? (
|
|
158
|
+
<span className="spagbol-canvas-menu-check" aria-hidden="true">
|
|
159
|
+
{action.checked ? "✓" : ""}
|
|
160
|
+
</span>
|
|
161
|
+
) : null}
|
|
162
|
+
{action.label}
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
className="spagbol-canvas-actions nodrag nopan"
|
|
169
|
+
role="group"
|
|
170
|
+
aria-label="Canvas actions"
|
|
171
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
172
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
173
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
174
|
+
>
|
|
175
|
+
{options.leading && (
|
|
176
|
+
<div className="spagbol-canvas-actions-leading">{options.leading}</div>
|
|
177
|
+
)}
|
|
178
|
+
{buttons.map((action) => actionButton(action))}
|
|
179
|
+
{hasMenu && (
|
|
180
|
+
<SpagbolCanvasMenu
|
|
181
|
+
label={
|
|
182
|
+
<>
|
|
183
|
+
{options.menuLabel ?? "MORE"} <span aria-hidden="true">ˆ</span>
|
|
184
|
+
</>
|
|
185
|
+
}
|
|
186
|
+
>
|
|
187
|
+
{items.map((item) =>
|
|
188
|
+
"separator" in item ? (
|
|
189
|
+
<div
|
|
190
|
+
key={item.id}
|
|
191
|
+
role="separator"
|
|
192
|
+
className="spagbol-canvas-menu-separator"
|
|
193
|
+
/>
|
|
194
|
+
) : (
|
|
195
|
+
actionButton(item, true)
|
|
196
|
+
),
|
|
197
|
+
)}
|
|
198
|
+
</SpagbolCanvasMenu>
|
|
199
|
+
)}
|
|
200
|
+
{error && (
|
|
201
|
+
<div className="spagbol-canvas-actions-error" role="alert">
|
|
202
|
+
{error}
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
aria-label="Dismiss action error"
|
|
206
|
+
onClick={() => setError(null)}
|
|
207
|
+
>
|
|
208
|
+
×
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function SpagbolCanvasControls(options: SpagbolCanvasControlsOptions) {
|
|
217
|
+
const { editor } = useSpagbol();
|
|
218
|
+
const snapshot = useEditorSnapshot(editor);
|
|
219
|
+
return (
|
|
220
|
+
<>
|
|
221
|
+
{options.showBreadcrumbs !== false && snapshot.path.length > 0 && (
|
|
222
|
+
<nav
|
|
223
|
+
className="spagbol-canvas-breadcrumbs nodrag nopan"
|
|
224
|
+
aria-label="Graph navigation"
|
|
225
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
226
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
227
|
+
>
|
|
228
|
+
<button type="button" onClick={() => editor.navigate([])}>
|
|
229
|
+
ROOT
|
|
230
|
+
</button>
|
|
231
|
+
{snapshot.path.map((id, index) => (
|
|
232
|
+
<button
|
|
233
|
+
key={`${index}:${id}`}
|
|
234
|
+
type="button"
|
|
235
|
+
aria-current={
|
|
236
|
+
index === snapshot.path.length - 1 ? "page" : undefined
|
|
237
|
+
}
|
|
238
|
+
onClick={() => editor.navigate(snapshot.path.slice(0, index + 1))}
|
|
239
|
+
>
|
|
240
|
+
{id}
|
|
241
|
+
</button>
|
|
242
|
+
))}
|
|
243
|
+
</nav>
|
|
244
|
+
)}
|
|
245
|
+
<div
|
|
246
|
+
className={`spagbol-canvas-controls${options.showZoom === false ? " spagbol-canvas-controls-no-zoom" : ""}`}
|
|
247
|
+
>
|
|
248
|
+
{options.showZoom !== false && (
|
|
249
|
+
<SpagbolCanvasControlsZoom extra={options.zoomExtra} />
|
|
250
|
+
)}
|
|
251
|
+
<SpagbolCanvasActions {...options} />
|
|
252
|
+
</div>
|
|
253
|
+
</>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Background,
|
|
3
|
+
ReactFlow,
|
|
4
|
+
type Node,
|
|
5
|
+
type Edge,
|
|
6
|
+
type ReactFlowProps,
|
|
7
|
+
} from "@xyflow/react";
|
|
8
|
+
|
|
9
|
+
/** Controlled integration boundary for hosts with an existing graph controller.
|
|
10
|
+
* Node/edge payloads and all edit callbacks belong to the host. Use inside a
|
|
11
|
+
* ReactFlowProvider so specialised host nodes retain access to the flow store.
|
|
12
|
+
*/
|
|
13
|
+
export type SpagbolCanvasFlowProps<
|
|
14
|
+
N extends Node = Node,
|
|
15
|
+
E extends Edge = Edge,
|
|
16
|
+
> = ReactFlowProps<N, E> & {
|
|
17
|
+
grid?: false | { color?: string; gap?: number; size?: number };
|
|
18
|
+
};
|
|
19
|
+
export function SpagbolCanvasFlow<
|
|
20
|
+
N extends Node = Node,
|
|
21
|
+
E extends Edge = Edge,
|
|
22
|
+
>({ grid, children, className, ...props }: SpagbolCanvasFlowProps<N, E>) {
|
|
23
|
+
return (
|
|
24
|
+
<ReactFlow<N, E>
|
|
25
|
+
{...props}
|
|
26
|
+
className={`spagbol-canvas-flow ${className ?? ""}`}
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
{grid !== false && (
|
|
30
|
+
<Background
|
|
31
|
+
color={grid?.color ?? "var(--spagbol-grid, rgba(255,255,255,0.08))"}
|
|
32
|
+
gap={grid?.gap ?? 28}
|
|
33
|
+
size={grid?.size ?? 1}
|
|
34
|
+
/>
|
|
35
|
+
)}
|
|
36
|
+
</ReactFlow>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import { standardLinkModes } from '../core/index.js';
|
|
3
|
+
import { SpagbolControlNumberLink } from './SpagbolControlNumberLink.js';
|
|
4
|
+
export interface SpagbolCanvasLinkControlsProps {
|
|
5
|
+
weight: number; mode: string; enabled: boolean; style?: CSSProperties;
|
|
6
|
+
modes?: readonly { id: string; label: string }[];
|
|
7
|
+
onWeightChange(value: number): void;
|
|
8
|
+
onModeChange(mode: string): void;
|
|
9
|
+
onEnabledChange(enabled: boolean): void;
|
|
10
|
+
}
|
|
11
|
+
export function SpagbolCanvasLinkControls({ weight, mode, enabled, style, modes = standardLinkModes, onWeightChange, onModeChange, onEnabledChange }: SpagbolCanvasLinkControlsProps) {
|
|
12
|
+
return <div className="spagbol-canvas-link-controls nodrag nopan" style={style} onPointerDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()}>
|
|
13
|
+
<div className="spagbol-canvas-link-controls-value-row">
|
|
14
|
+
<SpagbolControlNumberLink value={weight} onChange={onWeightChange} />
|
|
15
|
+
<button className={`spagbol-canvas-link-controls-enabled nodrag nopan${enabled ? ' spagbol-canvas-link-controls-enabled-active' : ''}`} type="button" onClick={event => { event.stopPropagation(); onEnabledChange(!enabled); }} onPointerDown={event => event.stopPropagation()} onMouseDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} aria-label={enabled ? 'Disable link' : 'Enable link'} aria-pressed={enabled} title={enabled ? 'Disable link' : 'Enable link'} />
|
|
16
|
+
</div>
|
|
17
|
+
<select className="spagbol-canvas-link-controls-mode nodrag nopan" value={mode} onChange={event => { onModeChange(event.target.value); event.currentTarget.blur(); }} onPointerDown={event => event.stopPropagation()} onMouseDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} aria-label="Link mode" title="Link mode">
|
|
18
|
+
{modes.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
|
|
19
|
+
</select>
|
|
20
|
+
</div>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface SpagbolCanvasMenuProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
label?: ReactNode;
|
|
6
|
+
ariaLabel?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
menuClassName?: string;
|
|
9
|
+
triggerClassName?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Controller-independent menu mechanics; the host owns actions and styling. */
|
|
12
|
+
export function SpagbolCanvasMenu({
|
|
13
|
+
children,
|
|
14
|
+
label = "MORE ˆ",
|
|
15
|
+
ariaLabel = "More actions",
|
|
16
|
+
className,
|
|
17
|
+
menuClassName,
|
|
18
|
+
triggerClassName,
|
|
19
|
+
}: SpagbolCanvasMenuProps) {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const wrapper = useRef<HTMLDivElement>(null);
|
|
22
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
23
|
+
const menu = useRef<HTMLDivElement>(null);
|
|
24
|
+
const id = useId();
|
|
25
|
+
function close(restoreFocus = false) {
|
|
26
|
+
if (restoreFocus) trigger.current?.focus();
|
|
27
|
+
setOpen(false);
|
|
28
|
+
}
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open) return;
|
|
31
|
+
const outside = (event: PointerEvent) => {
|
|
32
|
+
if (
|
|
33
|
+
event.target instanceof Node &&
|
|
34
|
+
!wrapper.current?.contains(event.target)
|
|
35
|
+
)
|
|
36
|
+
setOpen(false);
|
|
37
|
+
};
|
|
38
|
+
document.addEventListener("pointerdown", outside, true);
|
|
39
|
+
return () => document.removeEventListener("pointerdown", outside, true);
|
|
40
|
+
}, [open]);
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={wrapper}
|
|
44
|
+
className={`spagbol-canvas-menu ${className ?? ""}`}
|
|
45
|
+
data-open={open}
|
|
46
|
+
onMouseEnter={() => setOpen(true)}
|
|
47
|
+
onMouseLeave={() => setOpen(false)}
|
|
48
|
+
onBlur={(event) => {
|
|
49
|
+
if (!event.currentTarget.contains(event.relatedTarget)) close();
|
|
50
|
+
}}
|
|
51
|
+
onKeyDown={(event) => {
|
|
52
|
+
event.stopPropagation();
|
|
53
|
+
if (event.key === "Escape") {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
close(true);
|
|
56
|
+
}
|
|
57
|
+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
setOpen(true);
|
|
60
|
+
const key = event.key;
|
|
61
|
+
requestAnimationFrame(() => {
|
|
62
|
+
const buttons = Array.from(
|
|
63
|
+
menu.current?.querySelectorAll<HTMLButtonElement>(
|
|
64
|
+
"button:not(:disabled)",
|
|
65
|
+
) ?? [],
|
|
66
|
+
);
|
|
67
|
+
const current = buttons.indexOf(
|
|
68
|
+
document.activeElement as HTMLButtonElement,
|
|
69
|
+
);
|
|
70
|
+
const index =
|
|
71
|
+
key === "Home"
|
|
72
|
+
? 0
|
|
73
|
+
: key === "End"
|
|
74
|
+
? buttons.length - 1
|
|
75
|
+
: current < 0
|
|
76
|
+
? key === "ArrowDown"
|
|
77
|
+
? 0
|
|
78
|
+
: buttons.length - 1
|
|
79
|
+
: (current +
|
|
80
|
+
(key === "ArrowDown" ? 1 : -1) +
|
|
81
|
+
buttons.length) %
|
|
82
|
+
buttons.length;
|
|
83
|
+
buttons[index]?.focus();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<button
|
|
89
|
+
ref={trigger}
|
|
90
|
+
className={`spagbol-canvas-menu-trigger ${triggerClassName ?? ""}`}
|
|
91
|
+
type="button"
|
|
92
|
+
aria-haspopup="menu"
|
|
93
|
+
aria-controls={id}
|
|
94
|
+
aria-expanded={open}
|
|
95
|
+
aria-label={ariaLabel}
|
|
96
|
+
title={ariaLabel}
|
|
97
|
+
onFocus={() => setOpen(true)}
|
|
98
|
+
onClick={() => setOpen(true)}
|
|
99
|
+
>
|
|
100
|
+
{label}
|
|
101
|
+
</button>
|
|
102
|
+
<div
|
|
103
|
+
ref={menu}
|
|
104
|
+
id={id}
|
|
105
|
+
className={`spagbol-canvas-menu-items ${menuClassName ?? ""}`}
|
|
106
|
+
role="menu"
|
|
107
|
+
aria-label={ariaLabel}
|
|
108
|
+
hidden={!open}
|
|
109
|
+
onClick={(event) => {
|
|
110
|
+
const button =
|
|
111
|
+
event.target instanceof Element
|
|
112
|
+
? event.target.closest("button")
|
|
113
|
+
: null;
|
|
114
|
+
if (button && !button.disabled) close(true);
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
{children}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|