@dfosco/storyboard 0.7.1 → 0.8.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/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +13907 -10816
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +138 -128
- package/scaffold/AGENTS.md +9 -7
- package/scaffold/README.md +18 -0
- package/scaffold/deploy.yml +21 -0
- package/scaffold/gitignore +8 -14
- package/scaffold/preview.yml +40 -0
- package/scaffold/{manifest.json → scaffold.config.json} +73 -3
- package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
- package/scaffold/skills/canvas/SKILL.md +1 -1
- package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
- package/scaffold/skills/ship/SKILL.md +2 -2
- package/scaffold/skills/worktree/SKILL.md +1 -1
- package/scaffold/src/_prototype.jsx +17 -0
- package/scaffold/src/_story.jsx +13 -0
- package/scaffold/src/library/README.md +34 -0
- package/scaffold/src/library/_app.jsx +53 -0
- package/scaffold/src/library/_app.module.css +8 -0
- package/scaffold/src/library/create.jsx +10 -0
- package/scaffold/src/library/index.jsx +14 -0
- package/scaffold/src/library/mount.jsx +52 -0
- package/scaffold/src/library/prototypes-entry.jsx +57 -0
- package/scaffold/src/library/routes.jsx +205 -0
- package/scaffold/src/library/viewfinder.jsx +13 -0
- package/scaffold/src/library/workspace.jsx +17 -0
- package/scripts/aggregate-artifacts.mjs +257 -0
- package/src/core/artifact/operations.js +19 -13
- package/src/core/autosync/server.test.js +1 -1
- package/src/core/canvas/hot-pool.js +10 -11
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/server.js +324 -69
- package/src/core/canvas/server.test.js +365 -2
- package/src/core/canvas/terminal-server.js +144 -57
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/buildManifest.js +34 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/create.js +116 -18
- package/src/core/cli/dev.js +23 -35
- package/src/core/cli/index.js +21 -0
- package/src/core/cli/schemas.js +2 -3
- package/src/core/cli/serverUrl.js +24 -0
- package/src/core/cli/setup.js +14 -25
- package/src/core/cli/terminal-welcome.js +12 -6
- package/src/core/cli/updateVersion.js +42 -2
- package/src/core/cli/workleaf.js +140 -0
- package/src/core/comments/api.js +1 -1
- package/src/core/comments/queries.js +3 -0
- package/src/core/comments/ui/CommentWindow.jsx +3 -2
- package/src/core/data/artifactIndex.js +235 -0
- package/src/core/data/artifactIndex.test.js +203 -0
- package/src/core/data/artifactManifest.js +290 -0
- package/src/core/data/artifactManifest.test.js +207 -0
- package/src/core/data/viewfinder.js +47 -7
- package/src/core/index.js +6 -1
- package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
- package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
- package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
- package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
- package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
- package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
- package/src/core/lib/components/ui/dialog/index.js +4 -34
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
- package/src/core/lib/components/ui/label/label.jsx +1 -2
- package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
- package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
- package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
- package/src/core/lib/components/ui/panel/panel.jsx +11 -2
- package/src/core/lib/components/ui/separator/separator.jsx +6 -4
- package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
- package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
- package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
- package/src/core/mountStoryboardCore.js +234 -74
- package/src/core/mountStoryboardCore.test.js +174 -0
- package/src/core/rename-watcher/watcher.js +329 -36
- package/src/core/rename-watcher/watcher.test.js +253 -0
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +247 -0
- package/src/core/scaffold.js +4 -97
- package/src/core/stores/configSchema.js +41 -2
- package/src/core/stores/recentArtifacts.js +49 -2
- package/src/core/stores/recentArtifacts.test.js +59 -6
- package/src/core/stores/themeBootstrap.js +264 -0
- package/src/core/stores/themeBootstrap.test.js +211 -0
- package/src/core/stores/themeStore.test.js +154 -0
- package/src/core/stores/themeStore.ts +235 -220
- package/src/core/styles/generateThemesCss.js +63 -0
- package/src/core/styles/tailwind.css +12 -4
- package/src/core/styles/workspace-chrome.css +30 -0
- package/src/core/tools/handlers/paletteTheme.js +11 -8
- package/src/core/ui/ActionMenuButton.jsx +2 -1
- package/src/core/ui/CanvasCreateMenu.jsx +1 -19
- package/src/core/ui/CoreUIBar.jsx +10 -7
- package/src/core/ui/ThemeMenuButton.jsx +45 -30
- package/src/core/vite/server-plugin.js +13 -0
- package/src/core/workleaf/cleanup.js +102 -0
- package/src/core/workleaf/create.js +199 -0
- package/src/core/workleaf/delete.js +101 -0
- package/src/core/workleaf/list.js +57 -0
- package/src/core/workleaf/merge.js +238 -0
- package/src/core/workleaf/paths.js +146 -0
- package/src/core/workleaf/paths.test.js +230 -0
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +28 -21
- package/src/core/workshop/features/createFlow/server.js +5 -1
- package/src/core/workshop/features/createPrototype/server.js +5 -1
- package/src/core/workshop/features/templateIndex.js +28 -1
- package/src/core/worktree/port.js +48 -5
- package/src/core/worktree/port.test.js +42 -0
- package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
- package/src/internals/ArtifactForm/ArtifactForm.module.css +48 -66
- package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
- package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
- package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
- package/src/internals/CommandPalette/CommandPalette.jsx +25 -19
- package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
- package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
- package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
- package/src/internals/CommandPalette/createHelpers.js +30 -0
- package/src/internals/CreatePage/CreatePage.jsx +57 -0
- package/src/internals/CreatePage/CreatePage.module.css +34 -0
- package/src/internals/Dialog.jsx +51 -0
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +533 -0
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
- package/src/internals/Tooltip.jsx +39 -0
- package/src/internals/Viewfinder.jsx +196 -48
- package/src/internals/Viewfinder.module.css +72 -1
- package/src/internals/canvas/CanvasPage.jsx +267 -124
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/PageSelector.jsx +15 -1
- package/src/internals/canvas/PageSelector.module.css +14 -0
- package/src/internals/canvas/canvasApi.js +40 -5
- package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
- package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +131 -0
- package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
- package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
- package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
- package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
- package/src/internals/canvas/widgets/LinkPreview.module.css +2 -2
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +69 -10
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +114 -0
- package/src/internals/canvas/widgets/StorySetWidget.jsx +8 -4
- package/src/internals/canvas/widgets/StoryWidget.jsx +21 -38
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
- package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
- package/src/internals/canvas/widgets/WidgetChrome.module.css +15 -0
- package/src/internals/canvas/widgets/embedTheme.js +2 -2
- package/src/internals/canvas/widgets/expandUtils.js +19 -19
- package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
- package/src/internals/context.jsx +22 -1
- package/src/internals/context.test.jsx +47 -0
- package/src/internals/hooks/useArtifactIndex.js +31 -0
- package/src/internals/hooks/useArtifactIndex.test.js +45 -0
- package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
- package/src/internals/index.js +6 -0
- package/src/internals/loadConsumerWrapper.js +60 -0
- package/src/internals/story/ComponentSetPage.jsx +75 -34
- package/src/internals/story/StoryPage.jsx +16 -25
- package/src/internals/vite/data-plugin.js +313 -148
- package/src/internals/vite/data-plugin.test.js +11 -11
- package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
- package/terminal.config.json +1 -1
- package/src/core/lib/components/ui/card/card-action.jsx +0 -11
- package/src/core/lib/components/ui/card/card-content.jsx +0 -11
- package/src/core/lib/components/ui/card/card-description.jsx +0 -11
- package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
- package/src/core/lib/components/ui/card/card-header.jsx +0 -19
- package/src/core/lib/components/ui/card/card-title.jsx +0 -11
- package/src/core/lib/components/ui/card/card.jsx +0 -17
- package/src/core/lib/components/ui/card/index.js +0 -25
- package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/index.js +0 -13
- package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
- package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
- package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
- package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
- package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
- package/src/core/lib/components/ui/popover/index.js +0 -28
- package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
- package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
- package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
- package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
- package/src/core/lib/components/ui/popover/popover.jsx +0 -4
- package/src/core/lib/components/ui/select/index.js +0 -37
- package/src/core/lib/components/ui/select/select-content.jsx +0 -30
- package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
- package/src/core/lib/components/ui/select/select-group.jsx +0 -15
- package/src/core/lib/components/ui/select/select-item.jsx +0 -26
- package/src/core/lib/components/ui/select/select-label.jsx +0 -11
- package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
- package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
- package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
- package/src/core/lib/components/ui/select/select.jsx +0 -4
- package/src/core/lib/components/ui/sheet/index.js +0 -34
- package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
- package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
- package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
- package/src/core/lib/components/ui/toggle/index.js +0 -8
- package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
- package/src/core/lib/components/ui/toggle-group/index.js +0 -10
- package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
- package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
- package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
- package/src/internals/canvas/componentIsolate.jsx +0 -144
- package/src/internals/canvas/componentSetIsolate.jsx +0 -300
- package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
- package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
- package/src/primer/ThemeSync.jsx +0 -73
- package/src/primer/index.js +0 -1
|
@@ -12,6 +12,7 @@ import { getPasteRules } from '../../core/index.js'
|
|
|
12
12
|
import { isTerminalResizable, getTerminalDimensions } from '../../core/index.js'
|
|
13
13
|
import { getFlag, subscribeToStorage } from '../../core/index.js'
|
|
14
14
|
import { getCanvasZoom } from '../../core/index.js'
|
|
15
|
+
import { trackRecent as trackRecentArtifact } from '../../core/index.js'
|
|
15
16
|
import { registerSmoothCorners } from '../../core/utils/smoothCorners.js'
|
|
16
17
|
import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
|
|
17
18
|
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
@@ -28,7 +29,10 @@ import {
|
|
|
28
29
|
duplicateImage,
|
|
29
30
|
fetchGitHubEmbed,
|
|
30
31
|
getCanvas as getCanvasApi,
|
|
32
|
+
patchWidget as patchWidgetApi,
|
|
31
33
|
removeWidget as removeWidgetApi,
|
|
34
|
+
redoEvent as redoEventApi,
|
|
35
|
+
undoEvent as undoEventApi,
|
|
32
36
|
updateCanvas,
|
|
33
37
|
updateFolderMeta,
|
|
34
38
|
uploadImage,
|
|
@@ -732,6 +736,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
732
736
|
return () => { cancelled = true }
|
|
733
737
|
}, [canvasId])
|
|
734
738
|
|
|
739
|
+
// Track this canvas as a recent visit so it appears in the workspace
|
|
740
|
+
// Recent tab and the command palette regardless of how the user arrived
|
|
741
|
+
// (card click, command palette, direct URL, link from another canvas, …).
|
|
742
|
+
useEffect(() => {
|
|
743
|
+
if (!canvasId) return
|
|
744
|
+
const label = canvasMeta?.title || canvas?.title || name || canvasId.split('/').pop() || canvasId
|
|
745
|
+
trackRecentArtifact('canvas', canvasId, label)
|
|
746
|
+
}, [canvasId, canvasMeta, canvas, name])
|
|
747
|
+
|
|
735
748
|
// Centralized list of component export names.
|
|
736
749
|
// When jsxExports is available, use it (discovers new exports not yet in sources).
|
|
737
750
|
// When jsxExports is null (module import failed), fall back to sources so iframes
|
|
@@ -950,27 +963,66 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
950
963
|
}
|
|
951
964
|
}
|
|
952
965
|
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
966
|
+
// Per-widget debounced PATCH. Each widget gets its own pending props
|
|
967
|
+
// accumulator + timer so concurrent edits to different widgets don't stomp
|
|
968
|
+
// on one another. Successful patches return event ids — we push them onto
|
|
969
|
+
// the undo stack via undoRedo.trackMany so the toolbar Undo button targets
|
|
970
|
+
// exactly the right granular events.
|
|
971
|
+
const widgetPatchDebouncersRef = useRef(new Map())
|
|
972
|
+
const flushAllPendingWidgetPatches = useCallback(() => {
|
|
973
|
+
for (const entry of widgetPatchDebouncersRef.current.values()) {
|
|
974
|
+
if (entry.timer) {
|
|
975
|
+
clearTimeout(entry.timer)
|
|
976
|
+
entry.timer = null
|
|
977
|
+
entry.flushNow()
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}, [])
|
|
981
|
+
const cancelAllPendingWidgetPatches = useCallback(() => {
|
|
982
|
+
for (const entry of widgetPatchDebouncersRef.current.values()) {
|
|
983
|
+
if (entry.timer) {
|
|
984
|
+
clearTimeout(entry.timer)
|
|
985
|
+
entry.timer = null
|
|
986
|
+
entry.pendingProps = {}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}, [])
|
|
990
|
+
// patchWidgetDebounced(canvasId, widgetId, propsDelta) — coalesces multiple
|
|
991
|
+
// PATCHes per widget within DEBOUNCE_MS into a single request.
|
|
992
|
+
const patchWidgetDebounced = useCallback((targetCanvasId, widgetId, propsDelta) => {
|
|
993
|
+
if (!widgetId || !propsDelta) return
|
|
994
|
+
const map = widgetPatchDebouncersRef.current
|
|
995
|
+
let entry = map.get(widgetId)
|
|
996
|
+
if (!entry) {
|
|
997
|
+
entry = {
|
|
998
|
+
pendingProps: {},
|
|
999
|
+
timer: null,
|
|
1000
|
+
flushNow: () => {
|
|
1001
|
+
const props = entry.pendingProps
|
|
1002
|
+
entry.pendingProps = {}
|
|
1003
|
+
if (Object.keys(props).length === 0) return
|
|
1004
|
+
dirtyRef.current = true
|
|
1005
|
+
queueWrite(async () => {
|
|
1006
|
+
try {
|
|
1007
|
+
const res = await patchWidgetApi(targetCanvasId, widgetId, { props })
|
|
1008
|
+
if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
console.error('[canvas] Failed to patch widget:', err)
|
|
1011
|
+
}
|
|
1012
|
+
})
|
|
1013
|
+
},
|
|
1014
|
+
}
|
|
1015
|
+
map.set(widgetId, entry)
|
|
1016
|
+
}
|
|
1017
|
+
Object.assign(entry.pendingProps, propsDelta)
|
|
1018
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
1019
|
+
entry.timer = setTimeout(() => {
|
|
1020
|
+
entry.timer = null
|
|
1021
|
+
entry.flushNow()
|
|
1022
|
+
}, 500)
|
|
1023
|
+
}, [undoRedo])
|
|
971
1024
|
|
|
972
1025
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
973
|
-
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
974
1026
|
// Snap width/height to grid when snap is enabled
|
|
975
1027
|
const snapped = { ...updates }
|
|
976
1028
|
if (snapEnabled && snapGridSize) {
|
|
@@ -985,11 +1037,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
985
1037
|
const next = prev.map((w) =>
|
|
986
1038
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
987
1039
|
)
|
|
988
|
-
dirtyRef.current = true
|
|
989
|
-
debouncedSave(canvasId, next)
|
|
990
1040
|
return next
|
|
991
1041
|
})
|
|
992
|
-
|
|
1042
|
+
dirtyRef.current = true
|
|
1043
|
+
patchWidgetDebounced(canvasId, widgetId, snapped)
|
|
1044
|
+
}, [canvasId, patchWidgetDebounced, snapEnabled, snapGridSize])
|
|
993
1045
|
|
|
994
1046
|
// Stable ref so handleWidgetSelect can flip a "done" agent back to
|
|
995
1047
|
// "running" without re-creating itself on every widget update.
|
|
@@ -1002,35 +1054,60 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1002
1054
|
const defaultMeta = hubRoleOptions.find((r) => r.id === defaultHubRole) || null
|
|
1003
1055
|
const fallbackRoleId = defaultMeta?.id || 'member'
|
|
1004
1056
|
|
|
1005
|
-
undoRedo.snapshot(stateRef.current, 'edit-role', widgetId)
|
|
1006
1057
|
setLocalWidgets((prev) => {
|
|
1007
1058
|
if (!prev) return prev
|
|
1008
1059
|
|
|
1009
1060
|
const componentIds = getConnectedComponent(widgetId, localConnectors)
|
|
1010
1061
|
const scopeHas = (id) => componentIds.has(id)
|
|
1062
|
+
const roleChanges = [] // { widgetId, role }
|
|
1011
1063
|
const next = prev.map((widget) => {
|
|
1012
1064
|
if (widget.id === widgetId) {
|
|
1065
|
+
roleChanges.push({ widgetId: widget.id, role: targetRole })
|
|
1013
1066
|
return { ...widget, props: { ...widget.props, role: targetRole } }
|
|
1014
1067
|
}
|
|
1015
1068
|
if (!roleMeta || roleMeta.type !== 'unique') return widget
|
|
1016
1069
|
if (widget.type !== 'agent' && widget.type !== 'prompt') return widget
|
|
1017
1070
|
if (!scopeHas(widget.id)) return widget
|
|
1018
1071
|
if ((widget.props?.role || fallbackRoleId) !== targetRole) return widget
|
|
1072
|
+
roleChanges.push({ widgetId: widget.id, role: fallbackRoleId })
|
|
1019
1073
|
return { ...widget, props: { ...widget.props, role: fallbackRoleId } }
|
|
1020
1074
|
})
|
|
1021
1075
|
|
|
1022
1076
|
dirtyRef.current = true
|
|
1023
|
-
|
|
1077
|
+
// Persist as a single batch so undo restores all role flips atomically.
|
|
1078
|
+
if (roleChanges.length > 0) {
|
|
1079
|
+
const operations = roleChanges.map((c) => ({
|
|
1080
|
+
op: 'update-widget',
|
|
1081
|
+
widgetId: c.widgetId,
|
|
1082
|
+
props: { role: c.role },
|
|
1083
|
+
}))
|
|
1084
|
+
queueWrite(async () => {
|
|
1085
|
+
try {
|
|
1086
|
+
const res = await batchOperations(canvasId, operations)
|
|
1087
|
+
const ids = (res?.results || [])
|
|
1088
|
+
.map((r) => r?.eventId)
|
|
1089
|
+
.filter(Boolean)
|
|
1090
|
+
if (ids.length > 0) undoRedo.trackMany(ids)
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
console.error('[canvas] Failed to persist role change:', err)
|
|
1093
|
+
}
|
|
1094
|
+
})
|
|
1095
|
+
}
|
|
1024
1096
|
return next
|
|
1025
1097
|
})
|
|
1026
|
-
}, [canvasId,
|
|
1098
|
+
}, [canvasId, undoRedo, defaultHubRole, hubRoleOptions, localConnectors])
|
|
1027
1099
|
|
|
1028
1100
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
1029
|
-
// Cancel any pending
|
|
1030
|
-
//
|
|
1031
|
-
|
|
1101
|
+
// Cancel any pending widget patches that target this widget — they
|
|
1102
|
+
// would race against the delete and leave a stale widget_updated event
|
|
1103
|
+
// in the JSONL.
|
|
1104
|
+
const debouncer = widgetPatchDebouncersRef.current.get(widgetId)
|
|
1105
|
+
if (debouncer?.timer) {
|
|
1106
|
+
clearTimeout(debouncer.timer)
|
|
1107
|
+
debouncer.timer = null
|
|
1108
|
+
debouncer.pendingProps = {}
|
|
1109
|
+
}
|
|
1032
1110
|
|
|
1033
|
-
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
1034
1111
|
markWidgetDeleted(widgetId)
|
|
1035
1112
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
1036
1113
|
// Cascade: remove connectors referencing this widget
|
|
@@ -1039,27 +1116,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1039
1116
|
if (orphaned.length === 0) return prev
|
|
1040
1117
|
for (const c of orphaned) {
|
|
1041
1118
|
markConnectorDeleted(c.id)
|
|
1042
|
-
queueWrite(() =>
|
|
1043
|
-
|
|
1119
|
+
queueWrite(async () => {
|
|
1120
|
+
try {
|
|
1121
|
+
const res = await removeConnectorApi(canvasId, c.id)
|
|
1122
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
1123
|
+
} catch (err) {
|
|
1044
1124
|
console.error('[canvas] Failed to remove orphaned connector:', err)
|
|
1045
|
-
|
|
1046
|
-
)
|
|
1125
|
+
}
|
|
1126
|
+
})
|
|
1047
1127
|
}
|
|
1048
1128
|
return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
|
|
1049
1129
|
})
|
|
1050
1130
|
dirtyRef.current = true
|
|
1051
|
-
queueWrite(() =>
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1131
|
+
queueWrite(async () => {
|
|
1132
|
+
try {
|
|
1133
|
+
const res = await removeWidgetApi(canvasId, widgetId)
|
|
1134
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
1137
|
+
}
|
|
1138
|
+
})
|
|
1139
|
+
}, [canvasId, undoRedo, markWidgetDeleted, markConnectorDeleted])
|
|
1056
1140
|
|
|
1057
1141
|
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
1058
1142
|
try {
|
|
1059
|
-
undoRedo.snapshot(stateRef.current, 'connector-add')
|
|
1060
1143
|
const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
|
|
1061
1144
|
if (result.success && result.connector) {
|
|
1062
1145
|
setLocalConnectors((prev) => [...prev, result.connector])
|
|
1146
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
1063
1147
|
}
|
|
1064
1148
|
} catch (err) {
|
|
1065
1149
|
console.error('[canvas] Failed to add connector:', err)
|
|
@@ -1099,15 +1183,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1099
1183
|
}, [handleConnectorAdd])
|
|
1100
1184
|
|
|
1101
1185
|
const handleConnectorRemove = useCallback((connectorId) => {
|
|
1102
|
-
undoRedo.snapshot(stateRef.current, 'connector-remove')
|
|
1103
1186
|
markConnectorDeleted(connectorId)
|
|
1104
1187
|
setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
|
|
1105
1188
|
dirtyRef.current = true
|
|
1106
|
-
queueWrite(() =>
|
|
1107
|
-
|
|
1189
|
+
queueWrite(async () => {
|
|
1190
|
+
try {
|
|
1191
|
+
const res = await removeConnectorApi(canvasId, connectorId)
|
|
1192
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
1193
|
+
} catch (err) {
|
|
1108
1194
|
console.error('[canvas] Failed to remove connector:', err)
|
|
1109
|
-
|
|
1110
|
-
)
|
|
1195
|
+
}
|
|
1196
|
+
})
|
|
1111
1197
|
}, [canvasId, undoRedo, markConnectorDeleted])
|
|
1112
1198
|
|
|
1113
1199
|
// Connector drag state
|
|
@@ -1293,7 +1379,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1293
1379
|
if (dupResult.success) copyProps.src = dupResult.filename
|
|
1294
1380
|
}
|
|
1295
1381
|
|
|
1296
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1297
1382
|
const result = await addWidgetApi(canvasId, {
|
|
1298
1383
|
type: widget.type,
|
|
1299
1384
|
props: copyProps,
|
|
@@ -1305,6 +1390,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1305
1390
|
}
|
|
1306
1391
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1307
1392
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1393
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
1308
1394
|
}
|
|
1309
1395
|
} catch (err) {
|
|
1310
1396
|
console.error('[canvas] Failed to copy widget:', err)
|
|
@@ -1316,8 +1402,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1316
1402
|
if (!widget) return
|
|
1317
1403
|
const widgets = [widget]
|
|
1318
1404
|
|
|
1319
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1320
|
-
|
|
1321
1405
|
const occupied = new Set(
|
|
1322
1406
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1323
1407
|
)
|
|
@@ -1378,8 +1462,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1378
1462
|
const newWidgets = []
|
|
1379
1463
|
const newConnectors = []
|
|
1380
1464
|
const refMap = response.refs || {}
|
|
1465
|
+
const eventIds = []
|
|
1381
1466
|
|
|
1382
1467
|
for (const result of response.results) {
|
|
1468
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
1383
1469
|
if (result.op === 'create-widget' && result.widget) {
|
|
1384
1470
|
newWidgets.push(result.widget)
|
|
1385
1471
|
}
|
|
@@ -1409,6 +1495,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1409
1495
|
if (newConnectors.length > 0) {
|
|
1410
1496
|
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1411
1497
|
}
|
|
1498
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
1412
1499
|
} catch (err) {
|
|
1413
1500
|
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1414
1501
|
}
|
|
@@ -1419,9 +1506,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1419
1506
|
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1420
1507
|
if (widgets.length === 0) return
|
|
1421
1508
|
|
|
1422
|
-
// Single undo snapshot for the entire batch
|
|
1423
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1424
|
-
|
|
1425
1509
|
// Compute occupied positions to find free offset
|
|
1426
1510
|
const occupied = new Set(
|
|
1427
1511
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
@@ -1435,6 +1519,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1435
1519
|
while (anyOccupied()) offset++
|
|
1436
1520
|
|
|
1437
1521
|
const newWidgets = []
|
|
1522
|
+
const eventIds = []
|
|
1438
1523
|
for (const widget of widgets) {
|
|
1439
1524
|
const position = {
|
|
1440
1525
|
x: (widget.position?.x ?? 0) + offset * 40,
|
|
@@ -1460,6 +1545,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1460
1545
|
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
1461
1546
|
}
|
|
1462
1547
|
newWidgets.push(result.widget)
|
|
1548
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
1463
1549
|
}
|
|
1464
1550
|
} catch (err) {
|
|
1465
1551
|
console.error('[canvas] Failed to duplicate widget:', err)
|
|
@@ -1470,6 +1556,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1470
1556
|
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1471
1557
|
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1472
1558
|
}
|
|
1559
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
1473
1560
|
}, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
|
|
1474
1561
|
|
|
1475
1562
|
// Duplicate selected widgets WITH connectors (Cmd+Shift+D)
|
|
@@ -1479,8 +1566,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1479
1566
|
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1480
1567
|
if (widgets.length === 0) return
|
|
1481
1568
|
|
|
1482
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1483
|
-
|
|
1484
1569
|
// Compute offset — same logic as handleDuplicateSelected
|
|
1485
1570
|
const occupied = new Set(
|
|
1486
1571
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
@@ -1558,8 +1643,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1558
1643
|
const newWidgets = []
|
|
1559
1644
|
const newConnectors = []
|
|
1560
1645
|
const refMap = response.refs || {}
|
|
1646
|
+
const eventIds = []
|
|
1561
1647
|
|
|
1562
1648
|
for (const result of response.results) {
|
|
1649
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
1563
1650
|
if (result.op === 'create-widget' && result.widget) {
|
|
1564
1651
|
newWidgets.push(result.widget)
|
|
1565
1652
|
}
|
|
@@ -1590,6 +1677,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1590
1677
|
if (newConnectors.length > 0) {
|
|
1591
1678
|
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1592
1679
|
}
|
|
1680
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
1593
1681
|
} catch (err) {
|
|
1594
1682
|
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1595
1683
|
}
|
|
@@ -1636,6 +1724,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1636
1724
|
: [],
|
|
1637
1725
|
createdAt: snapshot.createdAt ?? null,
|
|
1638
1726
|
updatedAt: snapshot.updatedAt ?? null,
|
|
1727
|
+
// PR-specific metadata (state, branches, diff stats)
|
|
1728
|
+
state: snapshot.state ?? null,
|
|
1729
|
+
merged: typeof snapshot.merged === 'boolean' ? snapshot.merged : null,
|
|
1730
|
+
draft: typeof snapshot.draft === 'boolean' ? snapshot.draft : null,
|
|
1731
|
+
baseRef: typeof snapshot.baseRef === 'string' ? snapshot.baseRef : null,
|
|
1732
|
+
headRef: typeof snapshot.headRef === 'string' ? snapshot.headRef : null,
|
|
1733
|
+
additions: typeof snapshot.additions === 'number' ? snapshot.additions : null,
|
|
1734
|
+
deletions: typeof snapshot.deletions === 'number' ? snapshot.deletions : null,
|
|
1735
|
+
changedFiles: typeof snapshot.changedFiles === 'number' ? snapshot.changedFiles : null,
|
|
1639
1736
|
fetchedAt: new Date().toISOString(),
|
|
1640
1737
|
},
|
|
1641
1738
|
}
|
|
@@ -1654,17 +1751,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1654
1751
|
}, [buildGitHubPreviewUpdates, handleWidgetUpdate])
|
|
1655
1752
|
|
|
1656
1753
|
const debouncedSourceSave = useRef(
|
|
1657
|
-
debounce((canvasId, sources) => {
|
|
1658
|
-
queueWrite(() =>
|
|
1659
|
-
|
|
1754
|
+
debounce((canvasId, sources, onResult) => {
|
|
1755
|
+
queueWrite(async () => {
|
|
1756
|
+
try {
|
|
1757
|
+
const res = await updateCanvas(canvasId, { sources })
|
|
1758
|
+
if (typeof onResult === 'function') onResult(res)
|
|
1759
|
+
} catch (err) {
|
|
1660
1760
|
console.error('[canvas] Failed to save sources:', err)
|
|
1661
|
-
|
|
1662
|
-
)
|
|
1761
|
+
}
|
|
1762
|
+
})
|
|
1663
1763
|
}, 2000)
|
|
1664
1764
|
).current
|
|
1665
1765
|
|
|
1666
1766
|
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
1667
|
-
undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
|
|
1668
1767
|
const snapped = { ...updates }
|
|
1669
1768
|
if (snapEnabled && snapGridSize) {
|
|
1670
1769
|
if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
|
|
@@ -1679,7 +1778,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1679
1778
|
debouncedSourceSave(canvasId, next)
|
|
1680
1779
|
return next
|
|
1681
1780
|
})
|
|
1682
|
-
}, [canvasId, debouncedSourceSave,
|
|
1781
|
+
}, [canvasId, debouncedSourceSave, snapEnabled, snapGridSize])
|
|
1683
1782
|
|
|
1684
1783
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
1685
1784
|
setWidgetDragging(false)
|
|
@@ -1697,7 +1796,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1697
1796
|
// Suppress the click-based selection reset that fires after pointerup
|
|
1698
1797
|
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
1699
1798
|
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
1700
|
-
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
1701
1799
|
|
|
1702
1800
|
// Compute delta from the dragged widget's old position
|
|
1703
1801
|
const isJsx = dragId.startsWith('jsx-')
|
|
@@ -1713,33 +1811,46 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1713
1811
|
const dx = rounded.x - oldPos.x
|
|
1714
1812
|
const dy = rounded.y - oldPos.y
|
|
1715
1813
|
|
|
1716
|
-
|
|
1814
|
+
cancelAllPendingWidgetPatches()
|
|
1717
1815
|
|
|
1718
|
-
// Update JSON widget positions
|
|
1816
|
+
// Update JSON widget positions. We collect move ops inside the setState
|
|
1817
|
+
// updater (which runs during commit) and fire the batch from there to
|
|
1818
|
+
// avoid the "set before commit" timing trap.
|
|
1719
1819
|
setLocalWidgets((prev) => {
|
|
1720
1820
|
if (!prev) return prev
|
|
1821
|
+
const widgetMoveOps = []
|
|
1721
1822
|
const next = prev.map((w) => {
|
|
1722
|
-
if (w.id === dragId)
|
|
1823
|
+
if (w.id === dragId) {
|
|
1824
|
+
widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: rounded })
|
|
1825
|
+
return { ...w, position: rounded }
|
|
1826
|
+
}
|
|
1723
1827
|
if (ids.has(w.id)) {
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
|
|
1728
|
-
y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
|
|
1729
|
-
},
|
|
1828
|
+
const newPos = {
|
|
1829
|
+
x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
|
|
1830
|
+
y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
|
|
1730
1831
|
}
|
|
1832
|
+
widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: newPos })
|
|
1833
|
+
return { ...w, position: newPos }
|
|
1731
1834
|
}
|
|
1732
1835
|
return w
|
|
1733
1836
|
})
|
|
1734
1837
|
dirtyRef.current = true
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1838
|
+
if (widgetMoveOps.length > 0) {
|
|
1839
|
+
queueWrite(async () => {
|
|
1840
|
+
try {
|
|
1841
|
+
const res = await batchOperations(canvasId, widgetMoveOps)
|
|
1842
|
+
const evIds = (res?.results || []).map((r) => r?.eventId).filter(Boolean)
|
|
1843
|
+
if (evIds.length > 0) undoRedo.trackMany(evIds)
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
console.error('[canvas] Failed to save multi-move:', err)
|
|
1846
|
+
}
|
|
1847
|
+
})
|
|
1848
|
+
}
|
|
1739
1849
|
return next
|
|
1740
1850
|
})
|
|
1741
1851
|
|
|
1742
|
-
// Update JSX source positions
|
|
1852
|
+
// Update JSX source positions — still a single source_updated event;
|
|
1853
|
+
// not yet wired to per-source undo tracking.
|
|
1743
1854
|
setLocalSources((prev) => {
|
|
1744
1855
|
const current = Array.isArray(prev) ? prev : []
|
|
1745
1856
|
let changed = false
|
|
@@ -1775,7 +1886,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1775
1886
|
}
|
|
1776
1887
|
|
|
1777
1888
|
if (dragId.startsWith('jsx-')) {
|
|
1778
|
-
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
1779
1889
|
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
1780
1890
|
setLocalSources((prev) => {
|
|
1781
1891
|
const current = Array.isArray(prev) ? prev : []
|
|
@@ -1792,21 +1902,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1792
1902
|
return
|
|
1793
1903
|
}
|
|
1794
1904
|
|
|
1795
|
-
|
|
1796
|
-
|
|
1905
|
+
// Single-widget move — granular PATCH /widget so the move is its own
|
|
1906
|
+
// undoable event.
|
|
1907
|
+
cancelAllPendingWidgetPatches()
|
|
1797
1908
|
setLocalWidgets((prev) => {
|
|
1798
1909
|
if (!prev) return prev
|
|
1799
1910
|
const next = prev.map((w) =>
|
|
1800
1911
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
1801
1912
|
)
|
|
1802
1913
|
dirtyRef.current = true
|
|
1803
|
-
queueWrite(() =>
|
|
1804
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1805
|
-
.catch((err) => console.error('[canvas] Failed to save widget position:', err))
|
|
1806
|
-
)
|
|
1807
1914
|
return next
|
|
1808
1915
|
})
|
|
1809
|
-
|
|
1916
|
+
queueWrite(async () => {
|
|
1917
|
+
try {
|
|
1918
|
+
const res = await patchWidgetApi(canvasId, dragId, { position: rounded })
|
|
1919
|
+
if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
1922
|
+
}
|
|
1923
|
+
})
|
|
1924
|
+
}, [canvasId, undoRedo, cancelAllPendingWidgetPatches, transitionPeers, clearDragPreview])
|
|
1810
1925
|
|
|
1811
1926
|
// Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
|
|
1812
1927
|
useEffect(() => {
|
|
@@ -2241,7 +2356,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2241
2356
|
if (result.hotSession?.webglReady) {
|
|
2242
2357
|
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
2243
2358
|
}
|
|
2244
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2245
2359
|
// Guard against duplicates: HMR may have already pushed the new widget
|
|
2246
2360
|
// into localWidgets before the API response returns (race condition).
|
|
2247
2361
|
setLocalWidgets((prev) => {
|
|
@@ -2250,6 +2364,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2250
2364
|
return [...arr, result.widget]
|
|
2251
2365
|
})
|
|
2252
2366
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2367
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
2253
2368
|
}
|
|
2254
2369
|
} catch (err) {
|
|
2255
2370
|
console.error('[canvas] Failed to add widget:', err)
|
|
@@ -2268,13 +2383,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2268
2383
|
position: pos,
|
|
2269
2384
|
})
|
|
2270
2385
|
if (result.success && result.widget) {
|
|
2271
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2272
2386
|
setLocalWidgets((prev) => {
|
|
2273
2387
|
const arr = prev || []
|
|
2274
2388
|
if (arr.some((w) => w.id === result.widget.id)) return arr
|
|
2275
2389
|
return [...arr, result.widget]
|
|
2276
2390
|
})
|
|
2277
2391
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2392
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
2278
2393
|
}
|
|
2279
2394
|
} catch (err) {
|
|
2280
2395
|
console.error('[canvas] Failed to add story widget:', err)
|
|
@@ -2718,11 +2833,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2718
2833
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
2719
2834
|
e.preventDefault()
|
|
2720
2835
|
if (selectedWidgetIds.size > 1) {
|
|
2721
|
-
// Multi-delete —
|
|
2722
|
-
//
|
|
2836
|
+
// Multi-delete — remove all via individual widget_removed events
|
|
2837
|
+
// (not widgets_replaced) to avoid the server's wipe guard and
|
|
2723
2838
|
// ensure terminal sessions are properly orphaned per widget.
|
|
2724
|
-
|
|
2725
|
-
debouncedSave.cancel()
|
|
2839
|
+
cancelAllPendingWidgetPatches()
|
|
2726
2840
|
const idsToRemove = new Set(selectedWidgetIds)
|
|
2727
2841
|
// Mark deletions before mutating local state so the HMR merge
|
|
2728
2842
|
// doesn't re-add them mid-flight.
|
|
@@ -2733,22 +2847,28 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2733
2847
|
const orphaned = prev.filter((c) => idsToRemove.has(c.start.widgetId) || idsToRemove.has(c.end.widgetId))
|
|
2734
2848
|
for (const c of orphaned) {
|
|
2735
2849
|
markConnectorDeleted(c.id)
|
|
2736
|
-
queueWrite(() =>
|
|
2737
|
-
|
|
2850
|
+
queueWrite(async () => {
|
|
2851
|
+
try {
|
|
2852
|
+
const res = await removeConnectorApi(canvasId, c.id)
|
|
2853
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
2854
|
+
} catch (err) {
|
|
2738
2855
|
console.error('[canvas] Failed to remove orphaned connector:', err)
|
|
2739
|
-
|
|
2740
|
-
)
|
|
2856
|
+
}
|
|
2857
|
+
})
|
|
2741
2858
|
}
|
|
2742
2859
|
return prev.filter((c) => !idsToRemove.has(c.start.widgetId) && !idsToRemove.has(c.end.widgetId))
|
|
2743
2860
|
})
|
|
2744
2861
|
dirtyRef.current = true
|
|
2745
2862
|
// Queue individual delete API calls
|
|
2746
2863
|
for (const widgetId of idsToRemove) {
|
|
2747
|
-
queueWrite(() =>
|
|
2748
|
-
|
|
2864
|
+
queueWrite(async () => {
|
|
2865
|
+
try {
|
|
2866
|
+
const res = await removeWidgetApi(canvasId, widgetId)
|
|
2867
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
2868
|
+
} catch (err) {
|
|
2749
2869
|
console.error('[canvas] Failed to remove widget in multi-delete:', err)
|
|
2750
|
-
|
|
2751
|
-
)
|
|
2870
|
+
}
|
|
2871
|
+
})
|
|
2752
2872
|
}
|
|
2753
2873
|
} else {
|
|
2754
2874
|
const widgetId = [...selectedWidgetIds][0]
|
|
@@ -2759,7 +2879,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2759
2879
|
}
|
|
2760
2880
|
document.addEventListener('keydown', handleKeyDown)
|
|
2761
2881
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
2762
|
-
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId,
|
|
2882
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, cancelAllPendingWidgetPatches, markWidgetDeleted, markConnectorDeleted])
|
|
2763
2883
|
|
|
2764
2884
|
// Ref to store processImageFile for use by drop effect
|
|
2765
2885
|
const processImageFileRef = useRef(null)
|
|
@@ -2829,10 +2949,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2829
2949
|
position: pos,
|
|
2830
2950
|
})
|
|
2831
2951
|
if (result.success && result.widget) {
|
|
2832
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2833
2952
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
2834
2953
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2835
2954
|
navigator.clipboard?.writeText(result.widget.id).catch(() => {})
|
|
2955
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
2836
2956
|
}
|
|
2837
2957
|
return true
|
|
2838
2958
|
} catch (err) {
|
|
@@ -2916,11 +3036,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2916
3036
|
const baseX = Math.round(center.x - groupW / 2)
|
|
2917
3037
|
const baseY = Math.round(center.y - groupH / 2)
|
|
2918
3038
|
|
|
2919
|
-
// Single undo snapshot for the entire paste
|
|
2920
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2921
|
-
|
|
2922
3039
|
// Paste all widgets, collecting new IDs for selection
|
|
2923
3040
|
const newWidgets = []
|
|
3041
|
+
const eventIds = []
|
|
2924
3042
|
for (const w of sourceWidgets) {
|
|
2925
3043
|
const relX = (w.position?.x ?? 0) - minX
|
|
2926
3044
|
const relY = (w.position?.y ?? 0) - minY
|
|
@@ -2943,6 +3061,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2943
3061
|
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
2944
3062
|
}
|
|
2945
3063
|
newWidgets.push(result.widget)
|
|
3064
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
2946
3065
|
}
|
|
2947
3066
|
}
|
|
2948
3067
|
|
|
@@ -2950,6 +3069,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2950
3069
|
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
2951
3070
|
setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
|
|
2952
3071
|
}
|
|
3072
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
2953
3073
|
} catch (err) {
|
|
2954
3074
|
console.error('[canvas] Failed to paste widget reference:', err)
|
|
2955
3075
|
}
|
|
@@ -3000,9 +3120,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
3000
3120
|
position: pos,
|
|
3001
3121
|
})
|
|
3002
3122
|
if (result.success && result.widget) {
|
|
3003
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
3004
3123
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
3005
3124
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
3125
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
3006
3126
|
}
|
|
3007
3127
|
} catch (err) {
|
|
3008
3128
|
console.error('[canvas] Failed to add widget from paste:', err)
|
|
@@ -3090,35 +3210,57 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
3090
3210
|
}, [loading])
|
|
3091
3211
|
|
|
3092
3212
|
// --- Undo / Redo ---
|
|
3213
|
+
// Server-authoritative: POST /undo / POST /redo append an inverse event
|
|
3214
|
+
// to the JSONL. The HMR push from that write flows back through useCanvas
|
|
3215
|
+
// → setLocalWidgets, so we don't optimistically mutate local state here.
|
|
3216
|
+
//
|
|
3217
|
+
// 404 handling: if the server can't find the target event, it was almost
|
|
3218
|
+
// certainly compacted away (compaction collapses history into a single
|
|
3219
|
+
// canvas_created baseline). We clear the per-tab stack so the toolbar
|
|
3220
|
+
// button greys out instead of silently no-op'ing on every click.
|
|
3093
3221
|
const handleUndo = useCallback(() => {
|
|
3094
|
-
const
|
|
3095
|
-
if (!
|
|
3096
|
-
|
|
3222
|
+
const eventId = undoRedo.popUndo()
|
|
3223
|
+
if (!eventId) return
|
|
3224
|
+
flushAllPendingWidgetPatches()
|
|
3097
3225
|
debouncedSourceSave.cancel()
|
|
3098
3226
|
dirtyRef.current = true
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3227
|
+
queueWrite(async () => {
|
|
3228
|
+
try {
|
|
3229
|
+
const { status, data } = await undoEventApi(canvasId, eventId)
|
|
3230
|
+
if (status === 404) {
|
|
3231
|
+
console.warn('[canvas] Undo target was compacted away — resetting undo stack')
|
|
3232
|
+
undoRedo.reset()
|
|
3233
|
+
return
|
|
3234
|
+
}
|
|
3235
|
+
if (data?.eventId) undoRedo.pushRedo(data.eventId)
|
|
3236
|
+
else if (data?.error) console.error('[canvas] Undo rejected:', data.error)
|
|
3237
|
+
} catch (err) {
|
|
3238
|
+
console.error('[canvas] Failed to persist undo:', err)
|
|
3239
|
+
}
|
|
3240
|
+
})
|
|
3241
|
+
}, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
|
|
3107
3242
|
|
|
3108
3243
|
const handleRedo = useCallback(() => {
|
|
3109
|
-
const
|
|
3110
|
-
if (!
|
|
3111
|
-
|
|
3244
|
+
const eventId = undoRedo.popRedo()
|
|
3245
|
+
if (!eventId) return
|
|
3246
|
+
flushAllPendingWidgetPatches()
|
|
3112
3247
|
debouncedSourceSave.cancel()
|
|
3113
3248
|
dirtyRef.current = true
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3249
|
+
queueWrite(async () => {
|
|
3250
|
+
try {
|
|
3251
|
+
const { status, data } = await redoEventApi(canvasId, eventId)
|
|
3252
|
+
if (status === 404) {
|
|
3253
|
+
console.warn('[canvas] Redo target was compacted away — resetting redo stack')
|
|
3254
|
+
undoRedo.reset()
|
|
3255
|
+
return
|
|
3256
|
+
}
|
|
3257
|
+
if (data?.eventId) undoRedo.pushUndo(data.eventId)
|
|
3258
|
+
else if (data?.error) console.error('[canvas] Redo rejected:', data.error)
|
|
3259
|
+
} catch (err) {
|
|
3260
|
+
console.error('[canvas] Failed to persist redo:', err)
|
|
3261
|
+
}
|
|
3262
|
+
})
|
|
3263
|
+
}, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
|
|
3122
3264
|
|
|
3123
3265
|
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
|
|
3124
3266
|
useEffect(() => {
|
|
@@ -3493,6 +3635,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
3493
3635
|
<div
|
|
3494
3636
|
ref={scrollRef}
|
|
3495
3637
|
data-storyboard-canvas-scroll
|
|
3638
|
+
data-sb-surface="canvas"
|
|
3496
3639
|
data-sb-canvas-theme={canvasTheme}
|
|
3497
3640
|
{...canvasPrimerAttrs}
|
|
3498
3641
|
className={styles.canvasScroll}
|