@pauloricca/spagbol 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +330 -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 +1210 -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 +169 -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/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 +1859 -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 +329 -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/portState.ts +24 -0
- package/src/react/styles.css +748 -0
- package/src/react/viewport.ts +90 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
type ComponentType,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
type Diagnostic,
|
|
13
|
+
type GraphEditor,
|
|
14
|
+
type EditorSnapshot,
|
|
15
|
+
type GraphNode,
|
|
16
|
+
type NodeData,
|
|
17
|
+
type ObservationStore,
|
|
18
|
+
type Point,
|
|
19
|
+
} from "../core/index.js";
|
|
20
|
+
|
|
21
|
+
export interface SpagbolCanvasFeatures {
|
|
22
|
+
compact?: boolean;
|
|
23
|
+
selectable?: boolean;
|
|
24
|
+
draggable?: boolean;
|
|
25
|
+
areas?: boolean;
|
|
26
|
+
linkProperties?: boolean;
|
|
27
|
+
nestedGraphs?: boolean;
|
|
28
|
+
panels?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface SpagbolNodeViewProps<D extends NodeData = NodeData> {
|
|
31
|
+
node: GraphNode<D>;
|
|
32
|
+
selected: boolean;
|
|
33
|
+
projected: boolean;
|
|
34
|
+
path: readonly string[];
|
|
35
|
+
resetOverride?(): void;
|
|
36
|
+
readOnly: boolean;
|
|
37
|
+
setValue(portId: string, value: number): void;
|
|
38
|
+
patchData(data: Partial<D>, structural?: boolean): void;
|
|
39
|
+
beginGesture(label?: string): void;
|
|
40
|
+
endGesture(cancel?: boolean): void;
|
|
41
|
+
}
|
|
42
|
+
export interface SpagbolNodeView {
|
|
43
|
+
body?: ComponentType<SpagbolNodeViewProps>;
|
|
44
|
+
/** Complete custom geometry; use SpagbolNodePort and the supplied command props. */
|
|
45
|
+
render?: ComponentType<SpagbolNodeViewProps>;
|
|
46
|
+
projected?: ComponentType<SpagbolNodeViewProps>;
|
|
47
|
+
minWidth?: number;
|
|
48
|
+
minHeight?: number;
|
|
49
|
+
resizable?: boolean;
|
|
50
|
+
autoWidth?: boolean;
|
|
51
|
+
compact?: boolean;
|
|
52
|
+
selectable?: boolean;
|
|
53
|
+
draggable?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Keep application body props typed while the renderer registry erases payloads. */
|
|
56
|
+
export function defineNodeView<D extends NodeData>(view: {
|
|
57
|
+
body?: ComponentType<SpagbolNodeViewProps<D>>;
|
|
58
|
+
render?: ComponentType<SpagbolNodeViewProps<D>>;
|
|
59
|
+
projected?: ComponentType<SpagbolNodeViewProps<D>>;
|
|
60
|
+
minWidth?: number;
|
|
61
|
+
minHeight?: number;
|
|
62
|
+
resizable?: boolean;
|
|
63
|
+
autoWidth?: boolean;
|
|
64
|
+
compact?: boolean;
|
|
65
|
+
selectable?: boolean;
|
|
66
|
+
draggable?: boolean;
|
|
67
|
+
}): SpagbolNodeView {
|
|
68
|
+
const wrap = (
|
|
69
|
+
component?: ComponentType<SpagbolNodeViewProps<D>>,
|
|
70
|
+
): ComponentType<SpagbolNodeViewProps> | undefined =>
|
|
71
|
+
component
|
|
72
|
+
? (props) => createElement(component, props as SpagbolNodeViewProps<D>)
|
|
73
|
+
: undefined;
|
|
74
|
+
return {
|
|
75
|
+
...view,
|
|
76
|
+
body: wrap(view.body),
|
|
77
|
+
render: wrap(view.render),
|
|
78
|
+
projected: wrap(view.projected),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export interface GraphContextValue {
|
|
82
|
+
editor: GraphEditor;
|
|
83
|
+
views: Readonly<Record<string, SpagbolNodeView>>;
|
|
84
|
+
features: SpagbolCanvasFeatures;
|
|
85
|
+
diagnostics: readonly Diagnostic[];
|
|
86
|
+
nodeKinds?: readonly string[];
|
|
87
|
+
changeNodeKind?(id: string, kind: string): void | Promise<void>;
|
|
88
|
+
zoom: number;
|
|
89
|
+
connecting: boolean;
|
|
90
|
+
linkOverlay: HTMLElement | null;
|
|
91
|
+
projection?: { groupId: string; path: readonly string[] };
|
|
92
|
+
instanceId: string;
|
|
93
|
+
areaDuplicate?(id: string, phase: "start" | "move" | "end" | "cancel", offset: Point, external?: boolean): void;
|
|
94
|
+
registerTarget(key: string, element: HTMLElement | null): void;
|
|
95
|
+
}
|
|
96
|
+
export const GraphContext = createContext<GraphContextValue | null>(null);
|
|
97
|
+
export function useSpagbol(): GraphContextValue {
|
|
98
|
+
const context = useContext(GraphContext);
|
|
99
|
+
if (!context)
|
|
100
|
+
throw new Error("Spagbol components must be inside SpagbolCanvas.");
|
|
101
|
+
return context;
|
|
102
|
+
}
|
|
103
|
+
export function useGraphEditor() {
|
|
104
|
+
return useSpagbol().editor;
|
|
105
|
+
}
|
|
106
|
+
export function useEditorSnapshot(editor: GraphEditor) {
|
|
107
|
+
return useSyncExternalStore(
|
|
108
|
+
editor.subscribe,
|
|
109
|
+
editor.getSnapshot,
|
|
110
|
+
editor.getSnapshot,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
/** Subscribe to a stable slice, so value changes do not redraw selection-only UI. */
|
|
114
|
+
export function useEditorSelector<T>(editor: GraphEditor, select: (snapshot: EditorSnapshot) => T): T {
|
|
115
|
+
return useSyncExternalStore(editor.subscribe, () => select(editor.getSnapshot()), () => select(editor.getSnapshot()));
|
|
116
|
+
}
|
|
117
|
+
export function useObservation<T>(
|
|
118
|
+
store: ObservationStore<T>,
|
|
119
|
+
path: readonly string[],
|
|
120
|
+
nodeId: string,
|
|
121
|
+
instance = "",
|
|
122
|
+
): T | undefined {
|
|
123
|
+
const key = store.key(path, nodeId, instance);
|
|
124
|
+
return useSyncExternalStore(
|
|
125
|
+
(listener) => store.subscribe(key, listener),
|
|
126
|
+
() => store.get(key),
|
|
127
|
+
() => undefined,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
/** Register a scoped DOM target for app-owned GPU/canvas rendering. */
|
|
131
|
+
export function useRenderTarget(nodeId: string, slot = "preview") {
|
|
132
|
+
const { editor, registerTarget, projection } = useSpagbol();
|
|
133
|
+
const path = [...editor.getSnapshot().path, ...(projection ? [projection.groupId, ...projection.path.slice(0, -1)] : [])];
|
|
134
|
+
const key = JSON.stringify([path, nodeId, slot]);
|
|
135
|
+
return useCallback(
|
|
136
|
+
(element: HTMLElement | null) => registerTarget(key, element),
|
|
137
|
+
[key, registerTarget],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
export type SpagbolTheme = CSSProperties & {
|
|
141
|
+
[key: `--spagbol-${string}`]: string | number;
|
|
142
|
+
};
|
|
143
|
+
export interface SpagbolCanvasSlots {
|
|
144
|
+
background?: ReactNode;
|
|
145
|
+
overlay?: ReactNode;
|
|
146
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* Teia's original controls. Existing Teia tokens take precedence; Spagbol
|
|
2
|
+
tokens/defaults allow the same controls in other consumers and outside a canvas. */
|
|
3
|
+
.spagbol-control-slider,
|
|
4
|
+
.spagbol-control-button {
|
|
5
|
+
grid-column: 1 / -1;
|
|
6
|
+
width: 100%;
|
|
7
|
+
min-width: 156px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.spagbol-control-slider {
|
|
11
|
+
position: relative;
|
|
12
|
+
height: var(--node-display-height, 48px);
|
|
13
|
+
background: transparent;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
touch-action: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.spagbol-control-button {
|
|
19
|
+
height: var(--node-display-height, 48px);
|
|
20
|
+
min-width: 0;
|
|
21
|
+
border: 0;
|
|
22
|
+
background: transparent;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
touch-action: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.spagbol-control-button:hover,
|
|
28
|
+
.spagbol-control-button:focus-visible {
|
|
29
|
+
background: var(--color-foreground-08, rgba(255, 255, 255, 0.08));
|
|
30
|
+
color: var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
31
|
+
outline: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.spagbol-control-button-active {
|
|
35
|
+
background: var(--color-accent, var(--spagbol-accent, #2fd061));
|
|
36
|
+
color: var(--color-background, var(--spagbol-bg, #000));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.spagbol-control-button-active:hover,
|
|
40
|
+
.spagbol-control-button-active:focus-visible {
|
|
41
|
+
background: var(--color-accent, var(--spagbol-accent, #2fd061));
|
|
42
|
+
color: var(--color-background, var(--spagbol-bg, #000));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.spagbol-control-slider-fill {
|
|
46
|
+
position: absolute;
|
|
47
|
+
inset: auto auto 0 0;
|
|
48
|
+
width: 0;
|
|
49
|
+
height: 100%;
|
|
50
|
+
background: var(--color-accent, var(--spagbol-accent, #2fd061));
|
|
51
|
+
pointer-events: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.spagbol-control-slider-vertical .spagbol-control-slider-fill {
|
|
55
|
+
width: 100%;
|
|
56
|
+
height: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.spagbol-control-slider input[type="range"] {
|
|
60
|
+
position: absolute;
|
|
61
|
+
inset: 0;
|
|
62
|
+
width: 100%;
|
|
63
|
+
height: 100%;
|
|
64
|
+
margin: 0;
|
|
65
|
+
opacity: 0;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.spagbol-control-slider-vertical input[type="range"] {
|
|
70
|
+
writing-mode: vertical-lr;
|
|
71
|
+
direction: rtl;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Match Teia's control focus styling inside the canvas's generic button rules. */
|
|
75
|
+
.spagbol-canvas button.spagbol-control-button:focus-visible {
|
|
76
|
+
outline: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
.spagbol-control-number {
|
|
81
|
+
width: 68px;
|
|
82
|
+
min-width: 0;
|
|
83
|
+
max-width: 68px;
|
|
84
|
+
height: 22px;
|
|
85
|
+
padding: 0 4px;
|
|
86
|
+
border: 1px solid var(--spagbol-foreground, #fff);
|
|
87
|
+
background: transparent;
|
|
88
|
+
color: var(--spagbol-foreground, #fff);
|
|
89
|
+
cursor: ns-resize;
|
|
90
|
+
line-height: 20px;
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
text-align: left;
|
|
93
|
+
text-overflow: clip;
|
|
94
|
+
white-space: nowrap;
|
|
95
|
+
user-select: none;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.spagbol-control-number:focus {
|
|
99
|
+
outline: 1px solid var(--spagbol-foreground, #fff);
|
|
100
|
+
outline-offset: 1px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.spagbol-control-number-editing {
|
|
104
|
+
display: block;
|
|
105
|
+
appearance: textfield;
|
|
106
|
+
cursor: text;
|
|
107
|
+
font: inherit;
|
|
108
|
+
user-select: text;
|
|
109
|
+
-webkit-appearance: none;
|
|
110
|
+
-moz-appearance: textfield;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.spagbol-control-number-replaced {
|
|
114
|
+
border-color: color-mix(in srgb, var(--spagbol-foreground, #fff) 42%, transparent);
|
|
115
|
+
color: color-mix(in srgb, var(--spagbol-foreground, #fff) 48%, var(--spagbol-bg, #000));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.spagbol-control-number-editing::selection {
|
|
119
|
+
background: var(--spagbol-foreground, #fff);
|
|
120
|
+
color: var(--spagbol-bg, #000);
|
|
121
|
+
}
|
|
122
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GraphDocument,
|
|
3
|
+
GraphFragment,
|
|
4
|
+
GraphNode,
|
|
5
|
+
GraphArea,
|
|
6
|
+
Point,
|
|
7
|
+
} from "../core/index.js";
|
|
8
|
+
|
|
9
|
+
export interface DuplicateDrag {
|
|
10
|
+
namespace: string;
|
|
11
|
+
fragment: GraphFragment;
|
|
12
|
+
origin: Point;
|
|
13
|
+
nodeId: string;
|
|
14
|
+
positions: Map<string, Point>;
|
|
15
|
+
external: boolean;
|
|
16
|
+
areaPositions?: Map<string, Point>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Rendering only: originals and moving copies coexist without editing the document. */
|
|
20
|
+
export function duplicateDragPresentation(
|
|
21
|
+
graph: GraphDocument,
|
|
22
|
+
drag: DuplicateDrag,
|
|
23
|
+
) {
|
|
24
|
+
const aliases = new Map(
|
|
25
|
+
drag.fragment.nodes.map((node) => [
|
|
26
|
+
node.id,
|
|
27
|
+
`${drag.namespace}:origin:${node.id}`,
|
|
28
|
+
]),
|
|
29
|
+
);
|
|
30
|
+
const areaAliases = new Map(drag.fragment.areas.map(area => [area.id, `${drag.namespace}:origin:${area.id}`]));
|
|
31
|
+
const originalAreas = new Map<string, GraphArea>();
|
|
32
|
+
const originalNodes = new Map<string, GraphNode>();
|
|
33
|
+
const previewNodeIds = new Set(drag.fragment.nodes.map((node) => node.id));
|
|
34
|
+
const candidates = [
|
|
35
|
+
...drag.fragment.links,
|
|
36
|
+
...(drag.external ? drag.fragment.boundaryLinks : []),
|
|
37
|
+
];
|
|
38
|
+
const previewLinks = new Map(
|
|
39
|
+
candidates.map((link) => [`${drag.namespace}:link:${link.id}`, link.id]),
|
|
40
|
+
);
|
|
41
|
+
const linkIds = new Map(
|
|
42
|
+
candidates.map((link) => [link.id, `${drag.namespace}:link:${link.id}`]),
|
|
43
|
+
);
|
|
44
|
+
const stationary = graph.nodes.map((node) => {
|
|
45
|
+
const alias = aliases.get(node.id);
|
|
46
|
+
if (!alias) return node;
|
|
47
|
+
originalNodes.set(alias, node);
|
|
48
|
+
return { ...node, id: alias };
|
|
49
|
+
});
|
|
50
|
+
const moving = drag.fragment.nodes.map((node) => ({
|
|
51
|
+
...node,
|
|
52
|
+
position: drag.positions.get(node.id) ?? node.position,
|
|
53
|
+
}));
|
|
54
|
+
const originalLinks = graph.links.map((link) => ({
|
|
55
|
+
...link,
|
|
56
|
+
source: {
|
|
57
|
+
...link.source,
|
|
58
|
+
nodeId: aliases.get(link.source.nodeId) ?? link.source.nodeId,
|
|
59
|
+
},
|
|
60
|
+
target:
|
|
61
|
+
link.target.type === "port"
|
|
62
|
+
? {
|
|
63
|
+
...link.target,
|
|
64
|
+
nodeId: aliases.get(link.target.nodeId) ?? link.target.nodeId,
|
|
65
|
+
}
|
|
66
|
+
: link.target,
|
|
67
|
+
}));
|
|
68
|
+
const movingLinks = candidates.map((link) => ({
|
|
69
|
+
...link,
|
|
70
|
+
id: linkIds.get(link.id)!,
|
|
71
|
+
target:
|
|
72
|
+
link.target.type === "link"
|
|
73
|
+
? {
|
|
74
|
+
...link.target,
|
|
75
|
+
linkId: linkIds.get(link.target.linkId) ?? link.target.linkId,
|
|
76
|
+
}
|
|
77
|
+
: link.target,
|
|
78
|
+
}));
|
|
79
|
+
const presentation: GraphDocument = {
|
|
80
|
+
...graph,
|
|
81
|
+
nodes: [...stationary, ...moving],
|
|
82
|
+
links: [...originalLinks, ...movingLinks],
|
|
83
|
+
areas: [
|
|
84
|
+
...graph.areas.map(area => {
|
|
85
|
+
const alias = areaAliases.get(area.id);
|
|
86
|
+
if (alias) originalAreas.set(alias, area);
|
|
87
|
+
return { ...area, id: alias ?? area.id,
|
|
88
|
+
...(area.ownerNodeId ? { ownerNodeId: aliases.get(area.ownerNodeId) ?? area.ownerNodeId } : {}),
|
|
89
|
+
...(area.nodeIds ? { nodeIds: area.nodeIds.map(id => aliases.get(id) ?? id) } : {}),
|
|
90
|
+
...(area.areaIds ? { areaIds: area.areaIds.map(id => areaAliases.get(id) ?? id) } : {}),
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
...drag.fragment.areas.map(area => ({ ...area, position: drag.areaPositions?.get(area.id) ?? area.position })),
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
return { graph: presentation, originalAreas, originalNodes, previewNodeIds, previewLinks };
|
|
97
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Edge, Node, NodeChange } from "@xyflow/react";
|
|
2
|
+
import type { Size } from "../core/index.js";
|
|
3
|
+
|
|
4
|
+
/** Apply measurement notifications without turning them into graph/history edits. */
|
|
5
|
+
export function rememberMeasurements<N extends Node>(
|
|
6
|
+
cache: Map<string, Size>,
|
|
7
|
+
changes: readonly NodeChange<N>[],
|
|
8
|
+
): boolean {
|
|
9
|
+
let changed = false;
|
|
10
|
+
for (const change of changes)
|
|
11
|
+
if (change.type === "dimensions" && change.dimensions) {
|
|
12
|
+
const previous = cache.get(change.id);
|
|
13
|
+
if (
|
|
14
|
+
previous?.width !== change.dimensions.width ||
|
|
15
|
+
previous?.height !== change.dimensions.height
|
|
16
|
+
) {
|
|
17
|
+
cache.set(change.id, { ...change.dimensions });
|
|
18
|
+
changed = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return changed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** React Flow hides the reconnecting edge; a separate identity keeps the source visible. */
|
|
25
|
+
export function stationaryReconnectEdge<E extends Edge>(edge: E): E {
|
|
26
|
+
return {
|
|
27
|
+
...edge,
|
|
28
|
+
id: `reconnect-preview:${edge.id}`,
|
|
29
|
+
selected: false,
|
|
30
|
+
reconnectable: false,
|
|
31
|
+
selectable: false,
|
|
32
|
+
focusable: false,
|
|
33
|
+
deletable: false,
|
|
34
|
+
data: { ...edge.data, emphasized: false, dimmed: false },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shallowRecordEqual(left: unknown, right: unknown): boolean {
|
|
39
|
+
if (left === right) return true;
|
|
40
|
+
if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false;
|
|
41
|
+
const a = left as Record<string, unknown>, b = right as Record<string, unknown>;
|
|
42
|
+
const keys = Object.keys(a);
|
|
43
|
+
return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key]);
|
|
44
|
+
}
|
|
45
|
+
/** Keep native flow records stable when immutable model and geometry are unchanged. */
|
|
46
|
+
export function reuseFlowPresentations<T extends { id: string }>(previous: T[], next: T[]): T[] {
|
|
47
|
+
const byId = new Map(previous.map(item => [item.id, item]));
|
|
48
|
+
const result = next.map(item => {
|
|
49
|
+
const old = byId.get(item.id);
|
|
50
|
+
if (!old) return item;
|
|
51
|
+
const a = old as unknown as Record<string, unknown>, b = item as unknown as Record<string, unknown>;
|
|
52
|
+
const keys = Object.keys(a);
|
|
53
|
+
return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key] || (['data', 'style', 'position', 'measured'].includes(key) && shallowRecordEqual(a[key], b[key]))) ? old : item;
|
|
54
|
+
});
|
|
55
|
+
return result.length === previous.length && result.every((item, index) => item === previous[index]) ? previous : result;
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getBezierPath, Position } from "@xyflow/react";
|
|
2
|
+
import type { Point } from "../core/index.js";
|
|
3
|
+
|
|
4
|
+
/** Adapted from Teia's feedback/self-loop treatment; no audio semantics here. */
|
|
5
|
+
export function linkPath(
|
|
6
|
+
source: Point,
|
|
7
|
+
target: Point,
|
|
8
|
+
feedback = false,
|
|
9
|
+
sameNode = false,
|
|
10
|
+
): [string, number, number] {
|
|
11
|
+
if (!feedback && !sameNode) {
|
|
12
|
+
const [path, x, y] = getBezierPath({
|
|
13
|
+
sourceX: source.x,
|
|
14
|
+
sourceY: source.y,
|
|
15
|
+
targetX: target.x,
|
|
16
|
+
targetY: target.y,
|
|
17
|
+
sourcePosition: Position.Right,
|
|
18
|
+
targetPosition: Position.Left,
|
|
19
|
+
});
|
|
20
|
+
return [path, x, y];
|
|
21
|
+
}
|
|
22
|
+
const dx = Math.abs(source.x - target.x),
|
|
23
|
+
dy = Math.abs(source.y - target.y);
|
|
24
|
+
if (sameNode) {
|
|
25
|
+
const offset = Math.max(220 * 0.36, dx * 0.34 + dy * 0.12);
|
|
26
|
+
const controlY =
|
|
27
|
+
Math.min(source.y, target.y) - Math.min(32, Math.max(16, dy * 0.2 + 16));
|
|
28
|
+
return [
|
|
29
|
+
`M ${source.x},${source.y} C ${source.x + offset},${controlY} ${target.x - offset},${controlY} ${target.x},${target.y}`,
|
|
30
|
+
(source.x + target.x) / 2,
|
|
31
|
+
0.125 * source.y + 0.75 * controlY + 0.125 * target.y,
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
const offset = Math.max(142, dx * 0.8 + dy * 0.2);
|
|
35
|
+
return [
|
|
36
|
+
`M ${source.x},${source.y} C ${source.x + offset},${source.y} ${target.x - offset},${target.y} ${target.x},${target.y}`,
|
|
37
|
+
(source.x + target.x) / 2,
|
|
38
|
+
(source.y + target.y) / 2,
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
export const nodeFlowId = (id: string) => `node:${id}`;
|
|
42
|
+
export const areaFlowId = (id: string) => `area:${id}`;
|
|
43
|
+
export const anchorFlowId = (id: string) => `anchor:${id}`;
|
|
44
|
+
export const sourceHandle = (id: string) => `out:${id}`;
|
|
45
|
+
export const targetHandle = (id: string) => `in:${id}`;
|
|
46
|
+
export function parseHandle(
|
|
47
|
+
handle: string | null | undefined,
|
|
48
|
+
): { direction: "input" | "output"; id: string } | undefined {
|
|
49
|
+
if (handle?.startsWith("out:"))
|
|
50
|
+
return { direction: "output", id: handle.slice(4) };
|
|
51
|
+
if (handle?.startsWith("in:"))
|
|
52
|
+
return { direction: "input", id: handle.slice(3) };
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SpagbolCanvas,
|
|
3
|
+
type SpagbolCanvasProps,
|
|
4
|
+
type SpagbolLinkPresentation,
|
|
5
|
+
} from "./SpagbolCanvas.js";
|
|
6
|
+
export {
|
|
7
|
+
SpagbolNodeFrame,
|
|
8
|
+
SpagbolNodePort,
|
|
9
|
+
SpagbolNodePanel,
|
|
10
|
+
type SpagbolNodeFrameProps,
|
|
11
|
+
type SpagbolNodePortProps,
|
|
12
|
+
} from "./SpagbolNodeFrame.js";
|
|
13
|
+
export {
|
|
14
|
+
SpagbolControlNumber,
|
|
15
|
+
formatNumber,
|
|
16
|
+
type SpagbolControlNumberProps,
|
|
17
|
+
} from "./SpagbolControlNumber.js";
|
|
18
|
+
export {
|
|
19
|
+
defineNodeView,
|
|
20
|
+
useEditorSnapshot,
|
|
21
|
+
useGraphEditor,
|
|
22
|
+
useObservation,
|
|
23
|
+
useRenderTarget,
|
|
24
|
+
useSpagbol,
|
|
25
|
+
type SpagbolNodeView,
|
|
26
|
+
type SpagbolNodeViewProps,
|
|
27
|
+
type SpagbolCanvasFeatures,
|
|
28
|
+
type SpagbolTheme,
|
|
29
|
+
type SpagbolCanvasSlots,
|
|
30
|
+
} from "./context.js";
|
|
31
|
+
export { linkPath } from "./geometry.js";
|
|
32
|
+
export {
|
|
33
|
+
SpagbolCanvasFlow,
|
|
34
|
+
type SpagbolCanvasFlowProps,
|
|
35
|
+
} from "./SpagbolCanvasFlow.js";
|
|
36
|
+
export {
|
|
37
|
+
SpagbolControlZoom,
|
|
38
|
+
type SpagbolControlZoomProps,
|
|
39
|
+
} from "./SpagbolControlZoom.js";
|
|
40
|
+
export {
|
|
41
|
+
SpagbolCanvasMenu,
|
|
42
|
+
type SpagbolCanvasMenuProps,
|
|
43
|
+
} from "./SpagbolCanvasMenu.js";
|
|
44
|
+
export {
|
|
45
|
+
translateExtentForVisibleContent,
|
|
46
|
+
clampViewportToTranslateExtent,
|
|
47
|
+
visibleContentBounds,
|
|
48
|
+
infiniteCanvasExtent,
|
|
49
|
+
type CanvasBoundsItem,
|
|
50
|
+
type CanvasViewport,
|
|
51
|
+
type CanvasExtent,
|
|
52
|
+
} from "./viewport.js";
|
|
53
|
+
export {
|
|
54
|
+
SpagbolCanvasControls,
|
|
55
|
+
SpagbolCanvasActions,
|
|
56
|
+
SpagbolCanvasControlsZoom,
|
|
57
|
+
type SpagbolCanvasControlsOptions,
|
|
58
|
+
type SpagbolCanvasAction,
|
|
59
|
+
type SpagbolCanvasMenuItem,
|
|
60
|
+
type SpagbolCanvasMenuSeparator,
|
|
61
|
+
} from "./SpagbolCanvasControls.js";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
.spagbol-canvas .spagbol-canvas-link-controls {
|
|
2
|
+
position: fixed;
|
|
3
|
+
z-index: 0;
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: 4px;
|
|
7
|
+
padding: 4px;
|
|
8
|
+
border: 1px solid var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
9
|
+
background:
|
|
10
|
+
linear-gradient(var(--color-foreground, var(--spagbol-foreground, #fff)), var(--color-foreground, var(--spagbol-foreground, #fff))) left top / 100% 4px no-repeat,
|
|
11
|
+
linear-gradient(var(--color-foreground, var(--spagbol-foreground, #fff)), var(--color-foreground, var(--spagbol-foreground, #fff))) left bottom / 100% 4px no-repeat,
|
|
12
|
+
linear-gradient(var(--color-foreground, var(--spagbol-foreground, #fff)), var(--color-foreground, var(--spagbol-foreground, #fff))) left top / 4px 100% no-repeat,
|
|
13
|
+
linear-gradient(var(--color-foreground, var(--spagbol-foreground, #fff)), var(--color-foreground, var(--spagbol-foreground, #fff))) right top / 4px 100% no-repeat,
|
|
14
|
+
linear-gradient(var(--color-foreground, var(--spagbol-foreground, #fff)), var(--color-foreground, var(--spagbol-foreground, #fff))) left 26px / 100% 4px no-repeat;
|
|
15
|
+
color: var(--color-background, var(--spagbol-bg, #000));
|
|
16
|
+
font-size: 13px;
|
|
17
|
+
font-weight: 400;
|
|
18
|
+
pointer-events: all;
|
|
19
|
+
box-shadow: 6px 6px 0 var(--color-background-42, #0000006b);
|
|
20
|
+
transform-origin: center;
|
|
21
|
+
--edge-control-width: 96px;
|
|
22
|
+
--edge-weight-control-width: 74px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.spagbol-canvas .spagbol-canvas-link-controls-value-row {
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.spagbol-canvas .spagbol-control-number-link {
|
|
31
|
+
width: var(--edge-weight-control-width);
|
|
32
|
+
height: 22px;
|
|
33
|
+
padding: 0 4px;
|
|
34
|
+
border: 0;
|
|
35
|
+
background: var(--color-surface, var(--spagbol-surface, #000));
|
|
36
|
+
color: var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
37
|
+
cursor: ns-resize;
|
|
38
|
+
font-size: 13px;
|
|
39
|
+
font-weight: 400;
|
|
40
|
+
line-height: 20px;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
text-align: left;
|
|
43
|
+
text-overflow: clip;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
user-select: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.spagbol-canvas .spagbol-control-number-link:focus {
|
|
49
|
+
outline: 1px solid var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
50
|
+
outline-offset: 1px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.spagbol-canvas .spagbol-control-number-link-editing {
|
|
54
|
+
display: block;
|
|
55
|
+
background: var(--color-surface, var(--spagbol-surface, #000));
|
|
56
|
+
color: var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
57
|
+
cursor: text;
|
|
58
|
+
font-size: 13px;
|
|
59
|
+
font-weight: 400;
|
|
60
|
+
user-select: text;
|
|
61
|
+
-moz-appearance: textfield;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.spagbol-canvas .spagbol-control-number-link-editing::selection {
|
|
65
|
+
background: var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
66
|
+
color: var(--color-background, var(--spagbol-bg, #000));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.spagbol-canvas .spagbol-canvas-link-controls-enabled {
|
|
70
|
+
display: grid;
|
|
71
|
+
width: 22px;
|
|
72
|
+
height: 22px;
|
|
73
|
+
flex: 0 0 22px;
|
|
74
|
+
place-items: center;
|
|
75
|
+
padding: 0;
|
|
76
|
+
border: 0;
|
|
77
|
+
border-radius: 0;
|
|
78
|
+
background: var(--color-surface, var(--spagbol-surface, #000));
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.spagbol-canvas .spagbol-canvas-link-controls-enabled::after {
|
|
83
|
+
display: block;
|
|
84
|
+
width: 9px;
|
|
85
|
+
height: 9px;
|
|
86
|
+
border: 1px solid var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
87
|
+
border-radius: 50%;
|
|
88
|
+
content: '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.spagbol-canvas .spagbol-canvas-link-controls-enabled-active::after {
|
|
92
|
+
background: var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.spagbol-canvas .spagbol-canvas-link-controls-enabled:hover {
|
|
96
|
+
background: var(--color-surface, var(--spagbol-surface, #000));
|
|
97
|
+
color: var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.spagbol-canvas .spagbol-canvas-link-controls-enabled:focus-visible {
|
|
101
|
+
outline: 1px solid var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
102
|
+
outline-offset: 2px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.spagbol-canvas .spagbol-canvas-link-controls-mode {
|
|
106
|
+
width: var(--edge-control-width);
|
|
107
|
+
height: 22px;
|
|
108
|
+
padding: 0 4px;
|
|
109
|
+
border: 0;
|
|
110
|
+
border-radius: 0;
|
|
111
|
+
background: var(--color-surface, var(--spagbol-surface, #000));
|
|
112
|
+
color: var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
font-size: 13px;
|
|
115
|
+
font-weight: 400;
|
|
116
|
+
line-height: 20px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.spagbol-canvas .spagbol-canvas-link-controls-mode:focus {
|
|
120
|
+
outline: 1px solid var(--color-foreground, var(--spagbol-foreground, #fff));
|
|
121
|
+
outline-offset: 1px;
|
|
122
|
+
}
|
|
123
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GraphDocument } from "../core/index.js";
|
|
2
|
+
export type PortState = { inputs: Set<string>; outputs: Set<string>; replaced: Set<string> };
|
|
3
|
+
const documentCache = new WeakMap<GraphDocument, Map<string, PortState>>();
|
|
4
|
+
const connectionCache = new WeakMap<GraphDocument['links'], { enabled: string; state: Map<string, PortState> }>();
|
|
5
|
+
export const emptyPortState: PortState = { inputs: new Set(), outputs: new Set(), replaced: new Set() };
|
|
6
|
+
/** Value edits share connection state; rewiring/mode/enabled edits invalidate it. */
|
|
7
|
+
export function graphPortStates(graph: GraphDocument) {
|
|
8
|
+
const cached = documentCache.get(graph); if (cached) return cached;
|
|
9
|
+
const enabledKey = JSON.stringify(graph.nodes.map(node => [node.id, node.enabled !== false]));
|
|
10
|
+
const connections = Object.isFrozen(graph) ? connectionCache.get(graph.links) : undefined;
|
|
11
|
+
if (connections?.enabled === enabledKey) { documentCache.set(graph, connections.state); return connections.state; }
|
|
12
|
+
const result = new Map<string, PortState>();
|
|
13
|
+
const enabled = new Set(graph.nodes.filter(node => node.enabled !== false).map(node => node.id));
|
|
14
|
+
const state = (id: string) => { let value = result.get(id); if (!value) { value = { inputs: new Set(), outputs: new Set(), replaced: new Set() }; result.set(id, value); } return value; };
|
|
15
|
+
for (const link of graph.links) {
|
|
16
|
+
state(link.source.nodeId).outputs.add(link.source.portId);
|
|
17
|
+
if (link.target.type === 'port') {
|
|
18
|
+
state(link.target.nodeId).inputs.add(link.target.portId);
|
|
19
|
+
if (link.enabled !== false && link.mode === 'set' && enabled.has(link.source.nodeId)) state(link.target.nodeId).replaced.add(link.target.portId);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (Object.isFrozen(graph)) { documentCache.set(graph, result); connectionCache.set(graph.links, { enabled: enabledKey, state: result }); }
|
|
23
|
+
return result;
|
|
24
|
+
}
|