@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/assets/index-DsFKgqkT.js +116 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +21 -4
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/PropertyPanel.tsx +68 -1
- package/src/components/editor/domEditingLayers.ts +39 -5
- package/src/components/editor/domEditingTextFields.test.ts +60 -0
- package/src/components/editor/domEditingTypes.ts +1 -1
- package/src/components/nle/NLELayout.tsx +7 -4
- package/src/components/nle/NLEPreview.tsx +9 -44
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/useAppHotkeys.ts +54 -0
- package/src/hooks/useClipboard.ts +229 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +33 -0
- package/src/player/components/Player.tsx +7 -1
- package/src/utils/clipboardPayload.test.ts +62 -0
- package/src/utils/clipboardPayload.ts +168 -0
- package/dist/assets/index-BP8No8kB.js +0 -115
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-
|
|
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.
|
|
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.
|
|
36
|
-
"@hyperframes/player": "0.6.
|
|
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.
|
|
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<(
|
|
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:
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 < B & C > 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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
118
|
-
}, [refreshKey,
|
|
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,
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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)
|