@hyperframes/studio 0.1.0
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-B1830ANq.js +78 -0
- package/dist/assets/index-KoBceNoU.css +1 -0
- package/dist/icons/timeline/audio.svg +7 -0
- package/dist/icons/timeline/captions.svg +5 -0
- package/dist/icons/timeline/composition.svg +12 -0
- package/dist/icons/timeline/image.svg +18 -0
- package/dist/icons/timeline/music.svg +10 -0
- package/dist/icons/timeline/text.svg +3 -0
- package/dist/index.html +13 -0
- package/package.json +50 -0
- package/src/App.tsx +557 -0
- package/src/components/editor/FileTree.tsx +70 -0
- package/src/components/editor/PropertyPanel.tsx +209 -0
- package/src/components/editor/SourceEditor.tsx +116 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
- package/src/components/nle/NLELayout.tsx +252 -0
- package/src/components/nle/NLEPreview.tsx +37 -0
- package/src/components/ui/Button.tsx +123 -0
- package/src/components/ui/index.ts +2 -0
- package/src/hooks/useCodeEditor.ts +82 -0
- package/src/hooks/useElementPicker.ts +338 -0
- package/src/hooks/useMountEffect.ts +18 -0
- package/src/icons/SystemIcons.tsx +130 -0
- package/src/index.ts +31 -0
- package/src/main.tsx +10 -0
- package/src/player/components/AgentActivityTrack.tsx +98 -0
- package/src/player/components/Player.tsx +120 -0
- package/src/player/components/PlayerControls.tsx +181 -0
- package/src/player/components/PreviewPanel.tsx +149 -0
- package/src/player/components/Timeline.tsx +431 -0
- package/src/player/hooks/useTimelinePlayer.ts +465 -0
- package/src/player/index.ts +17 -0
- package/src/player/lib/time.ts +5 -0
- package/src/player/lib/useMountEffect.ts +10 -0
- package/src/player/store/playerStore.ts +93 -0
- package/src/styles/studio.css +31 -0
- package/src/utils/sourcePatcher.ts +149 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button & IconButton — The most important primitive.
|
|
3
|
+
*
|
|
4
|
+
* Absorbs: active state (scale 0.98), hit target (min 32px),
|
|
5
|
+
* shadow anatomy (primary), focus ring, disabled state,
|
|
6
|
+
* loading state, reduced motion, proper timing tokens.
|
|
7
|
+
*
|
|
8
|
+
* Rules applied:
|
|
9
|
+
* - physics-active-state: scale(0.98) on :active
|
|
10
|
+
* - ux-fitts-target-size: min 32px hit target
|
|
11
|
+
* - visual-button-shadow-anatomy: 6-layer shadow on primary
|
|
12
|
+
* - duration-press-hover: 120ms press, 150ms hover
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
16
|
+
|
|
17
|
+
// -- Button --
|
|
18
|
+
|
|
19
|
+
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
20
|
+
type ButtonSize = "sm" | "md" | "lg";
|
|
21
|
+
|
|
22
|
+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
23
|
+
variant?: ButtonVariant;
|
|
24
|
+
size?: ButtonSize;
|
|
25
|
+
loading?: boolean;
|
|
26
|
+
icon?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const variantStyles: Record<ButtonVariant, string> = {
|
|
30
|
+
primary: [
|
|
31
|
+
"bg-white text-neutral-950 font-medium",
|
|
32
|
+
"shadow-btn-primary",
|
|
33
|
+
"hover:bg-neutral-200",
|
|
34
|
+
"active:scale-[0.97]",
|
|
35
|
+
].join(" "),
|
|
36
|
+
secondary: [
|
|
37
|
+
"bg-transparent text-neutral-300 font-medium",
|
|
38
|
+
"border border-border",
|
|
39
|
+
"hover:bg-surface-hover hover:text-white hover:border-border-strong",
|
|
40
|
+
"active:scale-[0.98]",
|
|
41
|
+
].join(" "),
|
|
42
|
+
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(" "),
|
|
43
|
+
ghost: ["bg-transparent text-neutral-400", "hover:bg-surface-hover hover:text-white", "active:scale-[0.98]"].join(
|
|
44
|
+
" ",
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sizeStyles: Record<ButtonSize, string> = {
|
|
49
|
+
sm: "h-7 px-2.5 text-xs gap-1.5 rounded-button",
|
|
50
|
+
md: "h-8 px-3 text-sm gap-1.5 rounded-button",
|
|
51
|
+
lg: "h-9 px-4 text-base gap-2 rounded-button",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
55
|
+
({ variant = "secondary", size = "md", loading, icon, children, className = "", disabled, ...props }, ref) => {
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
ref={ref}
|
|
59
|
+
disabled={disabled || loading}
|
|
60
|
+
className={[
|
|
61
|
+
"inline-flex items-center justify-center",
|
|
62
|
+
"transition-all duration-press ease-standard",
|
|
63
|
+
"disabled:opacity-40 disabled:pointer-events-none",
|
|
64
|
+
"select-none cursor-pointer",
|
|
65
|
+
variantStyles[variant],
|
|
66
|
+
sizeStyles[size],
|
|
67
|
+
className,
|
|
68
|
+
].join(" ")}
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
{loading ? (
|
|
72
|
+
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
|
|
73
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
74
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
75
|
+
</svg>
|
|
76
|
+
) : icon ? (
|
|
77
|
+
<span className="flex-shrink-0">{icon}</span>
|
|
78
|
+
) : null}
|
|
79
|
+
{children && <span>{children}</span>}
|
|
80
|
+
</button>
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
Button.displayName = "Button";
|
|
85
|
+
|
|
86
|
+
// -- IconButton --
|
|
87
|
+
// For icon-only buttons. Enforces min 32px hit target.
|
|
88
|
+
|
|
89
|
+
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
90
|
+
icon: ReactNode;
|
|
91
|
+
size?: ButtonSize;
|
|
92
|
+
variant?: ButtonVariant;
|
|
93
|
+
"aria-label": string; // REQUIRED for accessibility
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const iconSizeStyles: Record<ButtonSize, string> = {
|
|
97
|
+
sm: "min-w-7 min-h-7 rounded-button", // 28px
|
|
98
|
+
md: "min-w-8 min-h-8 rounded-button", // 32px — minimum recommended
|
|
99
|
+
lg: "min-w-9 min-h-9 rounded-button", // 36px
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
103
|
+
({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
ref={ref}
|
|
107
|
+
className={[
|
|
108
|
+
"inline-flex items-center justify-center",
|
|
109
|
+
"transition-all duration-press ease-standard",
|
|
110
|
+
"disabled:opacity-40 disabled:pointer-events-none",
|
|
111
|
+
"select-none cursor-pointer",
|
|
112
|
+
variantStyles[variant],
|
|
113
|
+
iconSizeStyles[size],
|
|
114
|
+
className,
|
|
115
|
+
].join(" ")}
|
|
116
|
+
{...props}
|
|
117
|
+
>
|
|
118
|
+
{icon}
|
|
119
|
+
</button>
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
IconButton.displayName = "IconButton";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
export interface OpenFile {
|
|
4
|
+
path: string;
|
|
5
|
+
content: string;
|
|
6
|
+
savedContent: string;
|
|
7
|
+
isDirty: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseCodeEditorReturn {
|
|
11
|
+
openFiles: OpenFile[];
|
|
12
|
+
activeFilePath: string | null;
|
|
13
|
+
activeFile: OpenFile | null;
|
|
14
|
+
openFile: (path: string, content: string) => void;
|
|
15
|
+
closeFile: (path: string) => void;
|
|
16
|
+
setActiveFile: (path: string) => void;
|
|
17
|
+
updateContent: (content: string) => void;
|
|
18
|
+
markSaved: (path: string) => void;
|
|
19
|
+
/** External update from agent — updates saved content, shows reload indicator */
|
|
20
|
+
externalUpdate: (path: string, content: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useCodeEditor(): UseCodeEditorReturn {
|
|
24
|
+
const [openFiles, setOpenFiles] = useState<OpenFile[]>([]);
|
|
25
|
+
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
const activeFile = openFiles.find((f) => f.path === activeFilePath) ?? null;
|
|
28
|
+
|
|
29
|
+
const openFile = useCallback((path: string, content: string) => {
|
|
30
|
+
setOpenFiles((prev) => {
|
|
31
|
+
const existing = prev.find((f) => f.path === path);
|
|
32
|
+
if (existing) return prev;
|
|
33
|
+
return [...prev, { path, content, savedContent: content, isDirty: false }];
|
|
34
|
+
});
|
|
35
|
+
setActiveFilePath(path);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const closeFile = useCallback(
|
|
39
|
+
(path: string) => {
|
|
40
|
+
setOpenFiles((prev) => prev.filter((f) => f.path !== path));
|
|
41
|
+
setActiveFilePath((prev) => {
|
|
42
|
+
if (prev === path) {
|
|
43
|
+
const remaining = openFiles.filter((f) => f.path !== path);
|
|
44
|
+
return remaining.length > 0 ? remaining[remaining.length - 1].path : null;
|
|
45
|
+
}
|
|
46
|
+
return prev;
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
[openFiles],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const updateContent = useCallback(
|
|
53
|
+
(content: string) => {
|
|
54
|
+
setOpenFiles((prev) =>
|
|
55
|
+
prev.map((f) => (f.path === activeFilePath ? { ...f, content, isDirty: content !== f.savedContent } : f)),
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
[activeFilePath],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const markSaved = useCallback((path: string) => {
|
|
62
|
+
setOpenFiles((prev) => prev.map((f) => (f.path === path ? { ...f, savedContent: f.content, isDirty: false } : f)));
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const externalUpdate = useCallback((path: string, content: string) => {
|
|
66
|
+
setOpenFiles((prev) =>
|
|
67
|
+
prev.map((f) => (f.path === path ? { ...f, savedContent: content, content, isDirty: false } : f)),
|
|
68
|
+
);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
openFiles,
|
|
73
|
+
activeFilePath,
|
|
74
|
+
activeFile,
|
|
75
|
+
openFile,
|
|
76
|
+
closeFile,
|
|
77
|
+
setActiveFile: setActiveFilePath,
|
|
78
|
+
updateContent,
|
|
79
|
+
markSaved,
|
|
80
|
+
externalUpdate,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { useMountEffect } from "./useMountEffect";
|
|
3
|
+
import { resolveSourceFile, applyPatch } from "../utils/sourcePatcher";
|
|
4
|
+
|
|
5
|
+
export interface PickedElement {
|
|
6
|
+
id: string | null;
|
|
7
|
+
tagName: string;
|
|
8
|
+
selector: string;
|
|
9
|
+
label: string;
|
|
10
|
+
boundingBox: { x: number; y: number; width: number; height: number };
|
|
11
|
+
textContent: string | null;
|
|
12
|
+
src: string | null;
|
|
13
|
+
dataAttributes: Record<string, string>;
|
|
14
|
+
computedStyles: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UseElementPickerReturn {
|
|
18
|
+
isPickMode: boolean;
|
|
19
|
+
pickedElement: PickedElement | null;
|
|
20
|
+
enablePick: () => void;
|
|
21
|
+
disablePick: () => void;
|
|
22
|
+
clearPick: () => void;
|
|
23
|
+
/** Update a CSS property on the picked element live + persist to source */
|
|
24
|
+
setStyle: (prop: string, value: string) => void;
|
|
25
|
+
/** Update a data attribute on the picked element + persist to source */
|
|
26
|
+
setDataAttr: (attr: string, value: string) => void;
|
|
27
|
+
/** Update the text content of the picked element + persist to source */
|
|
28
|
+
setTextContent: (text: string) => void;
|
|
29
|
+
/** Override the active iframe (for zoomed canvas view). Pass null to restore primary. */
|
|
30
|
+
setActiveIframe: (el: HTMLIFrameElement | null) => void;
|
|
31
|
+
/** Ref that always points to the active iframe (focused canvas frame or preview panel) */
|
|
32
|
+
activeIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PickerOptions {
|
|
36
|
+
/** Workspace files for source patching */
|
|
37
|
+
workspaceFiles?: Record<string, string>;
|
|
38
|
+
/** Callback to sync patched files to the project */
|
|
39
|
+
onSyncFiles?: (files: Record<string, string>) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hook for element picking via the HyperFrame runtime's picker API.
|
|
44
|
+
* Communicates with the iframe via postMessage.
|
|
45
|
+
*/
|
|
46
|
+
export function useElementPicker(
|
|
47
|
+
iframeRef: React.RefObject<HTMLIFrameElement | null>,
|
|
48
|
+
options?: PickerOptions,
|
|
49
|
+
): UseElementPickerReturn {
|
|
50
|
+
const [isPickMode, setIsPickMode] = useState(false);
|
|
51
|
+
const [pickedElement, setPickedElement] = useState<PickedElement | null>(null);
|
|
52
|
+
|
|
53
|
+
// Secondary/override iframe ref — set when a zoomed frame is active.
|
|
54
|
+
// When set, all postMessage sends and DOM reads go to this ref instead.
|
|
55
|
+
const activeOverrideRef = useRef<HTMLIFrameElement | null>(null);
|
|
56
|
+
|
|
57
|
+
const getActiveIframe = useCallback((): HTMLIFrameElement | null => {
|
|
58
|
+
return activeOverrideRef.current ?? iframeRef.current;
|
|
59
|
+
}, [iframeRef]);
|
|
60
|
+
|
|
61
|
+
// Exposed so the host page can wire the focused view's iframe into the picker
|
|
62
|
+
const setActiveIframe = useCallback((el: HTMLIFrameElement | null) => {
|
|
63
|
+
activeOverrideRef.current = el;
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const enablePick = useCallback(() => {
|
|
67
|
+
try {
|
|
68
|
+
getActiveIframe()?.contentWindow?.postMessage(
|
|
69
|
+
{ source: "hf-parent", type: "control", action: "enable-pick-mode" },
|
|
70
|
+
"*",
|
|
71
|
+
);
|
|
72
|
+
setIsPickMode(true);
|
|
73
|
+
} catch {
|
|
74
|
+
/* cross-origin */
|
|
75
|
+
}
|
|
76
|
+
}, [getActiveIframe]);
|
|
77
|
+
|
|
78
|
+
const disablePick = useCallback(() => {
|
|
79
|
+
try {
|
|
80
|
+
getActiveIframe()?.contentWindow?.postMessage(
|
|
81
|
+
{ source: "hf-parent", type: "control", action: "disable-pick-mode" },
|
|
82
|
+
"*",
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
/* cross-origin */
|
|
86
|
+
}
|
|
87
|
+
setIsPickMode(false);
|
|
88
|
+
}, [getActiveIframe]);
|
|
89
|
+
|
|
90
|
+
const clearPick = useCallback(() => {
|
|
91
|
+
setPickedElement(null);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// Listen for picker messages from the iframe
|
|
95
|
+
useMountEffect(() => {
|
|
96
|
+
const handleMessage = (e: MessageEvent) => {
|
|
97
|
+
const data = e.data;
|
|
98
|
+
if (data?.source !== "hf-preview") return;
|
|
99
|
+
// Accept events from either the primary iframe or the active override
|
|
100
|
+
const activeIframe = getActiveIframe();
|
|
101
|
+
if (!activeIframe) return;
|
|
102
|
+
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow) return;
|
|
103
|
+
|
|
104
|
+
if (data.type === "element-picked") {
|
|
105
|
+
const el = data.elementInfo;
|
|
106
|
+
if (el) {
|
|
107
|
+
const styles = readComputedStyles(activeIframe, el.selector);
|
|
108
|
+
setPickedElement({
|
|
109
|
+
id: el.id ?? null,
|
|
110
|
+
tagName: el.tagName ?? "div",
|
|
111
|
+
selector: el.selector ?? "",
|
|
112
|
+
label: el.label ?? el.tagName ?? "Element",
|
|
113
|
+
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
114
|
+
textContent: el.textContent ?? null,
|
|
115
|
+
src: el.src ?? null,
|
|
116
|
+
dataAttributes: el.dataAttributes ?? {},
|
|
117
|
+
computedStyles: styles,
|
|
118
|
+
});
|
|
119
|
+
setIsPickMode(false);
|
|
120
|
+
}
|
|
121
|
+
} else if (data.type === "element-pick-candidates") {
|
|
122
|
+
// Multiple candidates at click point — pick the first one
|
|
123
|
+
const el = data.candidates?.[data.selectedIndex ?? 0];
|
|
124
|
+
if (el) {
|
|
125
|
+
const styles = readComputedStyles(activeIframe, el.selector);
|
|
126
|
+
setPickedElement({
|
|
127
|
+
id: el.id ?? null,
|
|
128
|
+
tagName: el.tagName ?? "div",
|
|
129
|
+
selector: el.selector ?? "",
|
|
130
|
+
label: el.label ?? el.tagName ?? "Element",
|
|
131
|
+
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
132
|
+
textContent: el.textContent ?? null,
|
|
133
|
+
src: el.src ?? null,
|
|
134
|
+
dataAttributes: el.dataAttributes ?? {},
|
|
135
|
+
computedStyles: styles,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (data.type === "pick-mode-cancelled") {
|
|
141
|
+
setIsPickMode(false);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
window.addEventListener("message", handleMessage);
|
|
146
|
+
return () => window.removeEventListener("message", handleMessage);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Ref for options to avoid stale closures in debounced callback
|
|
150
|
+
const optionsRef = useRef(options);
|
|
151
|
+
optionsRef.current = options;
|
|
152
|
+
|
|
153
|
+
// Sync immediately (not debounced) — save on every change for reliability
|
|
154
|
+
const syncToSource = useCallback(
|
|
155
|
+
(
|
|
156
|
+
elementId: string,
|
|
157
|
+
selector: string,
|
|
158
|
+
op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string },
|
|
159
|
+
) => {
|
|
160
|
+
const opts = optionsRef.current;
|
|
161
|
+
if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
|
|
162
|
+
const files = opts.workspaceFiles;
|
|
163
|
+
const sourceFile = resolveSourceFile(elementId, selector, files);
|
|
164
|
+
if (!sourceFile || !files[sourceFile]) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const patched = applyPatch(files[sourceFile], elementId, op);
|
|
168
|
+
if (patched !== files[sourceFile]) {
|
|
169
|
+
opts.onSyncFiles({ [sourceFile]: patched });
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
[],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const setStyle = useCallback(
|
|
176
|
+
(prop: string, value: string) => {
|
|
177
|
+
const activeIframe = getActiveIframe();
|
|
178
|
+
if (!pickedElement?.selector || !activeIframe) return;
|
|
179
|
+
try {
|
|
180
|
+
const doc = activeIframe.contentDocument;
|
|
181
|
+
const el = doc?.querySelector(pickedElement.selector) as HTMLElement | null;
|
|
182
|
+
if (el) {
|
|
183
|
+
el.style.setProperty(prop, value);
|
|
184
|
+
setPickedElement((prev) =>
|
|
185
|
+
prev
|
|
186
|
+
? {
|
|
187
|
+
...prev,
|
|
188
|
+
computedStyles: { ...prev.computedStyles, [prop]: value },
|
|
189
|
+
}
|
|
190
|
+
: null,
|
|
191
|
+
);
|
|
192
|
+
// Persist to source file
|
|
193
|
+
if (pickedElement.id) {
|
|
194
|
+
// ID-based patching — surgical edit of just the element's style
|
|
195
|
+
syncToSource(pickedElement.id, pickedElement.selector, { type: "inline-style", property: prop, value });
|
|
196
|
+
} else {
|
|
197
|
+
// No ID — save the full composition HTML from the iframe
|
|
198
|
+
// This captures ALL inline style changes, not just the targeted one
|
|
199
|
+
try {
|
|
200
|
+
const fullHtml = activeIframe.contentDocument?.documentElement.outerHTML;
|
|
201
|
+
if (fullHtml && optionsRef.current?.onSyncFiles) {
|
|
202
|
+
// Determine which file this iframe represents
|
|
203
|
+
const src = activeIframe.getAttribute("src") ?? "";
|
|
204
|
+
const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
|
|
205
|
+
const filePath = compMatch ? compMatch[1] : "index.html";
|
|
206
|
+
optionsRef.current.onSyncFiles({ [filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}` });
|
|
207
|
+
}
|
|
208
|
+
} catch { /* cross-origin */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
/* cross-origin */
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
[pickedElement, getActiveIframe, syncToSource],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const setDataAttr = useCallback(
|
|
219
|
+
(attr: string, value: string) => {
|
|
220
|
+
const activeIframe = getActiveIframe();
|
|
221
|
+
if (!pickedElement?.selector || !activeIframe) return;
|
|
222
|
+
try {
|
|
223
|
+
const doc = activeIframe.contentDocument;
|
|
224
|
+
const el = doc?.querySelector(pickedElement.selector);
|
|
225
|
+
if (el) {
|
|
226
|
+
el.setAttribute(`data-${attr}`, value);
|
|
227
|
+
setPickedElement((prev) =>
|
|
228
|
+
prev
|
|
229
|
+
? {
|
|
230
|
+
...prev,
|
|
231
|
+
dataAttributes: { ...prev.dataAttributes, [attr]: value },
|
|
232
|
+
}
|
|
233
|
+
: null,
|
|
234
|
+
);
|
|
235
|
+
// Persist to source file immediately
|
|
236
|
+
if (pickedElement.id) {
|
|
237
|
+
syncToSource(pickedElement.id, pickedElement.selector, { type: "attribute", property: attr, value });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
/* cross-origin */
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
[pickedElement, getActiveIframe, syncToSource],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const setTextContent = useCallback(
|
|
248
|
+
(text: string) => {
|
|
249
|
+
const activeIframe = getActiveIframe();
|
|
250
|
+
if (!pickedElement?.selector || !activeIframe) return;
|
|
251
|
+
try {
|
|
252
|
+
const doc = activeIframe.contentDocument;
|
|
253
|
+
const el = doc?.querySelector(pickedElement.selector);
|
|
254
|
+
if (el) {
|
|
255
|
+
el.textContent = text;
|
|
256
|
+
setPickedElement((prev) => (prev ? { ...prev, textContent: text } : null));
|
|
257
|
+
// Persist to source file
|
|
258
|
+
if (pickedElement.id) {
|
|
259
|
+
syncToSource(pickedElement.id, pickedElement.selector, {
|
|
260
|
+
type: "text-content",
|
|
261
|
+
property: "textContent",
|
|
262
|
+
value: text,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
/* cross-origin */
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
[pickedElement, getActiveIframe, syncToSource],
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Ref-like object that always points to the active iframe (override or primary)
|
|
274
|
+
const activeIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
275
|
+
activeIframeRef.current = getActiveIframe();
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
isPickMode,
|
|
279
|
+
pickedElement,
|
|
280
|
+
enablePick,
|
|
281
|
+
disablePick,
|
|
282
|
+
clearPick,
|
|
283
|
+
setStyle,
|
|
284
|
+
setDataAttr,
|
|
285
|
+
setTextContent,
|
|
286
|
+
setActiveIframe,
|
|
287
|
+
/** Ref that always points to the active iframe (focused canvas frame or preview panel) */
|
|
288
|
+
activeIframeRef,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Read a subset of computed styles from an element in the iframe */
|
|
293
|
+
function readComputedStyles(iframe: HTMLIFrameElement, selector: string): Record<string, string> {
|
|
294
|
+
const styles: Record<string, string> = {};
|
|
295
|
+
try {
|
|
296
|
+
const doc = iframe.contentDocument;
|
|
297
|
+
const el = doc?.querySelector(selector);
|
|
298
|
+
if (!el) return styles;
|
|
299
|
+
const computed = iframe.contentWindow?.getComputedStyle(el);
|
|
300
|
+
if (!computed) return styles;
|
|
301
|
+
|
|
302
|
+
const props = [
|
|
303
|
+
"position",
|
|
304
|
+
"top",
|
|
305
|
+
"left",
|
|
306
|
+
"right",
|
|
307
|
+
"bottom",
|
|
308
|
+
"width",
|
|
309
|
+
"height",
|
|
310
|
+
"margin-top",
|
|
311
|
+
"margin-right",
|
|
312
|
+
"margin-bottom",
|
|
313
|
+
"margin-left",
|
|
314
|
+
"padding-top",
|
|
315
|
+
"padding-right",
|
|
316
|
+
"padding-bottom",
|
|
317
|
+
"padding-left",
|
|
318
|
+
"font-size",
|
|
319
|
+
"font-weight",
|
|
320
|
+
"font-family",
|
|
321
|
+
"color",
|
|
322
|
+
"background-color",
|
|
323
|
+
"background",
|
|
324
|
+
"opacity",
|
|
325
|
+
"border-radius",
|
|
326
|
+
"transform",
|
|
327
|
+
"z-index",
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
for (const prop of props) {
|
|
331
|
+
const val = computed.getPropertyValue(prop);
|
|
332
|
+
if (val) styles[prop] = val;
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
/* cross-origin */
|
|
336
|
+
}
|
|
337
|
+
return styles;
|
|
338
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run an effect exactly once on mount (and optional cleanup on unmount).
|
|
5
|
+
* This is the ONLY sanctioned way to call useEffect in this codebase.
|
|
6
|
+
*
|
|
7
|
+
* If you need to react to prop/state changes, use one of:
|
|
8
|
+
* - Derived state (compute inline, no hook needed)
|
|
9
|
+
* - Event handlers (onClick, onChange, etc.)
|
|
10
|
+
* - `key` prop to force remount
|
|
11
|
+
* - Data-fetching library (useQuery, useSWR)
|
|
12
|
+
*
|
|
13
|
+
* @see https://react.dev/learn/you-might-not-need-an-effect
|
|
14
|
+
*/
|
|
15
|
+
export function useMountEffect(effect: () => void | (() => void)) {
|
|
16
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
17
|
+
useEffect(effect, []);
|
|
18
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WarningCircle,
|
|
3
|
+
Warning,
|
|
4
|
+
ArrowLeft as PhArrowLeft,
|
|
5
|
+
Check as PhCheck,
|
|
6
|
+
CheckCircle as PhCheckCircle,
|
|
7
|
+
Circle as PhCircle,
|
|
8
|
+
Clock as PhClock,
|
|
9
|
+
Code as PhCode,
|
|
10
|
+
DownloadSimple,
|
|
11
|
+
Pencil as PhPencil,
|
|
12
|
+
ArrowSquareOut,
|
|
13
|
+
Eye as PhEye,
|
|
14
|
+
EyeClosed,
|
|
15
|
+
File as PhFile,
|
|
16
|
+
FileCode as PhFileCode,
|
|
17
|
+
FileText as PhFileText,
|
|
18
|
+
FilmStrip,
|
|
19
|
+
Heart as PhHeart,
|
|
20
|
+
Image as PhImage,
|
|
21
|
+
Info as PhInfo,
|
|
22
|
+
Stack,
|
|
23
|
+
SpinnerGap,
|
|
24
|
+
ArrowsOut,
|
|
25
|
+
CornersOut,
|
|
26
|
+
ChatCircle,
|
|
27
|
+
ChatCenteredText,
|
|
28
|
+
Cursor,
|
|
29
|
+
ArrowsOutCardinal,
|
|
30
|
+
MusicNote,
|
|
31
|
+
Palette as PhPalette,
|
|
32
|
+
Paperclip as PhPaperclip,
|
|
33
|
+
Pause as PhPause,
|
|
34
|
+
Play as PhPlay,
|
|
35
|
+
Plus as PhPlus,
|
|
36
|
+
MagnifyingGlass,
|
|
37
|
+
PaperPlaneRight,
|
|
38
|
+
SkipBack as PhSkipBack,
|
|
39
|
+
SkipForward as PhSkipForward,
|
|
40
|
+
Square as PhSquare,
|
|
41
|
+
Trash,
|
|
42
|
+
TextT,
|
|
43
|
+
UploadSimple,
|
|
44
|
+
User as PhUser,
|
|
45
|
+
UsersThree,
|
|
46
|
+
VideoCamera,
|
|
47
|
+
X as PhX,
|
|
48
|
+
Lightning,
|
|
49
|
+
MagnifyingGlassPlus,
|
|
50
|
+
MagnifyingGlassMinus,
|
|
51
|
+
Terminal as PhTerminal,
|
|
52
|
+
CaretDown,
|
|
53
|
+
CaretRight,
|
|
54
|
+
ClipboardText,
|
|
55
|
+
ArrowCounterClockwise,
|
|
56
|
+
Gear,
|
|
57
|
+
} from "@phosphor-icons/react";
|
|
58
|
+
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
59
|
+
|
|
60
|
+
type IconProps = PhosphorIconProps & { title?: string };
|
|
61
|
+
|
|
62
|
+
const makeIcon = (Icon: PhosphorIcon) => {
|
|
63
|
+
const Wrapped = ({ title, ...props }: IconProps) => (
|
|
64
|
+
<Icon alt={title} aria-label={title} aria-hidden={title ? undefined : true} {...props} />
|
|
65
|
+
);
|
|
66
|
+
return Wrapped;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Lucide name → Phosphor equivalent
|
|
70
|
+
export const AlertCircle = makeIcon(WarningCircle);
|
|
71
|
+
export const AlertTriangle = makeIcon(Warning);
|
|
72
|
+
export const ArrowLeft = makeIcon(PhArrowLeft);
|
|
73
|
+
export const Check = makeIcon(PhCheck);
|
|
74
|
+
export const CheckCircle = makeIcon(PhCheckCircle);
|
|
75
|
+
/** CheckCircle2 in lucide is visually identical to CheckCircle */
|
|
76
|
+
export const CheckCircle2 = makeIcon(PhCheckCircle);
|
|
77
|
+
export const Circle = makeIcon(PhCircle);
|
|
78
|
+
export const Clock = makeIcon(PhClock);
|
|
79
|
+
export const Code = makeIcon(PhCode);
|
|
80
|
+
export const Download = makeIcon(DownloadSimple);
|
|
81
|
+
export const Edit2 = makeIcon(PhPencil);
|
|
82
|
+
export const ExternalLink = makeIcon(ArrowSquareOut);
|
|
83
|
+
export const Eye = makeIcon(PhEye);
|
|
84
|
+
export const EyeOff = makeIcon(EyeClosed);
|
|
85
|
+
export const File = makeIcon(PhFile);
|
|
86
|
+
export const FileCode = makeIcon(PhFileCode);
|
|
87
|
+
// FileIcon alias (lucide exports both `File` and `FileIcon`)
|
|
88
|
+
export const FileIcon = makeIcon(PhFile);
|
|
89
|
+
export const FileText = makeIcon(PhFileText);
|
|
90
|
+
export const Film = makeIcon(FilmStrip);
|
|
91
|
+
export const Heart = makeIcon(PhHeart);
|
|
92
|
+
export const Image = makeIcon(PhImage);
|
|
93
|
+
export const Info = makeIcon(PhInfo);
|
|
94
|
+
export const Layers = makeIcon(Stack);
|
|
95
|
+
export const Loader2 = makeIcon(SpinnerGap);
|
|
96
|
+
export const Maximize = makeIcon(ArrowsOut);
|
|
97
|
+
export const Maximize2 = makeIcon(CornersOut);
|
|
98
|
+
export const MessageCircle = makeIcon(ChatCircle);
|
|
99
|
+
export const MessageSquare = makeIcon(ChatCenteredText);
|
|
100
|
+
export const MousePointer = makeIcon(Cursor);
|
|
101
|
+
export const Move = makeIcon(ArrowsOutCardinal);
|
|
102
|
+
export const Music = makeIcon(MusicNote);
|
|
103
|
+
export const Palette = makeIcon(PhPalette);
|
|
104
|
+
export const Paperclip = makeIcon(PhPaperclip);
|
|
105
|
+
export const Pause = makeIcon(PhPause);
|
|
106
|
+
export const Pencil = makeIcon(PhPencil);
|
|
107
|
+
export const Play = makeIcon(PhPlay);
|
|
108
|
+
export const Plus = makeIcon(PhPlus);
|
|
109
|
+
export const Search = makeIcon(MagnifyingGlass);
|
|
110
|
+
export const Send = makeIcon(PaperPlaneRight);
|
|
111
|
+
export const SkipBack = makeIcon(PhSkipBack);
|
|
112
|
+
export const SkipForward = makeIcon(PhSkipForward);
|
|
113
|
+
export const Square = makeIcon(PhSquare);
|
|
114
|
+
export const Trash2 = makeIcon(Trash);
|
|
115
|
+
export const Type = makeIcon(TextT);
|
|
116
|
+
export const Upload = makeIcon(UploadSimple);
|
|
117
|
+
export const User = makeIcon(PhUser);
|
|
118
|
+
export const Users = makeIcon(UsersThree);
|
|
119
|
+
export const Video = makeIcon(VideoCamera);
|
|
120
|
+
export const X = makeIcon(PhX);
|
|
121
|
+
export const Zap = makeIcon(Lightning);
|
|
122
|
+
export const ZoomIn = makeIcon(MagnifyingGlassPlus);
|
|
123
|
+
export const ZoomOut = makeIcon(MagnifyingGlassMinus);
|
|
124
|
+
// Extra icons used in this project (not in lucide's default mapping above)
|
|
125
|
+
export const Terminal = makeIcon(PhTerminal);
|
|
126
|
+
export const ChevronDown = makeIcon(CaretDown);
|
|
127
|
+
export const ChevronRight = makeIcon(CaretRight);
|
|
128
|
+
export const ClipboardList = makeIcon(ClipboardText);
|
|
129
|
+
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
130
|
+
export const Settings = makeIcon(Gear);
|