@workbench-kit/react 0.0.1-prototype.0 → 0.0.2-prototype.0.1.2

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.
Files changed (155) hide show
  1. package/package.json +33 -9
  2. package/src/index.ts +545 -43
  3. package/src/jdw/JdwPreview.tsx +38 -0
  4. package/src/jdw/JsonCodeEditorPane.tsx +274 -0
  5. package/src/jdw/builtins/renderBuiltinWidgetLeaf.tsx +29 -0
  6. package/src/jdw/builtins/renderBuiltinWidgetNode.tsx +126 -0
  7. package/src/jdw/createBuiltinJdwRegistry.ts +111 -0
  8. package/src/jdw/cssRenderBackend.tsx +134 -0
  9. package/src/jdw/document.ts +38 -0
  10. package/src/jdw/fixtures/jdw-fixtures.ts +52 -0
  11. package/src/jdw/fixtures/jdw-sample-screens.ts +522 -0
  12. package/src/jdw/index.ts +45 -0
  13. package/src/jdw/parse.ts +1 -0
  14. package/src/jdw/renderJdw.tsx +32 -0
  15. package/src/json-config/JsonConfigWorkbench.tsx +316 -0
  16. package/src/json-config/index.ts +6 -0
  17. package/src/json-config/json-config-editor-state.ts +42 -0
  18. package/src/layout/Panel.tsx +146 -1
  19. package/src/layout/SideBarViewFrame.tsx +367 -328
  20. package/src/layout/WorkbenchCanvas.tsx +849 -0
  21. package/src/layout/WorkbenchFullscreen.tsx +368 -0
  22. package/src/layout/WorkbenchLayout.tsx +5 -0
  23. package/src/layout/WorkbenchLayoutBase.tsx +294 -0
  24. package/src/layout/WorkbenchPropertyPanel.tsx +497 -0
  25. package/src/layout/WorkbenchSidebarActions.tsx +274 -0
  26. package/src/layout/WorkbenchTree.tsx +351 -0
  27. package/src/layout/layoutHelpers.ts +25 -0
  28. package/src/modal/ConfirmDialog.tsx +61 -53
  29. package/src/modal/Modal.tsx +295 -35
  30. package/src/overlay/ContextMenu.tsx +146 -145
  31. package/src/primitives/AbsoluteBox.tsx +46 -0
  32. package/src/primitives/Badge.tsx +12 -12
  33. package/src/primitives/Button.tsx +44 -14
  34. package/src/primitives/Checkbox.tsx +11 -5
  35. package/src/primitives/EmptyState.tsx +27 -26
  36. package/src/primitives/Field.tsx +38 -38
  37. package/src/primitives/IconButton.tsx +5 -2
  38. package/src/primitives/List.tsx +111 -0
  39. package/src/primitives/NumberInput.tsx +38 -0
  40. package/src/primitives/ScrollArea.tsx +42 -0
  41. package/src/primitives/Select.tsx +1 -12
  42. package/src/primitives/StatusBar.tsx +46 -0
  43. package/src/primitives/TextArea.tsx +36 -0
  44. package/src/primitives/TextInput.tsx +11 -2
  45. package/src/primitives/WorkbenchChrome.tsx +424 -0
  46. package/src/primitives/WorkbenchEditor.tsx +394 -0
  47. package/src/primitives/index.ts +105 -0
  48. package/src/primitives/select/Select.tsx +266 -0
  49. package/src/primitives/select/index.ts +2 -0
  50. package/src/primitives/select/options.ts +38 -0
  51. package/src/primitives/select/overlay.ts +63 -0
  52. package/src/primitives/select/select.app.css +9 -0
  53. package/src/primitives/select/select.css +140 -0
  54. package/src/primitives/select/types.ts +18 -0
  55. package/src/primitives/styles.css +955 -0
  56. package/src/scrollbars.css +69 -0
  57. package/src/styles.css +7738 -1762
  58. package/src/utils/codicon.ts +17 -0
  59. package/src/widget-asset/WidgetAssetEditor.tsx +140 -0
  60. package/src/widget-asset/WidgetAssetMetadataForm.tsx +82 -0
  61. package/src/widget-asset/WidgetAssetModeControls.tsx +42 -0
  62. package/src/widget-asset/WidgetAssetSourceEditor.tsx +50 -0
  63. package/src/widget-asset/WidgetAssetWorkbench.tsx +112 -0
  64. package/src/widget-asset/index.ts +17 -0
  65. package/src/widget-asset/widget-asset-document.ts +37 -0
  66. package/src/widget-asset/widget-asset-mode.ts +3 -0
  67. package/src/widget-studio/builtin-widget-asset-catalog.ts +12 -0
  68. package/src/widget-studio/create-widget-studio-workspace-editor.tsx +79 -0
  69. package/src/widget-studio/index.ts +7 -0
  70. package/src/widget-studio/resolve-widget-studio-asset-catalog.ts +26 -0
  71. package/src/widget-tree/WidgetAssetPalette.tsx +77 -0
  72. package/src/widget-tree/WidgetInspectorPanel.tsx +266 -0
  73. package/src/widget-tree/WidgetSourceEditor.tsx +56 -0
  74. package/src/widget-tree/WidgetTreeLab.tsx +272 -0
  75. package/src/widget-tree/WidgetTreeModeControls.tsx +44 -0
  76. package/src/widget-tree/WidgetTreeSidePanel.tsx +49 -0
  77. package/src/widget-tree/WidgetTreeView.tsx +97 -0
  78. package/src/widget-tree/WidgetTreeWorkbench.tsx +111 -0
  79. package/src/widget-tree/WidgetTreeWorkspaceShell.tsx +154 -0
  80. package/src/widget-tree/demo-registry.ts +32 -0
  81. package/src/widget-tree/demo-widget-assets.ts +4 -0
  82. package/src/widget-tree/index.ts +27 -0
  83. package/src/widget-tree/widget-tree-document.ts +20 -0
  84. package/src/widget-tree/widget-tree-layout.ts +31 -0
  85. package/src/widget-tree/widget-tree-mode.ts +7 -0
  86. package/src/widget-tree/widget-tree-play-helpers.ts +74 -0
  87. package/src/workbench/ActivityBar.tsx +52 -52
  88. package/src/workbench/ArtifactShell.tsx +397 -0
  89. package/src/workbench/CommandPalette.tsx +819 -0
  90. package/src/workbench/ConfirmationFlow.tsx +245 -0
  91. package/src/workbench/MarkdownPreview.tsx +151 -0
  92. package/src/workbench/ShortcutCommandBridge.tsx +368 -0
  93. package/src/workbench/SplitView.tsx +136 -136
  94. package/src/workbench/StatusBar.tsx +178 -123
  95. package/src/workbench/StructuredArtifactEditor.tsx +205 -0
  96. package/src/workbench/Timeline.tsx +302 -0
  97. package/src/workbench/WorkbenchCanvasShell.tsx +941 -0
  98. package/src/workbench/WorkbenchShell.tsx +74 -70
  99. package/src/workbench/WorkbenchStandaloneShell.tsx +318 -291
  100. package/src/workbench/auth/WorkbenchAuthGate.tsx +61 -0
  101. package/src/workbench/auth/WorkbenchLoginView.tsx +635 -0
  102. package/src/workbench/auth/index.ts +19 -0
  103. package/src/workbench/chat/ChatComposer.tsx +165 -148
  104. package/src/workbench/chat/ChatMessageItem.tsx +10 -1
  105. package/src/workbench/chat/index.ts +9 -0
  106. package/src/workbench/chat/slashCommand.ts +28 -0
  107. package/src/workbench/chat/useChatRuntimeState.ts +76 -0
  108. package/src/workbench/chat/useSlashCommandSuggest.ts +101 -0
  109. package/src/workbench/commands.ts +520 -490
  110. package/src/workbench/index.ts +443 -98
  111. package/src/workbench/keyboard.ts +53 -0
  112. package/src/workbench/schema/index.ts +48 -0
  113. package/src/workbench/schema/workbenchDocument.ts +31 -0
  114. package/src/workbench/schema/workbenchDocumentActions.ts +6 -0
  115. package/src/workbench/schema/workbenchDocumentAdapter.ts +9 -0
  116. package/src/workbench/schema/workbenchDocumentPatch.ts +13 -0
  117. package/src/workbench/schema/workbenchDocumentRenderer.tsx +702 -0
  118. package/src/workbench/settings/NavigationPanel.tsx +47 -0
  119. package/src/workbench/settings/SchemaForm.tsx +420 -0
  120. package/src/workbench/settings/SectionedPanel.tsx +415 -0
  121. package/src/workbench/settings/StructuredDataForm.tsx +539 -0
  122. package/src/workbench/settings/StructuredDataSchemaPanel.tsx +559 -0
  123. package/src/workbench/settings/StructuredDataSchemaPanelEmbed.tsx +21 -0
  124. package/src/workbench/settings/StructuredDataSchemaPanelFrame.tsx +76 -0
  125. package/src/workbench/settings/StructuredDataTableView.tsx +127 -0
  126. package/src/workbench/settings/WorkbenchPanelRegion.tsx +46 -0
  127. package/src/workbench/settings/WorkbenchSettingsModal.tsx +21 -12
  128. package/src/workbench/settings/WorkbenchSettingsNav.tsx +38 -22
  129. package/src/workbench/settings/index.ts +135 -0
  130. package/src/workbench/settings/panel-region.css +21 -0
  131. package/src/workbench/settings/sectionedPanelScrollSpy.ts +143 -0
  132. package/src/workbench/settings/structured-data-schema-panel.css +330 -0
  133. package/src/workbench/settings/structured-data-table-view.css +85 -0
  134. package/src/workbench/settings/structuredDataSchema.ts +886 -0
  135. package/src/workbench/shell.ts +15 -0
  136. package/src/workbench/shellState.ts +203 -203
  137. package/src/workbench/standalone.ts +101 -94
  138. package/src/workbench/status.ts +129 -0
  139. package/src/workbench/theme.ts +44 -0
  140. package/src/workbench/workspace/MultiProviderExplorer.tsx +625 -0
  141. package/src/workbench/workspace/WorkspaceDraftsContext.tsx +111 -0
  142. package/src/workbench/workspace/WorkspaceEditor.tsx +47 -12
  143. package/src/workbench/workspace/WorkspaceEditorPanel.tsx +334 -353
  144. package/src/workbench/workspace/WorkspaceExplorer.tsx +532 -524
  145. package/src/workbench/workspace/WorkspaceFileIcon.tsx +32 -2
  146. package/src/workbench/workspace/WorkspaceSearchPanel.tsx +113 -113
  147. package/src/workbench/workspace/WorkspaceSearchResults.tsx +47 -47
  148. package/src/workbench/workspace/index.ts +156 -98
  149. package/src/workbench/workspace/mimeType.ts +45 -0
  150. package/src/workbench/workspace/path.ts +10 -10
  151. package/src/workbench/workspace/search.ts +6 -6
  152. package/src/workbench/workspace/tree.ts +1 -1
  153. package/src/workbench/workspace/types.ts +10 -10
  154. package/src/workbench/workspace/useVirtualWorkspace.ts +111 -98
  155. package/src/workbench/workspace/workspaceJsonDiagnostics.ts +83 -0
@@ -0,0 +1,9 @@
1
+ export type {
2
+ WorkspaceToWorkbenchDocumentOptions,
3
+ WorkbenchToWorkspaceConversionOptions,
4
+ } from '@workbench-kit/contracts';
5
+ export {
6
+ buildWorkspaceDocumentLookup,
7
+ documentNodesToWorkspaceFiles,
8
+ workspaceFilesToDocument,
9
+ } from '@workbench-kit/contracts';
@@ -0,0 +1,13 @@
1
+ export type {
2
+ WorkbenchDocumentPatchError,
3
+ WorkbenchDocumentPatchResult,
4
+ WorkbenchDocumentPatchHistory,
5
+ WorkbenchDocumentPatchHistoryState,
6
+ } from '@workbench-kit/contracts';
7
+ export {
8
+ applyWorkbenchDocumentPatch,
9
+ assertWorkbenchDocument,
10
+ deserializeWorkbenchDocumentPatch,
11
+ initializeWorkbenchDocumentPatchHistory,
12
+ isWorkbenchDocumentSupported,
13
+ } from '@workbench-kit/contracts';
@@ -0,0 +1,702 @@
1
+ import type {
2
+ CSSProperties,
3
+ MouseEvent as ReactMouseEvent,
4
+ PointerEvent as ReactPointerEvent,
5
+ ReactNode,
6
+ } from 'react';
7
+ import { useEffect, useMemo, useRef, useState } from 'react';
8
+ import type { WorkbenchDocumentNode } from './workbenchDocument';
9
+ import type { WorkbenchDocumentRenderContext } from './workbenchDocument';
10
+ import type { WorkbenchPage, WorkbenchNodeLayout, WorkbenchVisualStyle } from './workbenchDocument';
11
+
12
+ const DEFAULT_NODE_MIN_SIZE = 12;
13
+ const RESIZE_HANDLE_SIZE = 10;
14
+
15
+ function wrapNodeClick(handler: (() => void) | undefined) {
16
+ if (!handler) {
17
+ return undefined;
18
+ }
19
+
20
+ return (event: ReactMouseEvent<HTMLElement>) => {
21
+ event.stopPropagation();
22
+ handler();
23
+ };
24
+ }
25
+
26
+ export interface WorkbenchDocumentRendererProps {
27
+ page: WorkbenchPage;
28
+ context?: WorkbenchDocumentRenderContext;
29
+ selectedNodeId?: string;
30
+ onNodeClick?: (nodeId: string) => void;
31
+ onNodeMove?: (nodeId: string, layout: WorkbenchNodeLayout) => void;
32
+ className?: string;
33
+ style?: CSSProperties;
34
+ }
35
+
36
+ type DragInteraction = 'move' | 'resize';
37
+
38
+ type DragState = {
39
+ nodeId: string;
40
+ interaction: DragInteraction;
41
+ startPointerX: number;
42
+ startPointerY: number;
43
+ startLayoutX: number;
44
+ startLayoutY: number;
45
+ startWidth?: number;
46
+ startHeight?: number;
47
+ constraints?: WorkbenchDocumentNode['constraints'];
48
+ pointerId: number;
49
+ };
50
+
51
+ type NodeLookup = Map<string, WorkbenchDocumentNode>;
52
+
53
+ function toCssPx(value?: number): string | undefined {
54
+ return value === undefined ? undefined : `${value}px`;
55
+ }
56
+
57
+ function styleFromNodeStyle(style?: WorkbenchVisualStyle): CSSProperties {
58
+ if (!style) {
59
+ return {};
60
+ }
61
+
62
+ return {
63
+ backgroundColor: style.backgroundColor,
64
+ borderColor: style.borderColor,
65
+ borderRadius: toCssPx(style.borderRadius),
66
+ borderStyle: style.borderWidth ? 'solid' : undefined,
67
+ borderWidth: style.borderWidth,
68
+ boxShadow: style.boxShadow,
69
+ color: style.color,
70
+ fontFamily: style.fontFamily,
71
+ fontSize: style.fontSize,
72
+ fontWeight: style.fontWeight,
73
+ opacity: style.opacity,
74
+ textAlign: style.textAlign,
75
+ };
76
+ }
77
+
78
+ function layoutToStyle(layout?: WorkbenchNodeLayout): CSSProperties {
79
+ if (!layout) {
80
+ return {};
81
+ }
82
+
83
+ return {
84
+ left: toCssPx(layout.x),
85
+ top: toCssPx(layout.y),
86
+ width: layout.width === undefined ? 'auto' : toCssPx(layout.width),
87
+ height: layout.height === undefined ? 'auto' : toCssPx(layout.height),
88
+ zIndex: layout.zIndex,
89
+ transform: layout.rotate ? `rotate(${layout.rotate}deg)` : undefined,
90
+ };
91
+ }
92
+
93
+ function scaleTransform(scale: number): string {
94
+ return scale === 1 ? '' : `scale(${scale})`;
95
+ }
96
+
97
+ function buildNodeStyle(
98
+ node: WorkbenchDocumentNode,
99
+ context: WorkbenchDocumentRenderContext | undefined,
100
+ ): CSSProperties {
101
+ const scale = context?.scale ?? 1;
102
+ const layout = layoutToStyle(node.layout);
103
+ const style = styleFromNodeStyle(node.style);
104
+ const selected = context?.selectedNodeIds?.includes(node.id) || false;
105
+ const hovered = context?.hoveredNodeId === node.id;
106
+
107
+ return {
108
+ ...layout,
109
+ ...style,
110
+ position:
111
+ node.layout?.x !== undefined ||
112
+ node.layout?.y !== undefined ||
113
+ node.layout?.width !== undefined ||
114
+ node.layout?.height !== undefined
115
+ ? 'absolute'
116
+ : undefined,
117
+ minWidth: layout.width ?? undefined,
118
+ minHeight: layout.height ?? undefined,
119
+ display: 'block',
120
+ transform:
121
+ `${node.layout?.rotate ? ` rotate(${node.layout.rotate}deg)` : ''}${scaleTransform(scale)}`.trim() ||
122
+ undefined,
123
+ outline: selected ? '2px solid #4f46e5' : hovered ? '2px dashed #94a3b8' : undefined,
124
+ boxSizing: 'border-box',
125
+ cursor: node.locked ? 'default' : 'pointer',
126
+ overflow: 'visible',
127
+ };
128
+ }
129
+
130
+ function isContainerNode(node: WorkbenchDocumentNode): boolean {
131
+ return (
132
+ node.type === 'frame' ||
133
+ node.type === 'group' ||
134
+ node.type === 'component' ||
135
+ node.type === 'instance'
136
+ );
137
+ }
138
+
139
+ function canResizeNode(node: WorkbenchDocumentNode): boolean {
140
+ return (
141
+ !node.locked && Boolean(node.layout?.width !== undefined && node.layout?.height !== undefined)
142
+ );
143
+ }
144
+
145
+ function clamp(
146
+ value: number | undefined,
147
+ min = DEFAULT_NODE_MIN_SIZE,
148
+ max?: number,
149
+ ): number | undefined {
150
+ if (value === undefined || !Number.isFinite(value)) {
151
+ return undefined;
152
+ }
153
+
154
+ let next = value;
155
+ if (next < min) {
156
+ next = min;
157
+ }
158
+ if (max !== undefined && next > max) {
159
+ next = max;
160
+ }
161
+ return next;
162
+ }
163
+
164
+ function applyConstraints(
165
+ layout: WorkbenchNodeLayout,
166
+ constraints: WorkbenchDocumentNode['constraints'] | undefined,
167
+ ): WorkbenchNodeLayout {
168
+ if (!constraints) {
169
+ return layout;
170
+ }
171
+ return {
172
+ ...layout,
173
+ width: clamp(layout.width, constraints.minWidth, constraints.maxWidth),
174
+ height: clamp(layout.height, constraints.minHeight, constraints.maxHeight),
175
+ };
176
+ }
177
+
178
+ function getResizeHandleLabel(node: WorkbenchDocumentNode, selected: boolean): string | undefined {
179
+ if (!selected || !canResizeNode(node)) {
180
+ return undefined;
181
+ }
182
+ return `Resize ${node.id}`;
183
+ }
184
+
185
+ type NodeRendererProps = {
186
+ node: WorkbenchDocumentNode;
187
+ style: CSSProperties;
188
+ children: ReactNode[];
189
+ onClick?: () => void;
190
+ onPointerDown?: (event: ReactPointerEvent<HTMLElement>) => void;
191
+ onResizeHandlePointerDown?: (event: ReactPointerEvent<HTMLElement>) => void;
192
+ resizeHandleLabel?: string;
193
+ };
194
+
195
+ function renderResizeHandle(
196
+ label: string | undefined,
197
+ onPointerDown?: (event: ReactPointerEvent<HTMLElement>) => void,
198
+ ) {
199
+ if (!label || !onPointerDown) {
200
+ return null;
201
+ }
202
+ return (
203
+ <div
204
+ aria-label={label}
205
+ style={{
206
+ position: 'absolute',
207
+ width: `${RESIZE_HANDLE_SIZE}px`,
208
+ height: `${RESIZE_HANDLE_SIZE}px`,
209
+ right: `${-RESIZE_HANDLE_SIZE / 2}px`,
210
+ bottom: `${-RESIZE_HANDLE_SIZE / 2}px`,
211
+ background: '#4f46e5',
212
+ borderRadius: 2,
213
+ cursor: 'nwse-resize',
214
+ zIndex: 2,
215
+ }}
216
+ onPointerDown={onPointerDown}
217
+ onClick={(event) => event.stopPropagation()}
218
+ />
219
+ );
220
+ }
221
+
222
+ const nodeRendererRegistry: Record<string, (params: NodeRendererProps) => ReactNode> = {
223
+ frame: ({
224
+ node,
225
+ style,
226
+ children,
227
+ onClick,
228
+ onPointerDown,
229
+ onResizeHandlePointerDown,
230
+ resizeHandleLabel,
231
+ }) => (
232
+ <div
233
+ key={node.id}
234
+ data-node-id={node.id}
235
+ style={style}
236
+ onClick={onClick}
237
+ onPointerDown={onPointerDown}
238
+ >
239
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>{children}</div>
240
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
241
+ </div>
242
+ ),
243
+ group: ({
244
+ node,
245
+ style,
246
+ children,
247
+ onClick,
248
+ onPointerDown,
249
+ onResizeHandlePointerDown,
250
+ resizeHandleLabel,
251
+ }) => (
252
+ <div
253
+ key={node.id}
254
+ data-node-id={node.id}
255
+ style={style}
256
+ onClick={onClick}
257
+ onPointerDown={onPointerDown}
258
+ >
259
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>{children}</div>
260
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
261
+ </div>
262
+ ),
263
+ component: ({
264
+ node,
265
+ style,
266
+ children,
267
+ onClick,
268
+ onPointerDown,
269
+ onResizeHandlePointerDown,
270
+ resizeHandleLabel,
271
+ }) => (
272
+ <div
273
+ key={node.id}
274
+ data-node-id={node.id}
275
+ style={style}
276
+ onClick={onClick}
277
+ onPointerDown={onPointerDown}
278
+ >
279
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>{children}</div>
280
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
281
+ </div>
282
+ ),
283
+ instance: ({
284
+ node,
285
+ style,
286
+ children,
287
+ onClick,
288
+ onPointerDown,
289
+ onResizeHandlePointerDown,
290
+ resizeHandleLabel,
291
+ }) => (
292
+ <div
293
+ key={node.id}
294
+ data-node-id={node.id}
295
+ style={style}
296
+ onClick={onClick}
297
+ onPointerDown={onPointerDown}
298
+ >
299
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>{children}</div>
300
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
301
+ </div>
302
+ ),
303
+ rectangle: ({
304
+ node,
305
+ style,
306
+ onClick,
307
+ onPointerDown,
308
+ onResizeHandlePointerDown,
309
+ resizeHandleLabel,
310
+ }) => (
311
+ <div
312
+ key={node.id}
313
+ data-node-id={node.id}
314
+ style={style}
315
+ onClick={wrapNodeClick(onClick)}
316
+ onPointerDown={onPointerDown}
317
+ >
318
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
319
+ </div>
320
+ ),
321
+ circle: ({
322
+ node,
323
+ style,
324
+ onClick,
325
+ onPointerDown,
326
+ onResizeHandlePointerDown,
327
+ resizeHandleLabel,
328
+ }) => (
329
+ <div
330
+ key={node.id}
331
+ data-node-id={node.id}
332
+ style={{ ...style, borderRadius: '50%' }}
333
+ onClick={wrapNodeClick(onClick)}
334
+ onPointerDown={onPointerDown}
335
+ >
336
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
337
+ </div>
338
+ ),
339
+ text: ({ node, style, onClick, onPointerDown, onResizeHandlePointerDown, resizeHandleLabel }) => (
340
+ <div
341
+ key={node.id}
342
+ data-node-id={node.id}
343
+ style={style}
344
+ onClick={wrapNodeClick(onClick)}
345
+ onPointerDown={onPointerDown}
346
+ >
347
+ {(node as { content?: string }).content ?? ''}
348
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
349
+ </div>
350
+ ),
351
+ image: ({
352
+ node,
353
+ style,
354
+ onClick,
355
+ onPointerDown,
356
+ onResizeHandlePointerDown,
357
+ resizeHandleLabel,
358
+ }) => {
359
+ const src = (node as { src?: string }).src ?? '';
360
+ return (
361
+ <div
362
+ key={node.id}
363
+ data-node-id={node.id}
364
+ style={style}
365
+ onClick={wrapNodeClick(onClick)}
366
+ onPointerDown={onPointerDown}
367
+ >
368
+ {src ? (
369
+ <img
370
+ src={src}
371
+ alt={node.name}
372
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
373
+ />
374
+ ) : null}
375
+ {!src ? (
376
+ <div
377
+ style={{
378
+ width: '100%',
379
+ height: '100%',
380
+ display: 'flex',
381
+ alignItems: 'center',
382
+ justifyContent: 'center',
383
+ color: '#94a3b8',
384
+ fontSize: 12,
385
+ background: '#1f2937',
386
+ border: '1px dashed #334155',
387
+ boxSizing: 'border-box',
388
+ }}
389
+ >
390
+ no image src
391
+ </div>
392
+ ) : null}
393
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
394
+ </div>
395
+ );
396
+ },
397
+ vector: ({
398
+ node,
399
+ style,
400
+ onClick,
401
+ onPointerDown,
402
+ onResizeHandlePointerDown,
403
+ resizeHandleLabel,
404
+ }) => (
405
+ <div
406
+ key={node.id}
407
+ data-node-id={node.id}
408
+ style={style}
409
+ onClick={wrapNodeClick(onClick)}
410
+ onPointerDown={onPointerDown}
411
+ >
412
+ {(node as { content?: string }).content ?? ''}
413
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
414
+ </div>
415
+ ),
416
+ unknown: ({
417
+ node,
418
+ style,
419
+ onClick,
420
+ onPointerDown,
421
+ onResizeHandlePointerDown,
422
+ resizeHandleLabel,
423
+ }) => (
424
+ <div
425
+ key={node.id}
426
+ data-node-id={node.id}
427
+ style={style}
428
+ onClick={wrapNodeClick(onClick)}
429
+ onPointerDown={onPointerDown}
430
+ >
431
+ {renderResizeHandle(resizeHandleLabel, onResizeHandlePointerDown)}
432
+ </div>
433
+ ),
434
+ };
435
+
436
+ function renderNode(
437
+ node: WorkbenchDocumentNode,
438
+ lookup: NodeLookup,
439
+ context: WorkbenchDocumentRenderContext | undefined,
440
+ onNodeClick: ((nodeId: string) => void) | undefined,
441
+ onNodePointerDown:
442
+ | ((
443
+ node: WorkbenchDocumentNode,
444
+ event: ReactPointerEvent<HTMLElement>,
445
+ interaction: DragInteraction,
446
+ ) => void)
447
+ | undefined,
448
+ resolveLayout: (node: WorkbenchDocumentNode) => WorkbenchNodeLayout | undefined,
449
+ ): ReactNode {
450
+ const nodeWithLayout = { ...node, layout: resolveLayout(node) };
451
+ const childRenderer = nodeRendererRegistry[node.type] ?? nodeRendererRegistry.unknown;
452
+ const children: ReactNode[] = [];
453
+
454
+ if (isContainerNode(node) && node.children?.length) {
455
+ for (const childId of node.children) {
456
+ const child = lookup.get(childId);
457
+ if (!child) {
458
+ continue;
459
+ }
460
+ children.push(
461
+ renderNode(child, lookup, context, onNodeClick, onNodePointerDown, resolveLayout),
462
+ );
463
+ }
464
+ }
465
+
466
+ const selectedNodeIds = context?.selectedNodeIds ?? [];
467
+ const isSelected = selectedNodeIds.includes(node.id);
468
+
469
+ const onClick = onNodeClick && !node.locked ? () => onNodeClick(node.id) : undefined;
470
+ const onMovePointerDown =
471
+ onNodePointerDown && !node.locked
472
+ ? (event: ReactPointerEvent<HTMLElement>) => onNodePointerDown(node, event, 'move')
473
+ : undefined;
474
+ const onResizePointerDown =
475
+ onNodePointerDown && canResizeNode(nodeWithLayout) && !node.locked
476
+ ? (event: ReactPointerEvent<HTMLElement>) =>
477
+ onNodePointerDown(nodeWithLayout, event, 'resize')
478
+ : undefined;
479
+ const style = buildNodeStyle(nodeWithLayout, context);
480
+
481
+ return childRenderer({
482
+ node: nodeWithLayout,
483
+ style,
484
+ children,
485
+ onClick,
486
+ onPointerDown: onMovePointerDown,
487
+ onResizeHandlePointerDown: onResizePointerDown,
488
+ resizeHandleLabel: getResizeHandleLabel(nodeWithLayout, isSelected),
489
+ });
490
+ }
491
+
492
+ export function WorkbenchDocumentRenderer({
493
+ page,
494
+ context,
495
+ selectedNodeId,
496
+ onNodeClick,
497
+ onNodeMove,
498
+ className,
499
+ style,
500
+ }: WorkbenchDocumentRendererProps) {
501
+ const [dragState, setDragState] = useState<DragState | null>(null);
502
+ const [dragPreview, setDragPreview] = useState<Record<string, WorkbenchNodeLayout>>({});
503
+ const dragPreviewRef = useRef<Record<string, WorkbenchNodeLayout>>({});
504
+ const hasMoveHandler = onNodeMove !== undefined;
505
+ const scale = context?.scale ?? 1;
506
+
507
+ useEffect(() => {
508
+ if (!dragState || !hasMoveHandler) {
509
+ return;
510
+ }
511
+
512
+ const computeNextMove = (pointerX: number, pointerY: number): WorkbenchNodeLayout => ({
513
+ x: dragState.startLayoutX + (pointerX - dragState.startPointerX) / scale,
514
+ y: dragState.startLayoutY + (pointerY - dragState.startPointerY) / scale,
515
+ });
516
+
517
+ const computeNextResize = (pointerX: number, pointerY: number): WorkbenchNodeLayout =>
518
+ applyConstraints(
519
+ {
520
+ x: dragState.startLayoutX,
521
+ y: dragState.startLayoutY,
522
+ width:
523
+ dragState.startWidth === undefined
524
+ ? undefined
525
+ : dragState.startWidth + (pointerX - dragState.startPointerX) / scale,
526
+ height:
527
+ dragState.startHeight === undefined
528
+ ? undefined
529
+ : dragState.startHeight + (pointerY - dragState.startPointerY) / scale,
530
+ },
531
+ dragState.constraints,
532
+ );
533
+
534
+ const applyPreview = (nextLayout: WorkbenchNodeLayout) => {
535
+ const next = { [dragState.nodeId]: nextLayout };
536
+ dragPreviewRef.current = next;
537
+ setDragPreview(next);
538
+ };
539
+
540
+ const onPointerMove = (event: PointerEvent) => {
541
+ if (event.pointerId !== dragState.pointerId) {
542
+ return;
543
+ }
544
+ event.preventDefault();
545
+ const nextLayout =
546
+ dragState.interaction === 'resize'
547
+ ? computeNextResize(event.clientX, event.clientY)
548
+ : computeNextMove(event.clientX, event.clientY);
549
+ applyPreview(nextLayout);
550
+ };
551
+
552
+ const onPointerUp = (event: PointerEvent) => {
553
+ if (event.pointerId !== dragState.pointerId) {
554
+ return;
555
+ }
556
+ event.preventDefault();
557
+ const finalLayout = dragPreviewRef.current[dragState.nodeId];
558
+ setDragState(null);
559
+ setDragPreview({});
560
+ dragPreviewRef.current = {};
561
+ if (!hasMoveHandler || !finalLayout || !onNodeMove) {
562
+ return;
563
+ }
564
+ onNodeMove(dragState.nodeId, finalLayout);
565
+ };
566
+
567
+ window.addEventListener('pointermove', onPointerMove, { passive: false });
568
+ window.addEventListener('pointerup', onPointerUp);
569
+ window.addEventListener('pointercancel', onPointerUp);
570
+
571
+ return () => {
572
+ window.removeEventListener('pointermove', onPointerMove);
573
+ window.removeEventListener('pointerup', onPointerUp);
574
+ window.removeEventListener('pointercancel', onPointerUp);
575
+ };
576
+ }, [dragState, hasMoveHandler, onNodeMove, scale]);
577
+
578
+ const handleInteractionStart = (
579
+ node: WorkbenchDocumentNode,
580
+ event: ReactPointerEvent<HTMLElement>,
581
+ interaction: DragInteraction,
582
+ ) => {
583
+ if (!hasMoveHandler) {
584
+ return;
585
+ }
586
+ if (event.button !== 0 || node.locked) {
587
+ return;
588
+ }
589
+
590
+ const layout = node.layout ?? {};
591
+ if (interaction === 'move' && (layout.x === undefined || layout.y === undefined)) {
592
+ return;
593
+ }
594
+ if (
595
+ interaction === 'resize' &&
596
+ (layout.x === undefined ||
597
+ layout.y === undefined ||
598
+ layout.width === undefined ||
599
+ layout.height === undefined)
600
+ ) {
601
+ return;
602
+ }
603
+
604
+ event.preventDefault();
605
+ event.stopPropagation();
606
+ setDragState({
607
+ nodeId: node.id,
608
+ interaction,
609
+ startPointerX: event.clientX,
610
+ startPointerY: event.clientY,
611
+ startLayoutX: layout.x ?? 0,
612
+ startLayoutY: layout.y ?? 0,
613
+ startWidth: interaction === 'resize' ? layout.width : undefined,
614
+ startHeight: interaction === 'resize' ? layout.height : undefined,
615
+ constraints: node.constraints,
616
+ pointerId: event.pointerId,
617
+ });
618
+ setDragPreview({});
619
+ dragPreviewRef.current = {};
620
+ };
621
+
622
+ const lookup = useMemo<NodeLookup>(() => {
623
+ const map = new Map<string, WorkbenchDocumentNode>();
624
+ for (const node of page.nodes) {
625
+ map.set(node.id, node);
626
+ }
627
+ return map;
628
+ }, [page.nodes]);
629
+
630
+ const rootNodeIds = useMemo(() => {
631
+ const childIdSet = new Set(
632
+ page.nodes.flatMap((node) => {
633
+ const nodeChildren =
634
+ 'children' in node ? (node as { children?: readonly string[] }).children : undefined;
635
+ return nodeChildren ?? [];
636
+ }),
637
+ );
638
+ return page.nodes
639
+ .filter((node) => !node.parentId && !childIdSet.has(node.id))
640
+ .map((node) => node.id);
641
+ }, [page.nodes]);
642
+
643
+ const normalizedSelectedIds = useMemo(() => {
644
+ const selectedNodeIds = context?.selectedNodeIds ?? [];
645
+ if (selectedNodeId && !selectedNodeIds.includes(selectedNodeId)) {
646
+ return [...selectedNodeIds, selectedNodeId];
647
+ }
648
+ return selectedNodeIds;
649
+ }, [context?.selectedNodeIds, selectedNodeId]);
650
+
651
+ const resolveLayout = (node: WorkbenchDocumentNode): WorkbenchNodeLayout | undefined => {
652
+ const preview = dragPreview[node.id];
653
+ const base = node.layout;
654
+
655
+ if (!preview && !base) {
656
+ return base;
657
+ }
658
+
659
+ if (!preview) {
660
+ return base;
661
+ }
662
+ if (!base) {
663
+ return preview;
664
+ }
665
+
666
+ return {
667
+ ...base,
668
+ ...preview,
669
+ };
670
+ };
671
+
672
+ const pageStyle: CSSProperties = {
673
+ position: 'relative',
674
+ width: page.width ? `${page.width}px` : '100%',
675
+ height: page.height ? `${page.height}px` : '100%',
676
+ background: page.background,
677
+ overflow: 'hidden',
678
+ ...style,
679
+ };
680
+
681
+ return (
682
+ <div className={className} style={pageStyle} data-page-id={page.id}>
683
+ {rootNodeIds.map((nodeId) => {
684
+ const node = lookup.get(nodeId);
685
+ if (!node) {
686
+ return null;
687
+ }
688
+ return renderNode(
689
+ node,
690
+ lookup,
691
+ {
692
+ ...context,
693
+ selectedNodeIds: normalizedSelectedIds,
694
+ },
695
+ onNodeClick,
696
+ hasMoveHandler ? handleInteractionStart : undefined,
697
+ resolveLayout,
698
+ );
699
+ })}
700
+ </div>
701
+ );
702
+ }