@hyperframes/studio 0.6.11 → 0.6.13

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/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-BP8No8kB.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-DsFKgqkT.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-Ckqo37Co.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.6.11",
36
- "@hyperframes/player": "0.6.11"
35
+ "@hyperframes/core": "0.6.13",
36
+ "@hyperframes/player": "0.6.13"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "19",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.6.11"
50
+ "@hyperframes/producer": "0.6.13"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "19",
package/src/App.tsx CHANGED
@@ -12,6 +12,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
12
12
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
13
13
  import { useDomEditSession } from "./hooks/useDomEditSession";
14
14
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
15
+ import { useClipboard } from "./hooks/useClipboard";
15
16
  import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
16
17
  import { useCaptionDetection } from "./hooks/useCaptionDetection";
17
18
  import { useRenderClipContent } from "./hooks/useRenderClipContent";
@@ -162,15 +163,28 @@ export function StudioApp() {
162
163
 
163
164
  const clearDomSelectionRef = useRef<() => void>(() => {});
164
165
  const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
165
- const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise<void>>(
166
+ const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
166
167
  async () => {},
167
168
  );
168
-
169
+ const domEditDeleteBridge = async (s: DomEditSelection) =>
170
+ handleDomEditElementDeleteRef.current(s);
171
+ const { handleCopy, handlePaste, handleCut } = useClipboard({
172
+ projectId,
173
+ activeCompPath,
174
+ domEditSelectionRef: domEditSelectionBridgeRef,
175
+ showToast,
176
+ writeProjectFile: fileManager.writeProjectFile,
177
+ recordEdit: editHistory.recordEdit,
178
+ domEditSaveTimestampRef,
179
+ reloadPreview,
180
+ handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
181
+ handleDomEditElementDelete: domEditDeleteBridge,
182
+ previewIframeRef,
183
+ });
169
184
  const appHotkeys = useAppHotkeys({
170
185
  toggleTimelineVisibility,
171
186
  handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
172
- handleDomEditElementDelete: async (s: DomEditSelection) =>
173
- handleDomEditElementDeleteRef.current(s),
187
+ handleDomEditElementDelete: domEditDeleteBridge,
174
188
  domEditSelectionRef: domEditSelectionBridgeRef,
175
189
  clearDomSelectionRef,
176
190
  editHistory,
@@ -182,6 +196,9 @@ export function StudioApp() {
182
196
  syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
183
197
  waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
184
198
  leftSidebarRef,
199
+ handleCopy,
200
+ handlePaste,
201
+ handleCut,
185
202
  });
186
203
 
187
204
  const domEditSession = useDomEditSession({
@@ -55,6 +55,7 @@ export function StudioRightPanel({
55
55
  copiedAgentPrompt,
56
56
  clearDomSelection,
57
57
  handleDomStyleCommit,
58
+ handleDomAttributeCommit,
58
59
  handleDomPathOffsetCommit,
59
60
  handleDomBoxSizeCommit,
60
61
  handleDomRotationCommit,
@@ -168,6 +169,7 @@ export function StudioRightPanel({
168
169
  copiedAgentPrompt={copiedAgentPrompt}
169
170
  onClearSelection={clearDomSelection}
170
171
  onSetStyle={handleDomStyleCommit}
172
+ onSetAttribute={handleDomAttributeCommit}
171
173
  onSetManualOffset={handleDomPathOffsetCommit}
172
174
  onSetManualSize={handleDomBoxSizeCommit}
173
175
  onSetManualRotation={handleDomRotationCommit}
@@ -1,5 +1,5 @@
1
1
  import { memo } from "react";
2
- import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
2
+ import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
3
3
  import {
4
4
  collectDomEditLayerItems,
5
5
  getDomEditLayerKey,
@@ -39,6 +39,7 @@ interface PropertyPanelProps {
39
39
  copiedAgentPrompt: boolean;
40
40
  onClearSelection: () => void;
41
41
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
42
+ onSetAttribute: (attr: string, value: string) => void | Promise<void>;
42
43
  onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
43
44
  onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
44
45
  onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
@@ -114,6 +115,67 @@ function LayerTree({
114
115
  );
115
116
  }
116
117
 
118
+ /* ------------------------------------------------------------------ */
119
+ /* TimingSection */
120
+ /* ------------------------------------------------------------------ */
121
+
122
+ function formatTimingValue(seconds: number): string {
123
+ if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
124
+ return `${seconds.toFixed(2)}s`;
125
+ }
126
+
127
+ function parseTimingValue(input: string): number | null {
128
+ const cleaned = input.replace(/s$/i, "").trim();
129
+ const parsed = Number.parseFloat(cleaned);
130
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
131
+ }
132
+
133
+ function TimingSection({
134
+ element,
135
+ onSetAttribute,
136
+ }: {
137
+ element: DomEditSelection;
138
+ onSetAttribute: (attr: string, value: string) => void | Promise<void>;
139
+ }) {
140
+ const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
141
+ const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0;
142
+ const end = start + duration;
143
+
144
+ const commitStart = (nextValue: string) => {
145
+ const parsed = parseTimingValue(nextValue);
146
+ if (parsed == null) return;
147
+ void onSetAttribute("start", parsed.toFixed(2));
148
+ };
149
+
150
+ const commitDuration = (nextValue: string) => {
151
+ const parsed = parseTimingValue(nextValue);
152
+ if (parsed == null || parsed <= 0) return;
153
+ void onSetAttribute("duration", parsed.toFixed(2));
154
+ };
155
+
156
+ const commitEnd = (nextValue: string) => {
157
+ const parsed = parseTimingValue(nextValue);
158
+ if (parsed == null || parsed <= start) return;
159
+ void onSetAttribute("duration", (parsed - start).toFixed(2));
160
+ };
161
+
162
+ return (
163
+ <Section title="Timing" icon={<Clock size={15} />}>
164
+ <div className={RESPONSIVE_GRID}>
165
+ <MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
166
+ <MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
167
+ </div>
168
+ <div className="mt-3">
169
+ <MetricField
170
+ label="Duration"
171
+ value={formatTimingValue(duration)}
172
+ onCommit={commitDuration}
173
+ />
174
+ </div>
175
+ </Section>
176
+ );
177
+ }
178
+
117
179
  /* ------------------------------------------------------------------ */
118
180
  /* PropertyPanel */
119
181
  /* ------------------------------------------------------------------ */
@@ -126,6 +188,7 @@ export const PropertyPanel = memo(function PropertyPanel({
126
188
  copiedAgentPrompt,
127
189
  onClearSelection,
128
190
  onSetStyle,
191
+ onSetAttribute,
129
192
  onSetManualOffset,
130
193
  onSetManualSize,
131
194
  onSetManualRotation,
@@ -322,6 +385,10 @@ export const PropertyPanel = memo(function PropertyPanel({
322
385
  </div>
323
386
  </Section>
324
387
 
388
+ {element.dataAttributes.start != null && (
389
+ <TimingSection element={element} onSetAttribute={onSetAttribute} />
390
+ )}
391
+
325
392
  {showEditableSections && (
326
393
  <StyleSections
327
394
  projectId={projectId}
@@ -73,10 +73,41 @@ function buildTextField(
73
73
  }
74
74
 
75
75
  export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
76
- const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
77
- if (childFields.length > 0) {
78
- return childFields.map((child, index) =>
79
- buildTextField(child, index, childFields.length, "child"),
76
+ const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
77
+
78
+ if (childElements.length > 0) {
79
+ const hasMixedContent = Array.from(el.childNodes).some(
80
+ (node) => node.nodeType === 3 && node.textContent?.trim(),
81
+ );
82
+
83
+ if (hasMixedContent) {
84
+ const fields: DomEditTextField[] = [];
85
+ let childIdx = 0;
86
+ for (const node of el.childNodes) {
87
+ if (node.nodeType === 3) {
88
+ const text = node.textContent ?? "";
89
+ if (!text.trim()) continue;
90
+ fields.push({
91
+ key: `text-node:${childIdx}`,
92
+ label: `Text ${childIdx + 1}`,
93
+ value: text,
94
+ tagName: "#text",
95
+ attributes: [],
96
+ inlineStyles: {},
97
+ computedStyles: {},
98
+ source: "text-node",
99
+ });
100
+ childIdx++;
101
+ } else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
102
+ fields.push(buildTextField(node, childIdx, childElements.length, "child"));
103
+ childIdx++;
104
+ }
105
+ }
106
+ return fields;
107
+ }
108
+
109
+ return childElements.map((child, index) =>
110
+ buildTextField(child, index, childElements.length, "child"),
80
111
  );
81
112
  }
82
113
 
@@ -99,8 +130,11 @@ function serializeTextFieldStyle(field: DomEditTextField): string {
99
130
 
100
131
  export function serializeDomEditTextFields(fields: DomEditTextField[]): string {
101
132
  return fields
102
- .filter((field) => field.source === "child")
133
+ .filter((field) => field.source === "child" || field.source === "text-node")
103
134
  .map((field) => {
135
+ if (field.source === "text-node") {
136
+ return escapeHtmlText(field.value);
137
+ }
104
138
  const attrs = [
105
139
  ...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"),
106
140
  { name: "data-hf-text-key", value: field.key },
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { serializeDomEditTextFields } from "./domEditing";
3
+
4
+ describe("serializeDomEditTextFields — mixed content", () => {
5
+ it("round-trips text-node + child element fields", () => {
6
+ expect(
7
+ serializeDomEditTextFields([
8
+ {
9
+ key: "text-node:0",
10
+ label: "Text 1",
11
+ value: "If you're ",
12
+ tagName: "#text",
13
+ attributes: [],
14
+ inlineStyles: {},
15
+ computedStyles: {},
16
+ source: "text-node",
17
+ },
18
+ {
19
+ key: "child:1:span",
20
+ label: "Text 2",
21
+ value: "turning 65",
22
+ tagName: "span",
23
+ attributes: [{ name: "class", value: "accent" }],
24
+ inlineStyles: { color: "red" },
25
+ computedStyles: {},
26
+ source: "child",
27
+ },
28
+ {
29
+ key: "text-node:2",
30
+ label: "Text 3",
31
+ value: " soon...",
32
+ tagName: "#text",
33
+ attributes: [],
34
+ inlineStyles: {},
35
+ computedStyles: {},
36
+ source: "text-node",
37
+ },
38
+ ]),
39
+ ).toBe(
40
+ `If you're <span class="accent" data-hf-text-key="child:1:span" style="color: red">turning 65</span> soon...`,
41
+ );
42
+ });
43
+
44
+ it("escapes HTML entities in text-node values", () => {
45
+ expect(
46
+ serializeDomEditTextFields([
47
+ {
48
+ key: "text-node:0",
49
+ label: "Text 1",
50
+ value: "A < B & C > D",
51
+ tagName: "#text",
52
+ attributes: [],
53
+ inlineStyles: {},
54
+ computedStyles: {},
55
+ source: "text-node",
56
+ },
57
+ ]),
58
+ ).toBe("A &lt; B &amp; C &gt; D");
59
+ });
60
+ });
@@ -65,7 +65,7 @@ export interface DomEditTextField {
65
65
  attributes: Array<{ name: string; value: string }>;
66
66
  inlineStyles: Record<string, string>;
67
67
  computedStyles: Record<string, string>;
68
- source: "self" | "child";
68
+ source: "self" | "child" | "text-node";
69
69
  }
70
70
 
71
71
  export interface DomEditSelection extends PatchTarget {
@@ -99,7 +99,7 @@ export const NLELayout = memo(function NLELayout({
99
99
  togglePlay,
100
100
  seek,
101
101
  onIframeLoad: baseOnIframeLoad,
102
- saveSeekPosition,
102
+ refreshPlayer,
103
103
  } = useTimelinePlayer();
104
104
 
105
105
  // Reset timeline state when the project changes
@@ -109,13 +109,16 @@ export const NLELayout = memo(function NLELayout({
109
109
  usePlayerStore.getState().reset();
110
110
  }
111
111
 
112
- // Save seek position before refresh
112
+ // Lightweight reload: change iframe src instead of destroying the Player.
113
+ // refreshPlayer() saves the seek position and appends a cache-busting _t
114
+ // param, avoiding the full web-component teardown + crossfade that the
115
+ // key-based path uses.
113
116
  const prevRefreshKeyRef = useRef(refreshKey);
114
117
  useEffect(() => {
115
118
  if (refreshKey === prevRefreshKeyRef.current) return;
116
119
  prevRefreshKeyRef.current = refreshKey;
117
- saveSeekPosition();
118
- }, [refreshKey, saveSeekPosition]);
120
+ refreshPlayer();
121
+ }, [refreshKey, refreshPlayer]);
119
122
 
120
123
  const onIframeLoad = useCallback(() => {
121
124
  baseOnIframeLoad();
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
1
+ import { memo, useCallback, useEffect, useRef, type Ref } from "react";
2
2
  import { Player } from "../../player";
3
3
  import {
4
4
  DEFAULT_PREVIEW_ZOOM,
@@ -53,15 +53,14 @@ export const NLEPreview = memo(function NLEPreview({
53
53
  onCompositionLoadingChange,
54
54
  portrait,
55
55
  directUrl,
56
- refreshKey,
57
56
  suppressLoadingOverlay,
58
57
  }: NLEPreviewProps) {
59
- const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
60
- const prevRefreshKeyRef = useRef(refreshKey);
58
+ // Player key only changes for structural changes (project switch, composition
59
+ // drill-down), NOT for content refreshes. Content refreshes use the lighter
60
+ // iframe.src reload path handled by NLELayout → refreshPlayer().
61
+ const activeKey = getPreviewPlayerKey({ projectId, directUrl });
61
62
  const viewportRef = useRef<HTMLDivElement>(null);
62
63
  const stageRef = useRef<HTMLDivElement>(null);
63
- const [retiringKey, setRetiringKey] = useState<string | null>(null);
64
- const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
65
64
 
66
65
  const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
67
66
  const hudRef = useRef<HTMLDivElement>(null);
@@ -80,7 +79,6 @@ export const NLEPreview = memo(function NLEPreview({
80
79
  return () => {
81
80
  if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
82
81
  if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
83
- if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
84
82
  };
85
83
  }, []);
86
84
 
@@ -130,14 +128,6 @@ export const NLEPreview = memo(function NLEPreview({
130
128
  [writeTransform],
131
129
  );
132
130
 
133
- if (refreshKey !== prevRefreshKeyRef.current) {
134
- const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
135
- prevRefreshKeyRef.current = refreshKey;
136
- setRetiringKey(oldKey);
137
- }
138
-
139
- const activeKey = `${baseKey}:${refreshKey ?? 0}`;
140
-
141
131
  const applyInitialZoom = useCallback(() => {
142
132
  const z = zoomRef.current;
143
133
  if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
@@ -145,16 +135,6 @@ export const NLEPreview = memo(function NLEPreview({
145
135
  }
146
136
  }, [writeTransform]);
147
137
 
148
- const handleNewPlayerLoad = () => {
149
- onIframeLoad();
150
- applyInitialZoom();
151
- if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
152
- retiringTimerRef.current = setTimeout(() => {
153
- setRetiringKey(null);
154
- retiringTimerRef.current = null;
155
- }, 160);
156
- };
157
-
158
138
  useEffect(() => {
159
139
  const viewport = viewportRef.current;
160
140
  if (!viewport) return;
@@ -282,32 +262,17 @@ export const NLEPreview = memo(function NLEPreview({
282
262
  }}
283
263
  data-testid="preview-zoom-stage"
284
264
  >
285
- {retiringKey && (
286
- <Player
287
- key={retiringKey}
288
- projectId={directUrl ? undefined : projectId}
289
- directUrl={directUrl}
290
- onLoad={() => {}}
291
- portrait={portrait}
292
- style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
293
- />
294
- )}
295
265
  <Player
296
266
  key={activeKey}
297
267
  ref={iframeRef}
298
268
  projectId={directUrl ? undefined : projectId}
299
269
  directUrl={directUrl}
300
- onLoad={
301
- retiringKey
302
- ? handleNewPlayerLoad
303
- : () => {
304
- onIframeLoad();
305
- applyInitialZoom();
306
- }
307
- }
270
+ onLoad={() => {
271
+ onIframeLoad();
272
+ applyInitialZoom();
273
+ }}
308
274
  onCompositionLoadingChange={onCompositionLoadingChange}
309
275
  portrait={portrait}
310
- style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
311
276
  suppressLoadingOverlay={suppressLoadingOverlay}
312
277
  />
313
278
  </div>
@@ -28,6 +28,7 @@ export function DomEditProvider({
28
28
  applyDomSelection,
29
29
  clearDomSelection,
30
30
  handleDomStyleCommit,
31
+ handleDomAttributeCommit,
31
32
  handleDomPathOffsetCommit,
32
33
  handleDomGroupPathOffsetCommit,
33
34
  handleDomBoxSizeCommit,
@@ -74,6 +75,7 @@ export function DomEditProvider({
74
75
  applyDomSelection,
75
76
  clearDomSelection,
76
77
  handleDomStyleCommit,
78
+ handleDomAttributeCommit,
77
79
  handleDomPathOffsetCommit,
78
80
  handleDomGroupPathOffsetCommit,
79
81
  handleDomBoxSizeCommit,
@@ -114,6 +116,7 @@ export function DomEditProvider({
114
116
  applyDomSelection,
115
117
  clearDomSelection,
116
118
  handleDomStyleCommit,
119
+ handleDomAttributeCommit,
117
120
  handleDomPathOffsetCommit,
118
121
  handleDomGroupPathOffsetCommit,
119
122
  handleDomBoxSizeCommit,
@@ -45,6 +45,9 @@ interface UseAppHotkeysParams {
45
45
  syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise<void>;
46
46
  waitForPendingDomEditSaves: () => Promise<void>;
47
47
  leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
48
+ handleCopy: () => boolean;
49
+ handlePaste: () => Promise<void>;
50
+ handleCut: () => Promise<boolean>;
48
51
  }
49
52
 
50
53
  // ── Hook ──
@@ -64,6 +67,9 @@ export function useAppHotkeys({
64
67
  syncHistoryPreviewAfterApply,
65
68
  waitForPendingDomEditSaves,
66
69
  leftSidebarRef,
70
+ handleCopy,
71
+ handlePaste,
72
+ handleCut,
67
73
  }: UseAppHotkeysParams) {
68
74
  const previewHotkeyWindowRef = useRef<Window | null>(null);
69
75
  const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
@@ -161,6 +167,12 @@ export function useAppHotkeys({
161
167
  handleUndoRef.current = handleUndo;
162
168
  const handleRedoRef = useRef(handleRedo);
163
169
  handleRedoRef.current = handleRedo;
170
+ const handleCopyRef = useRef(handleCopy);
171
+ handleCopyRef.current = handleCopy;
172
+ const handlePasteRef = useRef(handlePaste);
173
+ handlePasteRef.current = handlePaste;
174
+ const handleCutRef = useRef(handleCut);
175
+ handleCutRef.current = handleCut;
164
176
 
165
177
  // ── Consolidated keydown handler ──
166
178
 
@@ -197,6 +209,48 @@ export function useAppHotkeys({
197
209
  leftSidebarRef.current?.selectTab("assets");
198
210
  return;
199
211
  }
212
+
213
+ // Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy)
214
+ const copyPasteKey = event.key.toLowerCase();
215
+ if (
216
+ copyPasteKey === "c" &&
217
+ !event.shiftKey &&
218
+ !event.altKey &&
219
+ !isEditableTarget(event.target)
220
+ ) {
221
+ if (handleCopyRef.current()) {
222
+ event.preventDefault();
223
+ }
224
+ return;
225
+ }
226
+
227
+ // Cmd/Ctrl+V — paste
228
+ if (
229
+ copyPasteKey === "v" &&
230
+ !event.shiftKey &&
231
+ !event.altKey &&
232
+ !isEditableTarget(event.target)
233
+ ) {
234
+ event.preventDefault();
235
+ void handlePasteRef.current();
236
+ return;
237
+ }
238
+
239
+ // Cmd/Ctrl+X — cut (only preventDefault if there's a selected element to cut)
240
+ if (
241
+ copyPasteKey === "x" &&
242
+ !event.shiftKey &&
243
+ !event.altKey &&
244
+ !isEditableTarget(event.target)
245
+ ) {
246
+ const hasSelection =
247
+ !!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current;
248
+ if (hasSelection) {
249
+ event.preventDefault();
250
+ void handleCutRef.current();
251
+ }
252
+ return;
253
+ }
200
254
  }
201
255
 
202
256
  // Delete / Backspace — remove selected element (timeline clip or preview selection)