@hyperframes/studio 0.8.112 → 0.8.114

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 (53) hide show
  1. package/dist/assets/{hyperframes-player-CBpg41JM.js → hyperframes-player-aXNJKiIg.js} +1 -1
  2. package/dist/assets/index-BYnsJnJV.css +1 -0
  3. package/dist/assets/{index-BFitquwi.js → index-Bn2RJNTJ.js} +1 -1
  4. package/dist/assets/{index-CZfrSyU9.js → index-C9N-DGjC.js} +1 -1
  5. package/dist/assets/{index-BKy_birQ.js → index-Dz7doz9l.js} +199 -199
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1605 -1536
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +9 -8
  12. package/src/App.tsx +82 -84
  13. package/src/components/EditorShell.tsx +3 -3
  14. package/src/components/StudioHeader.tsx +4 -4
  15. package/src/components/StudioLeftPanels.tsx +3 -3
  16. package/src/components/StudioOverlays.tsx +4 -4
  17. package/src/components/StudioRightPanels.tsx +14 -9
  18. package/src/components/StudioToast.tsx +1 -1
  19. package/src/components/TimelineToolbar.tsx +3 -3
  20. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  21. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  22. package/src/components/editor/colorValue.test.ts +16 -0
  23. package/src/components/editor/colorValue.ts +100 -24
  24. package/src/components/editor/cropResize.test.ts +11 -0
  25. package/src/components/editor/cropResize.ts +2 -3
  26. package/src/components/editor/gradientValue.ts +28 -36
  27. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  28. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  29. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  30. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  31. package/src/components/editor/manualEditsDom.ts +2 -2
  32. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  33. package/src/components/editor/propertyPanelColor.tsx +1 -1
  34. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  36. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  37. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  38. package/src/hooks/useAppHotkeys.ts +11 -5
  39. package/src/hooks/useFrameCapture.ts +15 -8
  40. package/src/hooks/useGestureCommit.ts +4 -1
  41. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  42. package/src/hooks/usePanelLayout.ts +5 -1
  43. package/src/hooks/useShellSlices.ts +53 -0
  44. package/src/hooks/useStableHandlers.test.tsx +77 -0
  45. package/src/hooks/useStableHandlers.ts +44 -0
  46. package/src/hooks/useStudioSdkSessions.ts +2 -1
  47. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  48. package/src/hooks/useStudioSidePanels.tsx +23 -0
  49. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  50. package/src/hooks/useTimelineEditing.ts +33 -27
  51. package/src/utils/frameCapture.ts +3 -1
  52. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  53. package/dist/assets/index-DbfKVTId.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.112",
3
+ "version": "0.8.114",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.112",
64
- "@hyperframes/parsers": "0.8.112",
65
- "@hyperframes/player": "0.8.112",
66
- "@hyperframes/sdk": "0.8.112",
67
- "@hyperframes/studio-server": "0.8.112",
68
- "@hyperframes/shader-transitions": "0.8.112"
63
+ "@hyperframes/player": "0.8.114",
64
+ "@hyperframes/parsers": "0.8.114",
65
+ "@hyperframes/sdk": "0.8.114",
66
+ "@hyperframes/shader-transitions": "0.8.114",
67
+ "@hyperframes/studio-server": "0.8.114",
68
+ "@hyperframes/core": "0.8.114"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.112"
84
+ "@hyperframes/producer": "0.8.114"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -99,6 +99,7 @@
99
99
  "test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs",
100
100
  "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
101
101
  "test:watch": "vitest",
102
+ "test:colors:browser": "node tests/e2e/native-css-colors.mjs",
102
103
  "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
103
104
  "test:timeline-default": "bun run test:timeline-virtualization"
104
105
  }
package/src/App.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
2
2
  import { buildProjectApiPath } from "./utils/projectRouting";
3
- import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
3
+ import { useState, useCallback, useRef, useMemo } from "react";
4
+ import { useStableHandlers } from "./hooks/useStableHandlers";
5
+ import { useHistoryFlags, useToolbarSession } from "./hooks/useShellSlices";
4
6
  import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
5
7
  import { useRenderQueue } from "./components/renders/useRenderQueue";
6
8
  import { usePlayerStore } from "./player";
@@ -46,10 +48,9 @@ import {
46
48
  import type { DomEditSelection } from "./components/editor/domEditing";
47
49
  import { StudioHeader } from "./components/StudioHeader";
48
50
  import { useGestureCommit } from "./hooks/useGestureCommit";
49
- import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
50
- import { StudioLeftPanels } from "./components/StudioLeftPanels";
51
+ import { useGestureTrailOverlay } from "./hooks/useGestureTrailOverlay";
51
52
  import { EditorShell } from "./components/EditorShell";
52
- import { StudioRightPanels } from "./components/StudioRightPanels";
53
+ import { useStudioSidePanels } from "./hooks/useStudioSidePanels";
53
54
  import { TimelineToolbar } from "./components/TimelineToolbar";
54
55
  import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
55
56
  import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
@@ -99,17 +100,18 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
99
100
  rightCollapsed: initialUrlStateRef.current.rightCollapsed,
100
101
  rightPanelTab: initialUrlStateRef.current.rightPanelTab,
101
102
  });
102
- const editHistory = usePersistentEditHistory({ projectId });
103
+ const editHistory = useStableHandlers(usePersistentEditHistory({ projectId }), projectId);
103
104
  const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
104
105
  const pendingTimelineEditPathRef = useRef(new Set<string>());
105
106
  const isGestureRecordingRef = useRef(false);
106
107
  const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
107
- const fileManager = useFileManager({
108
+ const fileManagerResult = useFileManager({
108
109
  projectId,
109
110
  showToast,
110
111
  recordEdit: editHistory.recordEdit,
111
112
  setRefreshKey,
112
113
  });
114
+ const fileManager = useStableHandlers(fileManagerResult, projectId);
113
115
  const masterCompPath = useMemo(
114
116
  () => resolveMasterCompositionPath(fileManager.compositions),
115
117
  [fileManager.compositions],
@@ -134,8 +136,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
134
136
  );
135
137
  const activeCompPathRef = useRef(activeCompPath);
136
138
  activeCompPathRef.current = activeCompPath;
137
- const renderQueue = useRenderQueue(projectId, activeCompPathRef);
138
- const previewPersistence = usePreviewPersistence({
139
+ const renderQueue = useStableHandlers(useRenderQueue(projectId, activeCompPathRef), projectId);
140
+ const previewPersistenceResult = usePreviewPersistence({
139
141
  showToast,
140
142
  readOptionalProjectFile: fileManager.readOptionalProjectFile,
141
143
  writeProjectFile: fileManager.writeProjectFile,
@@ -144,6 +146,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
144
146
  activeCompPathRef,
145
147
  reloadPreview: () => setRefreshKey((k) => k + 1),
146
148
  });
149
+ const previewPersistence = useStableHandlers(previewPersistenceResult, projectId);
147
150
  const externalFileChanges = useStudioExternalFileChanges({
148
151
  projectId,
149
152
  activeCompPath,
@@ -184,15 +187,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
184
187
  } = useTimelineAddAtPlayhead(
185
188
  timelineEditing.handleTimelineAssetDrop,
186
189
  timelineEditing.handleTimelineCompositionDrop,
190
+ projectId,
187
191
  );
188
- const {
189
- activeBlockParams,
190
- setActiveBlockParams,
191
- handleAddBlock,
192
- handleTimelineBlockDrop,
193
- handleAddMediaOverlay,
194
- handlePreviewBlockDrop,
195
- } = useBlockHandlers({
192
+ const blockHandlersResult = useBlockHandlers({
196
193
  projectId,
197
194
  blockCtxDeps: {
198
195
  activeCompPath,
@@ -209,6 +206,14 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
209
206
  setRightCollapsed: panelLayout.setRightCollapsed,
210
207
  setRightPanelTab: panelLayout.setRightPanelTab,
211
208
  });
209
+ const {
210
+ activeBlockParams,
211
+ setActiveBlockParams,
212
+ handleAddBlock,
213
+ handleTimelineBlockDrop,
214
+ handleAddMediaOverlay,
215
+ handlePreviewBlockDrop,
216
+ } = useStableHandlers(blockHandlersResult, projectId);
212
217
  const dismissBlockParams = useCallback(() => setActiveBlockParams(null), [setActiveBlockParams]);
213
218
  const setRightPanelTab = useDismissingTabSetter(panelLayout.setRightPanelTab, dismissBlockParams);
214
219
  const layout = useMemo(
@@ -237,6 +242,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
237
242
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
238
243
  });
239
244
  const appHotkeys = useAppHotkeys({
245
+ projectId,
240
246
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
241
247
  handleLinkEdit: timelineEditing.handleLinkEdit,
242
248
  handleTimelineElementDeleteOnly: timelineEditing.handleTimelineElementDeleteOnly,
@@ -269,7 +275,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
269
275
  onToggleRecording: () => handleToggleRecordingRef.current(),
270
276
  readOnlyPreview,
271
277
  });
272
- const domEditSession = useDomEditSession({
278
+ const domEditSessionResult = useDomEditSession({
273
279
  projectId,
274
280
  activeCompPath,
275
281
  compIdToSrc,
@@ -309,6 +315,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
309
315
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
310
316
  readOnlyPreview,
311
317
  });
318
+ const domEditSession = useStableHandlers(domEditSessionResult, projectId);
312
319
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
313
320
  const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession;
314
321
  handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit);
@@ -342,13 +349,14 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
342
349
  effectiveTimelineDuration,
343
350
  });
344
351
  const compositionDimensions = useCompositionDimensions(previewIframeRef);
345
- const lint = useLintModal(projectId, refreshKey);
346
- const frameCapture = useFrameCapture({
352
+ const lint = useStableHandlers(useLintModal(projectId, refreshKey), projectId);
353
+ const frameCaptureResult = useFrameCapture({
347
354
  projectId,
348
355
  activeCompPath,
349
356
  showToast,
350
357
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
351
358
  });
359
+ const frameCapture = useStableHandlers(frameCaptureResult, projectId);
352
360
  const {
353
361
  consoleErrors,
354
362
  setConsoleErrors,
@@ -359,6 +367,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
359
367
  const domEditSessionRef = useRef(domEditSession);
360
368
  domEditSessionRef.current = domEditSession;
361
369
  const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
370
+ projectId,
362
371
  domEditSessionRef,
363
372
  previewIframeRef,
364
373
  showToast,
@@ -366,14 +375,11 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
366
375
  readOnlyPreview,
367
376
  });
368
377
  handleToggleRecordingRef.current = handleToggleRecording;
369
- const canvasRectRef = useRef<DOMRect | null>(null);
370
- useLayoutEffect(() => {
371
- if (gestureState !== "recording" || !previewIframe) {
372
- canvasRectRef.current = null;
373
- return;
374
- }
375
- canvasRectRef.current = previewIframe.getBoundingClientRect();
376
- }, [gestureState, previewIframe]);
378
+ const gestureOverlay = useGestureTrailOverlay(
379
+ { gestureState, gestureRecording },
380
+ previewIframe,
381
+ compositionDimensions,
382
+ );
377
383
  const handlePreviewIframeRef = useCallback(
378
384
  (iframe: HTMLIFrameElement | null) => {
379
385
  previewIframeRef.current = iframe;
@@ -415,6 +421,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
415
421
  setRightPanelTab,
416
422
  initialState: initialUrlStateRef.current,
417
423
  });
424
+ const historyFlags = useHistoryFlags(editHistory);
425
+ const toolbarSession = useToolbarSession(domEditSession);
418
426
  const studioCtxValue = buildStudioContextValue({
419
427
  projectId: projectId!,
420
428
  activeCompPath,
@@ -427,7 +435,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
427
435
  setRefreshKey,
428
436
  timelineElements,
429
437
  isPlaying,
430
- editHistory,
438
+ editHistory: historyFlags,
431
439
  handleUndo: appHotkeys.handleUndo,
432
440
  handleRedo: appHotkeys.handleRedo,
433
441
  renderQueue,
@@ -441,11 +449,51 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
441
449
  const timelineToolbar = useMemo(
442
450
  () => (
443
451
  <TimelineToolbar
444
- domEditSession={domEditSession}
452
+ domEditSession={toolbarSession}
445
453
  onSplitElement={timelineEditing.handleTimelineElementSplit}
446
454
  />
447
455
  ),
448
- [domEditSession, timelineEditing.handleTimelineElementSplit],
456
+ [toolbarSession, timelineEditing.handleTimelineElementSplit],
457
+ );
458
+ const handleExport = useCallback(() => {
459
+ void previewPersistence
460
+ .waitForPendingDomEditSaves()
461
+ .then(() => renderQueue.startRender(undefined));
462
+ }, [previewPersistence, renderQueue]);
463
+ const clearConsoleErrors = useCallback(() => setConsoleErrors(null), [setConsoleErrors]);
464
+ const panels = useStudioSidePanels(
465
+ {
466
+ onSelectComposition: handleSelectComposition,
467
+ onAddBlock: handleAddBlock,
468
+ onPreviewBlock: setBlockPreview,
469
+ onLint: lint.handleLint,
470
+ linting: lint.linting,
471
+ lintFindingCount: lint.lintFindingCount,
472
+ lintFindingsByFile: lint.findingsByFile,
473
+ lintHasError: lint.hasLintError,
474
+ onAddAssetToTimeline: handleAddAssetAtPlayhead,
475
+ onAddCompositionToTimeline: handleAddCompositionAtPlayhead,
476
+ },
477
+ {
478
+ activeBlockParams,
479
+ onDismissBlockParams: dismissBlockParams,
480
+ onCloseBlockParams: () => {
481
+ setActiveBlockParams(null);
482
+ panelLayout.setRightPanelTab("design");
483
+ },
484
+ recordingState: gestureState,
485
+ recordingDuration: gestureRecording.recordingDuration,
486
+ onToggleRecording: handleToggleRecording,
487
+ sdkSession: sdkHandle.session,
488
+ publishSdkSession: sdkHandle.publish,
489
+ forceReloadSdkSession: sdkHandle.forceReload,
490
+ reloadPreview,
491
+ recordEdit: editHistory.recordEdit,
492
+ onToggleElementHidden: timelineEditing.handleToggleElementHidden,
493
+ onAutoGroupCarveSources: timelineEditing.handleAutoGroupCarveSources,
494
+ onAddMediaOverlay: handleAddMediaOverlay,
495
+ },
496
+ projectId,
449
497
  );
450
498
  if (resolving || waitingForServer || !projectId)
451
499
  return <StudioSplash waiting={waitingForServer} />;
@@ -468,12 +516,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
468
516
  capturing={frameCapture.capturing}
469
517
  inspectorButtonActive={inspectorButtonActive}
470
518
  inspectorPanelActive={inspectorPanelActive}
471
- onExport={() => {
472
- void (async () => {
473
- await previewPersistence.waitForPendingDomEditSaves();
474
- await renderQueue.startRender(undefined);
475
- })();
476
- }}
519
+ onExport={handleExport}
477
520
  />
478
521
  {previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
479
522
  <SaveQueuePausedBanner
@@ -491,41 +534,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
491
534
  <EditorShell
492
535
  readOnlyPreview={readOnlyPreview}
493
536
  readOnlyPreviewReason={readOnlyPreviewReason}
494
- panels={
495
- <>
496
- <StudioLeftPanels
497
- onSelectComposition={handleSelectComposition}
498
- onAddBlock={handleAddBlock}
499
- onPreviewBlock={setBlockPreview}
500
- onLint={lint.handleLint}
501
- linting={lint.linting}
502
- lintFindingCount={lint.lintFindingCount}
503
- lintFindingsByFile={lint.findingsByFile}
504
- lintHasError={lint.hasLintError}
505
- onAddAssetToTimeline={handleAddAssetAtPlayhead}
506
- onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
507
- />
508
- <StudioRightPanels
509
- activeBlockParams={activeBlockParams}
510
- onDismissBlockParams={dismissBlockParams}
511
- onCloseBlockParams={() => {
512
- setActiveBlockParams(null);
513
- panelLayout.setRightPanelTab("design");
514
- }}
515
- recordingState={gestureState}
516
- recordingDuration={gestureRecording.recordingDuration}
517
- onToggleRecording={handleToggleRecording}
518
- sdkSession={sdkHandle.session}
519
- publishSdkSession={sdkHandle.publish}
520
- forceReloadSdkSession={sdkHandle.forceReload}
521
- reloadPreview={reloadPreview}
522
- recordEdit={editHistory.recordEdit}
523
- onToggleElementHidden={timelineEditing.handleToggleElementHidden}
524
- onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
525
- onAddMediaOverlay={handleAddMediaOverlay}
526
- />
527
- </>
528
- }
537
+ panels={panels}
529
538
  timelineToolbar={timelineToolbar}
530
539
  renderClipContent={renderClipContent}
531
540
  handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
@@ -561,18 +570,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
561
570
  recordingState={gestureState}
562
571
  onToggleRecording={handleToggleRecording}
563
572
  blockPreview={blockPreview}
564
- gestureOverlay={
565
- gestureState === "recording" && previewIframe ? (
566
- <GestureTrailOverlay
567
- samples={gestureRecording.samplesRef.current}
568
- sampleCount={gestureRecording.samplesRef.current.length}
569
- trail={gestureRecording.trailRef.current}
570
- canvasRect={canvasRectRef.current!}
571
- compositionSize={compositionDimensions ?? undefined}
572
- mode="recording"
573
- />
574
- ) : undefined
575
- }
573
+ gestureOverlay={gestureOverlay}
576
574
  />
577
575
  <StudioOverlays
578
576
  projectId={projectId}
@@ -580,7 +578,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
580
578
  lintModal={lint.lintModal}
581
579
  closeLintModal={lint.closeLintModal}
582
580
  consoleErrors={consoleErrors}
583
- clearConsoleErrors={() => setConsoleErrors(null)}
581
+ clearConsoleErrors={clearConsoleErrors}
584
582
  domEditSession={domEditSession}
585
583
  activeCompPath={activeCompPath}
586
584
  toasts={toasts}
@@ -1,4 +1,4 @@
1
- import { useCallback, type ReactNode } from "react";
1
+ import { memo, useCallback, type ReactNode } from "react";
2
2
  import { PreviewPane } from "./nle/PreviewPane";
3
3
  import { TimelinePane } from "./nle/TimelinePane";
4
4
  import { PreviewOverlays } from "./nle/PreviewOverlays";
@@ -81,7 +81,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
81
81
  // The dockable shell: every panel lives in one Dock, arranged by the user's
82
82
  // saved layout. Owns the shared player + composition-stack state via
83
83
  // NLEProvider so every panel shares one player.
84
- export function EditorShell({
84
+ export const EditorShell = memo(function EditorShell({
85
85
  panels,
86
86
  timelineToolbar,
87
87
  renderClipContent,
@@ -228,7 +228,7 @@ export function EditorShell({
228
228
  </div>
229
229
  </PreviewReadOnlyProvider>
230
230
  );
231
- }
231
+ });
232
232
 
233
233
  interface EditorShellBodyProps {
234
234
  panels: ReactNode;
@@ -1,4 +1,4 @@
1
- import { useContext, type MouseEvent } from "react";
1
+ import { memo, useContext, type MouseEvent } from "react";
2
2
  import { Camera } from "../icons/SystemIcons";
3
3
  import { useStudioShellContext } from "../contexts/StudioContext";
4
4
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -9,7 +9,7 @@ import { InspectorIcon } from "./icons/InspectorIcon";
9
9
  import { HyperframesLogo } from "./ui/HyperframesLogo";
10
10
  import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
11
11
 
12
- export interface StudioHeaderProps {
12
+ interface StudioHeaderProps {
13
13
  captureFrameHref: string;
14
14
  captureFrameFilename: string;
15
15
  handleCaptureFrameClick: (event: MouseEvent<HTMLAnchorElement>) => void;
@@ -35,7 +35,7 @@ export function shouldOpenInspector(
35
35
  }
36
36
 
37
37
  // fallow-ignore-next-line complexity
38
- export function StudioHeader({
38
+ export const StudioHeader = memo(function StudioHeader({
39
39
  captureFrameHref,
40
40
  captureFrameFilename,
41
41
  handleCaptureFrameClick,
@@ -184,4 +184,4 @@ export function StudioHeader({
184
184
  </div>
185
185
  </div>
186
186
  );
187
- }
187
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, type ReactNode } from "react";
1
+ import { memo, useCallback, type ReactNode } from "react";
2
2
  import { SourceEditor } from "./editor/SourceEditor";
3
3
  import { FileTree } from "./editor/FileTree";
4
4
  import { MediaPreview } from "./MediaPreview";
@@ -36,7 +36,7 @@ function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactN
36
36
  }
37
37
 
38
38
  // fallow-ignore-next-line complexity
39
- export function StudioLeftPanels({
39
+ export const StudioLeftPanels = memo(function StudioLeftPanels({
40
40
  onSelectComposition,
41
41
  onAddBlock,
42
42
  onPreviewBlock,
@@ -159,7 +159,7 @@ export function StudioLeftPanels({
159
159
  </Dock.Panel>
160
160
  </>
161
161
  );
162
- }
162
+ });
163
163
 
164
164
  function CodeBody({
165
165
  projectId,
@@ -1,4 +1,4 @@
1
- import type { ComponentProps } from "react";
1
+ import { memo, type ComponentProps } from "react";
2
2
  import { LintModal } from "./LintModal";
3
3
  import { AskAgentModal } from "./AskAgentModal";
4
4
  import { StudioToast } from "./StudioToast";
@@ -9,7 +9,7 @@ import type { useToast } from "../hooks/useToast";
9
9
 
10
10
  type LintFindings = ComponentProps<typeof LintModal>["findings"];
11
11
 
12
- export interface StudioOverlaysProps {
12
+ interface StudioOverlaysProps {
13
13
  projectId: string;
14
14
  projectDir?: string | null;
15
15
  lintModal: LintFindings | null;
@@ -28,7 +28,7 @@ export interface StudioOverlaysProps {
28
28
  * `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
29
29
  */
30
30
  // fallow-ignore-next-line complexity
31
- export function StudioOverlays({
31
+ export const StudioOverlays = memo(function StudioOverlays({
32
32
  projectId,
33
33
  projectDir,
34
34
  lintModal,
@@ -91,4 +91,4 @@ export function StudioOverlays({
91
91
  </div>
92
92
  </>
93
93
  );
94
- }
94
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { memo, useCallback } from "react";
2
2
  import type { StudioRightPanelsProps } from "./StudioRightPanels.types";
3
3
 
4
4
  import { PropertyPanel } from "./editor/PropertyPanel";
@@ -29,7 +29,9 @@ import { useRemoveBackground } from "../hooks/useRemoveBackground";
29
29
  import { useApplyColorGradingScope } from "../hooks/useApplyColorGradingScope";
30
30
 
31
31
  // fallow-ignore-next-line complexity
32
- export function StudioRightPanels({
32
+ const seekToTime = (t: number) => usePlayerStore.getState().requestSeek(t);
33
+
34
+ export const StudioRightPanels = memo(function StudioRightPanels({
33
35
  activeBlockParams,
34
36
  onCloseBlockParams,
35
37
  onDismissBlockParams,
@@ -166,7 +168,7 @@ export function StudioRightPanels({
166
168
  handleDomAttributeLiveCommit(attr, value, undefined, { previewOnly: true }),
167
169
  [handleDomAttributeLiveCommit],
168
170
  );
169
- const handleHideAllSelected = () => {
171
+ const handleHideAllSelected = useCallback(() => {
170
172
  // Audio has no visual to hide, and `data-hidden` on an audio element is what
171
173
  // MUTES it — preview silences it and the render drops it from the mix. The
172
174
  // timeline withholds the eye on an audio track for that reason
@@ -181,7 +183,12 @@ export function StudioRightPanels({
181
183
  const { elements } = usePlayerStore.getState();
182
184
  const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
183
185
  if (keys.length > 0) void onToggleElementHidden?.(keys, true);
184
- };
186
+ }, [domEditGroupSelections, showToast, activeCompPath, onToggleElementHidden]);
187
+ const convertToKeyframes = useCallback(
188
+ (animId: string, duration?: number) =>
189
+ handleGsapConvertToKeyframes(animId, undefined, duration),
190
+ [handleGsapConvertToKeyframes],
191
+ );
185
192
  const propertyPanel = (
186
193
  <DesignPanelPromoteProvider
187
194
  selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
@@ -246,10 +253,8 @@ export function StudioRightPanels({
246
253
  onCommitAnimatedProperties={commitAnimatedProperties}
247
254
  onAddKeyframe={handleGsapAddKeyframe}
248
255
  onRemoveKeyframe={handleGsapRemoveKeyframe}
249
- onConvertToKeyframes={(animId, duration) =>
250
- handleGsapConvertToKeyframes(animId, undefined, duration)
251
- }
252
- onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
256
+ onConvertToKeyframes={convertToKeyframes}
257
+ onSeekToTime={seekToTime}
253
258
  onSetArcPath={handleSetArcPath}
254
259
  onUpdateArcSegment={handleUpdateArcSegment}
255
260
  onUnroll={handleUnroll}
@@ -307,4 +312,4 @@ export function StudioRightPanels({
307
312
  </Dock.Panel>
308
313
  </>
309
314
  );
310
- }
315
+ });
@@ -46,5 +46,5 @@ export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastPr
46
46
  }
47
47
 
48
48
  export function toastSurface(isError: boolean): string {
49
- return `border shadow-popover backdrop-blur-md ${isError ? "border-danger/40 bg-raised" : "border-border bg-raised/95"}`;
49
+ return `border shadow-popover bg-raised ${isError ? "border-danger/40" : "border-border"}`;
50
50
  }
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef } from "react";
1
+ import { memo, useEffect, useRef } from "react";
2
2
  import {
3
3
  ArrowsOutLineHorizontal,
4
4
  Image,
@@ -141,7 +141,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
141
141
  }
142
142
 
143
143
  // fallow-ignore-next-line complexity
144
- export function TimelineToolbar({
144
+ export const TimelineToolbar = memo(function TimelineToolbar({
145
145
  domEditSession,
146
146
  onSplitElement,
147
147
  history,
@@ -558,4 +558,4 @@ export function TimelineToolbar({
558
558
  </div>
559
559
  </div>
560
560
  );
561
- }
561
+ });
@@ -1,8 +1,12 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { applyInlineStyle } from "./inlineTextStyleRange";
4
- import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
5
- import { parseCssColor, toHexColor } from "./colorValue";
4
+ import {
5
+ readFirstPaintedElement,
6
+ readInlineStyle,
7
+ readInlineStyleSpread,
8
+ } from "./inlineTextStyleRead";
9
+ import { resolvePickerColor } from "./colorValue";
6
10
  import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
7
11
 
8
12
  /**
@@ -248,7 +252,11 @@ function placeOverSelection(
248
252
  placeBelow,
249
253
  styles,
250
254
  colours,
251
- pickerColour: toPickerColour(styles.color ?? colours[0], doc),
255
+ pickerColour: resolvePickerColor(
256
+ styles.color ?? colours[0],
257
+ readFirstPaintedElement(range, "color"),
258
+ DEFAULT_COLOR,
259
+ ),
252
260
  };
253
261
  }
254
262
 
@@ -257,27 +265,3 @@ function isBold(weight: string | undefined): boolean {
257
265
  if (weight === "bold" || weight === "bolder") return true;
258
266
  return Number.parseInt(weight, 10) >= 600;
259
267
  }
260
-
261
- /** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */
262
- function toPickerColour(value: string | undefined, doc: Document): string {
263
- if (!value) return DEFAULT_COLOR;
264
- const parsed = parseCssColor(value);
265
- if (parsed) return toHexColor(parsed);
266
-
267
- // Canvas delegates the full CSS colour grammar to the browser, including
268
- // named colours that the small serialisation parser intentionally omits.
269
- // DOM-only test environments can lack a canvas implementation, in which
270
- // case the picker degrades to its explicit default while the swatch remains
271
- // truthful because CSS still paints the original value.
272
- try {
273
- const context = doc.createElement("canvas").getContext("2d");
274
- if (!context) return DEFAULT_COLOR;
275
- context.fillStyle = DEFAULT_COLOR;
276
- context.fillStyle = value;
277
- const normalised =
278
- typeof context.fillStyle === "string" ? parseCssColor(context.fillStyle) : null;
279
- return normalised ? toHexColor(normalised) : DEFAULT_COLOR;
280
- } catch {
281
- return DEFAULT_COLOR;
282
- }
283
- }