@hyperframes/studio 0.6.0-alpha.9 → 0.6.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/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime iframe integration utilities.
|
|
3
|
+
*
|
|
4
|
+
* Handles the boundary between the studio host page and the preview iframe:
|
|
5
|
+
* - Viewport normalisation on load
|
|
6
|
+
* - Auto-healing missing data-composition-id attributes
|
|
7
|
+
* - Unmuting media via postMessage
|
|
8
|
+
* - Resolving the underlying <iframe> from any wrapper element
|
|
9
|
+
* - Scanning the DOM for composition hosts the manifest missed
|
|
10
|
+
* (element-reference starts that the CDN runtime fails to resolve)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
14
|
+
import type { IframeWindow } from "./playbackTypes";
|
|
15
|
+
import {
|
|
16
|
+
getTimelineElementSelector,
|
|
17
|
+
getTimelineElementSourceFile,
|
|
18
|
+
getTimelineElementSelectorIndex,
|
|
19
|
+
getTimelineElementDisplayLabel,
|
|
20
|
+
buildTimelineElementIdentity,
|
|
21
|
+
} from "./timelineElementHelpers";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Viewport / DOM normalisation
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
28
|
+
if (doc.documentElement) {
|
|
29
|
+
doc.documentElement.style.overflow = "hidden";
|
|
30
|
+
doc.documentElement.style.margin = "0";
|
|
31
|
+
}
|
|
32
|
+
if (doc.body) {
|
|
33
|
+
doc.body.style.overflow = "hidden";
|
|
34
|
+
doc.body.style.margin = "0";
|
|
35
|
+
}
|
|
36
|
+
win.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function autoHealMissingCompositionIds(doc: Document): void {
|
|
40
|
+
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
|
|
41
|
+
const referencedIds = new Set<string>();
|
|
42
|
+
const scopedNodes = Array.from(doc.querySelectorAll("style, script"));
|
|
43
|
+
for (const node of scopedNodes) {
|
|
44
|
+
const text = node.textContent || "";
|
|
45
|
+
if (!text) continue;
|
|
46
|
+
let match: RegExpExecArray | null;
|
|
47
|
+
while ((match = compositionIdRe.exec(text)) !== null) {
|
|
48
|
+
const id = (match[1] || "").trim();
|
|
49
|
+
if (id) referencedIds.add(id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (referencedIds.size === 0) return;
|
|
54
|
+
|
|
55
|
+
const existingIds = new Set<string>();
|
|
56
|
+
const existingNodes = Array.from(doc.querySelectorAll<HTMLElement>("[data-composition-id]"));
|
|
57
|
+
for (const node of existingNodes) {
|
|
58
|
+
const id = node.getAttribute("data-composition-id");
|
|
59
|
+
if (id) existingIds.add(id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const compId of referencedIds) {
|
|
63
|
+
if (compId === "root" || existingIds.has(compId)) continue;
|
|
64
|
+
const host =
|
|
65
|
+
doc.getElementById(`${compId}-layer`) ||
|
|
66
|
+
doc.getElementById(`${compId}-comp`) ||
|
|
67
|
+
doc.getElementById(compId);
|
|
68
|
+
if (!host) continue;
|
|
69
|
+
if (!host.getAttribute("data-composition-id")) {
|
|
70
|
+
host.setAttribute("data-composition-id", compId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Muting / iframe resolution
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
|
|
80
|
+
if (!iframe) return;
|
|
81
|
+
try {
|
|
82
|
+
iframe.contentWindow?.postMessage(
|
|
83
|
+
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
|
|
84
|
+
"*",
|
|
85
|
+
);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the underlying iframe from any host element. Supports:
|
|
93
|
+
* - Direct `<iframe>` element (most common — studio's own `Player.tsx`)
|
|
94
|
+
* - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
|
|
95
|
+
* - Wrapper elements whose light DOM contains a descendant iframe
|
|
96
|
+
*
|
|
97
|
+
* Exported so web-component consumers can pre-resolve the iframe before
|
|
98
|
+
* assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
|
|
99
|
+
* when the element has no associated iframe yet.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```tsx
|
|
103
|
+
* const { iframeRef } = useTimelinePlayer();
|
|
104
|
+
* const playerElRef = useRef<HyperframesPlayer>(null);
|
|
105
|
+
*
|
|
106
|
+
* useEffect(() => {
|
|
107
|
+
* iframeRef.current = resolveIframe(playerElRef.current);
|
|
108
|
+
* }, [iframeRef]);
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
|
|
112
|
+
if (!el) return null;
|
|
113
|
+
if (el instanceof HTMLIFrameElement) return el;
|
|
114
|
+
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Enrich missing compositions from DOM
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Scan the iframe DOM for composition hosts missing from the current
|
|
123
|
+
* timeline elements and add them. The CDN runtime often fails to resolve
|
|
124
|
+
* element-reference starts (`data-start="intro"`) so composition hosts
|
|
125
|
+
* are silently dropped from `__clipManifest`. This pass reads the DOM +
|
|
126
|
+
* GSAP timeline registry directly to fill the gaps.
|
|
127
|
+
*/
|
|
128
|
+
export function buildMissingCompositionElements(
|
|
129
|
+
doc: Document,
|
|
130
|
+
iframeWin: IframeWindow,
|
|
131
|
+
currentEls: readonly TimelineElement[],
|
|
132
|
+
rootDuration: number,
|
|
133
|
+
): { missing: TimelineElement[]; updatedEls: TimelineElement[]; patched: boolean } {
|
|
134
|
+
const existingIds = new Set(currentEls.map((e) => e.id));
|
|
135
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
136
|
+
const rootCompId = rootComp?.getAttribute("data-composition-id");
|
|
137
|
+
// Use [data-composition-id][data-start] — the composition loader strips
|
|
138
|
+
// data-composition-src after loading, so we can't rely on it.
|
|
139
|
+
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
|
|
140
|
+
const missing: TimelineElement[] = [];
|
|
141
|
+
|
|
142
|
+
hosts.forEach((host) => {
|
|
143
|
+
const el = host as HTMLElement;
|
|
144
|
+
const compId = el.getAttribute("data-composition-id");
|
|
145
|
+
if (!compId || compId === rootCompId) return;
|
|
146
|
+
if (existingIds.has(el.id) || existingIds.has(compId)) return;
|
|
147
|
+
|
|
148
|
+
// Resolve start: numeric or element-reference
|
|
149
|
+
const startAttr = el.getAttribute("data-start") ?? "0";
|
|
150
|
+
let start = parseFloat(startAttr);
|
|
151
|
+
if (isNaN(start)) {
|
|
152
|
+
const ref =
|
|
153
|
+
doc.getElementById(startAttr) || doc.querySelector(`[data-composition-id="${startAttr}"]`);
|
|
154
|
+
if (ref) {
|
|
155
|
+
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
|
156
|
+
let refStart = parseFloat(refStartAttr);
|
|
157
|
+
// Recursively resolve one level of reference for the ref's own start
|
|
158
|
+
if (isNaN(refStart)) {
|
|
159
|
+
const refRef =
|
|
160
|
+
doc.getElementById(refStartAttr) ||
|
|
161
|
+
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
|
|
162
|
+
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
|
163
|
+
const rrCompId = refRef?.getAttribute("data-composition-id");
|
|
164
|
+
const rrDur =
|
|
165
|
+
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
|
|
166
|
+
(rrCompId
|
|
167
|
+
? ((
|
|
168
|
+
iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined
|
|
169
|
+
)?.duration?.() ?? 0)
|
|
170
|
+
: 0);
|
|
171
|
+
refStart = rrStart + rrDur;
|
|
172
|
+
}
|
|
173
|
+
const refCompId = ref.getAttribute("data-composition-id");
|
|
174
|
+
const refDur =
|
|
175
|
+
parseFloat(ref.getAttribute("data-duration") ?? "") ||
|
|
176
|
+
(refCompId
|
|
177
|
+
? ((
|
|
178
|
+
iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined
|
|
179
|
+
)?.duration?.() ?? 0)
|
|
180
|
+
: 0);
|
|
181
|
+
start = refStart + refDur;
|
|
182
|
+
} else {
|
|
183
|
+
start = 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Resolve duration from data-duration or GSAP timeline
|
|
188
|
+
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
|
|
189
|
+
if (isNaN(dur) || dur <= 0) {
|
|
190
|
+
dur =
|
|
191
|
+
(
|
|
192
|
+
iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined
|
|
193
|
+
)?.duration?.() ?? 0;
|
|
194
|
+
}
|
|
195
|
+
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
196
|
+
if (!Number.isFinite(start)) start = 0;
|
|
197
|
+
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
198
|
+
if (start >= rootDuration) return;
|
|
199
|
+
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
200
|
+
if (dur <= 0) return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const trackStr = el.getAttribute("data-track-index");
|
|
204
|
+
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
|
205
|
+
const compSrc =
|
|
206
|
+
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
207
|
+
const selector = getTimelineElementSelector(el);
|
|
208
|
+
const sourceFile = getTimelineElementSourceFile(el);
|
|
209
|
+
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
210
|
+
const label = getTimelineElementDisplayLabel({
|
|
211
|
+
id: el.id || compId || null,
|
|
212
|
+
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
213
|
+
tag: el.tagName,
|
|
214
|
+
});
|
|
215
|
+
const identity = buildTimelineElementIdentity({
|
|
216
|
+
preferredId: el.id || compId || null,
|
|
217
|
+
label,
|
|
218
|
+
fallbackIndex: missing.length,
|
|
219
|
+
domId: el.id || undefined,
|
|
220
|
+
selector,
|
|
221
|
+
selectorIndex,
|
|
222
|
+
sourceFile,
|
|
223
|
+
});
|
|
224
|
+
const entry: TimelineElement = {
|
|
225
|
+
id: identity.id,
|
|
226
|
+
label,
|
|
227
|
+
key: identity.key,
|
|
228
|
+
tag: el.tagName.toLowerCase(),
|
|
229
|
+
start,
|
|
230
|
+
duration: dur,
|
|
231
|
+
track: isNaN(track) ? 0 : track,
|
|
232
|
+
domId: el.id || undefined,
|
|
233
|
+
selector,
|
|
234
|
+
selectorIndex,
|
|
235
|
+
sourceFile,
|
|
236
|
+
};
|
|
237
|
+
if (compSrc) {
|
|
238
|
+
entry.compositionSrc = compSrc;
|
|
239
|
+
} else {
|
|
240
|
+
// Inline composition — expose inner video for thumbnails
|
|
241
|
+
const innerVideo = el.querySelector("video[src]");
|
|
242
|
+
if (innerVideo) {
|
|
243
|
+
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
244
|
+
entry.tag = "video";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
missing.push(entry);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Patch existing elements that are missing compositionSrc
|
|
251
|
+
let patched = false;
|
|
252
|
+
const updatedEls = (currentEls as TimelineElement[]).map((existing) => {
|
|
253
|
+
if (existing.compositionSrc) return existing;
|
|
254
|
+
// Find the matching DOM host by element id or composition id
|
|
255
|
+
const host =
|
|
256
|
+
doc.getElementById(existing.id) ??
|
|
257
|
+
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
|
258
|
+
if (!host) return existing;
|
|
259
|
+
const compSrc =
|
|
260
|
+
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
261
|
+
if (compSrc) {
|
|
262
|
+
patched = true;
|
|
263
|
+
return { ...existing, compositionSrc: compSrc };
|
|
264
|
+
}
|
|
265
|
+
return existing;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return { missing, updatedEls, patched };
|
|
269
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
2
|
+
import { getDomEditTargetKey } from "../components/editor/domEditing";
|
|
3
|
+
|
|
4
|
+
export function domEditSelectionsTargetSame(
|
|
5
|
+
a: DomEditSelection | null,
|
|
6
|
+
b: DomEditSelection | null,
|
|
7
|
+
): boolean {
|
|
8
|
+
if (a === b) return true;
|
|
9
|
+
if (!a || !b) return false;
|
|
10
|
+
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function domEditSelectionInGroup(
|
|
14
|
+
group: DomEditSelection[],
|
|
15
|
+
selection: DomEditSelection | null,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (!selection) return false;
|
|
18
|
+
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toggleDomEditGroupSelection(
|
|
22
|
+
group: DomEditSelection[],
|
|
23
|
+
selection: DomEditSelection,
|
|
24
|
+
): DomEditSelection[] {
|
|
25
|
+
if (domEditSelectionInGroup(group, selection)) {
|
|
26
|
+
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
|
|
27
|
+
}
|
|
28
|
+
return [...group, selection];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function replaceDomEditGroupSelection(
|
|
32
|
+
group: DomEditSelection[],
|
|
33
|
+
selection: DomEditSelection,
|
|
34
|
+
): DomEditSelection[] {
|
|
35
|
+
let replaced = false;
|
|
36
|
+
const nextGroup = group.map((entry) => {
|
|
37
|
+
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
|
|
38
|
+
replaced = true;
|
|
39
|
+
return selection;
|
|
40
|
+
});
|
|
41
|
+
return replaced ? nextGroup : [...group, selection];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function seedDomEditGroupWithSelection(
|
|
45
|
+
group: DomEditSelection[],
|
|
46
|
+
selection: DomEditSelection | null,
|
|
47
|
+
): DomEditSelection[] {
|
|
48
|
+
if (!selection || domEditSelectionInGroup(group, selection)) return group;
|
|
49
|
+
return [selection, ...group];
|
|
50
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { googleFontStylesheetUrl } from "../components/editor/fontCatalog";
|
|
2
|
+
import { importedFontFaceCss, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
3
|
+
import { toRelativeProjectAssetPath } from "./studioHelpers";
|
|
4
|
+
|
|
5
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
6
|
+
"inherit",
|
|
7
|
+
"initial",
|
|
8
|
+
"revert",
|
|
9
|
+
"revert-layer",
|
|
10
|
+
"serif",
|
|
11
|
+
"sans-serif",
|
|
12
|
+
"monospace",
|
|
13
|
+
"cursive",
|
|
14
|
+
"fantasy",
|
|
15
|
+
"system-ui",
|
|
16
|
+
"ui-sans-serif",
|
|
17
|
+
"ui-serif",
|
|
18
|
+
"ui-monospace",
|
|
19
|
+
"ui-rounded",
|
|
20
|
+
"emoji",
|
|
21
|
+
"math",
|
|
22
|
+
"fangsong",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export function primaryFontFamilyFromCss(value: string): string {
|
|
26
|
+
const first = value.split(",")[0] ?? "";
|
|
27
|
+
return first.trim().replace(/^["']|["']$/g, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function primaryFontFamilyValue(value: string): string {
|
|
31
|
+
return (
|
|
32
|
+
value
|
|
33
|
+
.split(",")[0]
|
|
34
|
+
?.trim()
|
|
35
|
+
.replace(/^["']|["']$/g, "")
|
|
36
|
+
.trim() ?? ""
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
|
41
|
+
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
|
42
|
+
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
|
43
|
+
|
|
44
|
+
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
45
|
+
if (doc.getElementById(id)) return;
|
|
46
|
+
|
|
47
|
+
const link = doc.createElement("link");
|
|
48
|
+
link.id = id;
|
|
49
|
+
link.rel = "stylesheet";
|
|
50
|
+
link.href = googleFontStylesheetUrl(family);
|
|
51
|
+
doc.head.appendChild(link);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
|
55
|
+
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
56
|
+
if (doc.getElementById(id)) return;
|
|
57
|
+
const style = doc.createElement("style");
|
|
58
|
+
style.id = id;
|
|
59
|
+
style.textContent = importedFontFaceCss(asset);
|
|
60
|
+
doc.head.appendChild(style);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ensureImportedFontFace(
|
|
64
|
+
html: string,
|
|
65
|
+
asset: ImportedFontAsset,
|
|
66
|
+
sourceFile: string,
|
|
67
|
+
): string {
|
|
68
|
+
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
69
|
+
if (html.includes(css)) return html;
|
|
70
|
+
|
|
71
|
+
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
72
|
+
const styleMatch = styleRe.exec(html);
|
|
73
|
+
if (styleMatch) {
|
|
74
|
+
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
75
|
+
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
79
|
+
if (/<\/head>/i.test(html)) {
|
|
80
|
+
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
|
81
|
+
}
|
|
82
|
+
return `${styleTag}\n${html}`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { TimelineElement } from "../player";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
import type { TimelineAssetKind } from "./timelineAssetDrop";
|
|
4
|
+
|
|
5
|
+
export interface EditingFile {
|
|
6
|
+
path: string;
|
|
7
|
+
content: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AppToast {
|
|
11
|
+
message: string;
|
|
12
|
+
tone: "error" | "info";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type RightPanelTab = "design" | "motion" | "renders";
|
|
16
|
+
|
|
17
|
+
export interface AgentModalAnchorPoint {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getTimelineElementLabel(element: TimelineElement): string {
|
|
23
|
+
return element.label || element.id || element.tag;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function confirmElementDelete(label: string, kind: "timeline clip" | "element"): boolean {
|
|
27
|
+
return window.confirm(
|
|
28
|
+
`Delete ${kind} "${label}"?\n\nThis removes it from the project source. You can use Undo to restore it.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizeProjectAssetPath(value: string): string {
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
35
|
+
return decodeURIComponent(maybeUrl)
|
|
36
|
+
.replace(/\\/g, "/")
|
|
37
|
+
.replace(/^\.?\//, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
41
|
+
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
|
42
|
+
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
|
43
|
+
|
|
44
|
+
fromParts.pop();
|
|
45
|
+
|
|
46
|
+
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
47
|
+
fromParts.shift();
|
|
48
|
+
targetParts.shift();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isAbsoluteFilePath(value: string): boolean {
|
|
55
|
+
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toProjectAbsolutePath(
|
|
59
|
+
projectDir: string | null,
|
|
60
|
+
sourceFile: string,
|
|
61
|
+
): string | undefined {
|
|
62
|
+
const trimmedSource = sourceFile.trim();
|
|
63
|
+
if (!trimmedSource) return undefined;
|
|
64
|
+
|
|
65
|
+
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
66
|
+
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
67
|
+
|
|
68
|
+
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
69
|
+
if (!normalizedRoot) return undefined;
|
|
70
|
+
|
|
71
|
+
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
75
|
+
const trimmed = value.trim();
|
|
76
|
+
if (!trimmed) return trimmed;
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
|
|
80
|
+
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
81
|
+
) {
|
|
82
|
+
return `${trimmed}px`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return trimmed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isImageBackgroundValue(value: string): boolean {
|
|
89
|
+
return /^url\(/i.test(value.trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isManualGeometryStyleProperty(property: string): boolean {
|
|
93
|
+
return property === "left" || property === "top" || property === "width" || property === "height";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
97
|
+
if (!target || typeof target !== "object") return null;
|
|
98
|
+
const maybeNode = target as {
|
|
99
|
+
nodeType?: number;
|
|
100
|
+
parentElement?: Element | null;
|
|
101
|
+
};
|
|
102
|
+
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
|
103
|
+
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
|
104
|
+
return maybeNode.parentElement as HTMLElement;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
|
110
|
+
const el = getEventTargetElement(target);
|
|
111
|
+
if (!el) return false;
|
|
112
|
+
return Boolean(
|
|
113
|
+
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
118
|
+
const isMac =
|
|
119
|
+
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
120
|
+
const modifier = isMac ? "Cmd" : "Ctrl";
|
|
121
|
+
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findMatchingTimelineElementId(
|
|
125
|
+
selection: Pick<
|
|
126
|
+
DomEditSelection,
|
|
127
|
+
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
128
|
+
>,
|
|
129
|
+
elements: TimelineElement[],
|
|
130
|
+
): string | null {
|
|
131
|
+
const selectionSourceFile = selection.sourceFile || "index.html";
|
|
132
|
+
for (const element of elements) {
|
|
133
|
+
const elementSourceFile = element.sourceFile || "index.html";
|
|
134
|
+
if (
|
|
135
|
+
selection.id &&
|
|
136
|
+
element.domId === selection.id &&
|
|
137
|
+
elementSourceFile === selectionSourceFile
|
|
138
|
+
) {
|
|
139
|
+
return element.key ?? element.id;
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
selection.isCompositionHost &&
|
|
143
|
+
selection.compositionSrc &&
|
|
144
|
+
element.compositionSrc === selection.compositionSrc
|
|
145
|
+
) {
|
|
146
|
+
return element.key ?? element.id;
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
selection.selector &&
|
|
150
|
+
element.selector === selection.selector &&
|
|
151
|
+
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
152
|
+
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
153
|
+
) {
|
|
154
|
+
return element.key ?? element.id;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function clampNumber(value: number, min: number, max: number): number {
|
|
162
|
+
if (max < min) return min;
|
|
163
|
+
return Math.min(Math.max(value, min), max);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function collectHtmlIds(source: string): string[] {
|
|
167
|
+
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
171
|
+
image: 3,
|
|
172
|
+
video: 5,
|
|
173
|
+
audio: 5,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export async function resolveDroppedAssetDuration(
|
|
177
|
+
projectId: string,
|
|
178
|
+
assetPath: string,
|
|
179
|
+
kind: TimelineAssetKind,
|
|
180
|
+
): Promise<number> {
|
|
181
|
+
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
|
|
182
|
+
|
|
183
|
+
const media = document.createElement(kind === "video" ? "video" : "audio");
|
|
184
|
+
media.preload = "metadata";
|
|
185
|
+
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
|
|
186
|
+
|
|
187
|
+
const duration = await new Promise<number>((resolve) => {
|
|
188
|
+
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
|
189
|
+
const finalize = (value: number) => {
|
|
190
|
+
window.clearTimeout(timeout);
|
|
191
|
+
resolve(value);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
media.addEventListener(
|
|
195
|
+
"loadedmetadata",
|
|
196
|
+
() => {
|
|
197
|
+
const raw = Number(media.duration);
|
|
198
|
+
finalize(
|
|
199
|
+
Number.isFinite(raw) && raw > 0
|
|
200
|
+
? Math.round(raw * 100) / 100
|
|
201
|
+
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
{ once: true },
|
|
205
|
+
);
|
|
206
|
+
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
|
|
207
|
+
once: true,
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
media.src = "";
|
|
212
|
+
media.load();
|
|
213
|
+
return duration;
|
|
214
|
+
}
|