mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
refactor(studio): update layout, config, and remove agent activity tracking (#64)
## Summary - **NLELayout**: Add toolbar slot, composition breadcrumb navigation, improved responsive layout - **Vite config**: Add full project API (preview, thumbnail, render, file CRUD) for standalone dev mode - Remove AgentActivityTrack component (replaced by timeline clips) - Add HTML editor utilities for composition source editing - Guard setInterval cleanup to dev-only to prevent `vite build` from hanging in CI 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
+110
-13
@@ -1,7 +1,10 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
|
||||
import { NLELayout } from "./components/nle/NLELayout";
|
||||
import { SourceEditor } from "./components/editor/SourceEditor";
|
||||
import { FileTree } from "./components/editor/FileTree";
|
||||
import { CompositionThumbnail } from "./player/components/CompositionThumbnail";
|
||||
import { VideoThumbnail } from "./player/components/VideoThumbnail";
|
||||
import type { TimelineElement } from "./player/store/playerStore";
|
||||
import {
|
||||
XIcon,
|
||||
CodeIcon,
|
||||
@@ -80,6 +83,24 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
|
||||
const errors = findings.filter((f) => f.severity === "error");
|
||||
const warnings = findings.filter((f) => f.severity === "warning");
|
||||
const hasIssues = findings.length > 0;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyToAgent = async () => {
|
||||
const lines = findings.map((f) => {
|
||||
let line = `[${f.severity}] ${f.message}`;
|
||||
if (f.file) line += `\n File: ${f.file}`;
|
||||
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
|
||||
return line;
|
||||
});
|
||||
const text = `Fix these HyperFrames lint issues:\n\n${lines.join("\n\n")}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -98,8 +119,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
|
||||
<WarningIcon size={18} className="text-red-400" weight="fill" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircleIcon size={18} className="text-green-400" weight="fill" />
|
||||
<div className="w-8 h-8 rounded-full bg-[#3CE6AC]/10 flex items-center justify-center">
|
||||
<CheckCircleIcon size={18} className="text-[#3CE6AC]" weight="fill" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -119,7 +140,19 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
{/* Copy to agent + findings */}
|
||||
{hasIssues && (
|
||||
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
|
||||
<button
|
||||
onClick={handleCopyToAgent}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
copied ? "bg-green-600 text-white" : "bg-blue-600 hover:bg-blue-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to Agent"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3">
|
||||
{!hasIssues && (
|
||||
<div className="py-8 text-center text-neutral-500 text-sm">
|
||||
@@ -139,8 +172,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
|
||||
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
||||
{f.fixHint && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-blue-400">{f.fixHint}</p>
|
||||
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -156,8 +189,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
|
||||
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
||||
{f.fixHint && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-blue-400">{f.fixHint}</p>
|
||||
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -202,6 +235,67 @@ export function StudioApp() {
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [fileTree, setFileTree] = useState<string[]>([]);
|
||||
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
||||
|
||||
const renderClipContent = useCallback(
|
||||
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return null;
|
||||
|
||||
// Resolve composition source path using the compIdToSrc map
|
||||
let compSrc = el.compositionSrc;
|
||||
if (compSrc && compIdToSrc.size > 0) {
|
||||
const resolved =
|
||||
compIdToSrc.get(el.id) ||
|
||||
compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, ""));
|
||||
if (resolved) compSrc = resolved;
|
||||
}
|
||||
|
||||
if (compSrc) {
|
||||
const previewUrl = `/api/projects/${pid}/preview/comp/${compSrc}`;
|
||||
return (
|
||||
<CompositionThumbnail
|
||||
previewUrl={previewUrl}
|
||||
label={el.id || el.tag}
|
||||
labelColor={style.label}
|
||||
seekTime={el.start}
|
||||
duration={el.duration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ((el.tag === "video" || el.tag === "img") && el.src) {
|
||||
const mediaSrc = el.src.startsWith("http")
|
||||
? el.src
|
||||
: `/api/projects/${pid}/preview/${el.src}`;
|
||||
return (
|
||||
<VideoThumbnail
|
||||
videoSrc={mediaSrc}
|
||||
label={el.id || el.tag}
|
||||
labelColor={style.label}
|
||||
duration={el.duration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// HTML scene divs — render from index.html at the scene's time
|
||||
if (el.tag === "div" && el.duration > 0) {
|
||||
const previewUrl = `/api/projects/${pid}/preview`;
|
||||
return (
|
||||
<CompositionThumbnail
|
||||
previewUrl={previewUrl}
|
||||
label={el.id || el.tag}
|
||||
labelColor={style.label}
|
||||
seekTime={el.start}
|
||||
duration={el.duration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[compIdToSrc],
|
||||
);
|
||||
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
||||
const [linting, setLinting] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@@ -212,6 +306,7 @@ export function StudioApp() {
|
||||
const [_renderError, setRenderError] = useState<string | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const projectIdRef = useRef(projectId);
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
// Listen for external file changes (user editing HTML outside the editor).
|
||||
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
||||
@@ -462,9 +557,11 @@ export function StudioApp() {
|
||||
<NLELayout
|
||||
projectId={projectId}
|
||||
refreshKey={refreshKey}
|
||||
activeCompositionPath={
|
||||
editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
|
||||
}
|
||||
renderClipContent={renderClipContent}
|
||||
onCompIdToSrcChange={setCompIdToSrc}
|
||||
onIframeRef={(iframe) => {
|
||||
previewIframeRef.current = iframe;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -488,7 +585,7 @@ export function StudioApp() {
|
||||
<button
|
||||
onClick={handleRender}
|
||||
disabled={renderState === "rendering"}
|
||||
className="h-8 px-3 rounded-lg bg-blue-600 border border-blue-500 text-xs font-semibold text-white hover:bg-blue-500 transition-colors disabled:opacity-60 tabular-nums"
|
||||
className="h-8 px-3 rounded-lg text-xs font-semibold text-[#09090B] bg-gradient-to-br from-[#3CE6AC] to-[#2BBFA0] hover:brightness-110 active:scale-[0.97] transition-colors disabled:opacity-60 tabular-nums"
|
||||
>
|
||||
{renderState === "rendering"
|
||||
? `${Math.round(renderProgress)}%`
|
||||
@@ -517,7 +614,7 @@ export function StudioApp() {
|
||||
<button
|
||||
onClick={handleRender}
|
||||
disabled={renderState === "rendering"}
|
||||
className="px-2 py-1 rounded text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition-colors disabled:opacity-60 tabular-nums"
|
||||
className="px-2 py-1 rounded text-[11px] font-semibold text-[#3CE6AC] hover:text-[#5EEFC0] transition-colors disabled:opacity-60 tabular-nums"
|
||||
>
|
||||
{renderState === "rendering" ? `${Math.round(renderProgress)}%` : "Export MP4"}
|
||||
</button>
|
||||
|
||||
@@ -26,7 +26,9 @@ function getLanguageExtension(language: string) {
|
||||
case "js":
|
||||
case "typescript":
|
||||
case "ts":
|
||||
return javascript({ typescript: language === "typescript" || language === "ts" });
|
||||
return javascript({
|
||||
typescript: language === "typescript" || language === "ts",
|
||||
});
|
||||
default:
|
||||
return html();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
|
||||
import { useState, useCallback, useRef, memo, type ReactNode } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
@@ -9,7 +10,9 @@ interface NLELayoutProps {
|
||||
portrait?: boolean;
|
||||
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
||||
previewOverlay?: ReactNode;
|
||||
/** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */
|
||||
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
|
||||
timelineToolbar?: ReactNode;
|
||||
/** Slot rendered below the timeline tracks */
|
||||
timelineFooter?: ReactNode;
|
||||
/** Increment to force the preview to reload (e.g., after file writes) */
|
||||
refreshKey?: number;
|
||||
@@ -17,6 +20,15 @@ interface NLELayoutProps {
|
||||
activeCompositionPath?: string | null;
|
||||
/** Callback to expose the iframe ref (for element picker, etc.) */
|
||||
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
|
||||
/** Callback when the viewed composition changes (drill-down/back) */
|
||||
onCompositionChange?: (compositionPath: string | null) => void;
|
||||
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
|
||||
renderClipContent?: (
|
||||
element: TimelineElement,
|
||||
style: { clip: string; label: string },
|
||||
) => ReactNode;
|
||||
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
||||
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
||||
}
|
||||
|
||||
const MIN_TIMELINE_H = 100;
|
||||
@@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({
|
||||
projectId,
|
||||
portrait,
|
||||
previewOverlay,
|
||||
timelineToolbar,
|
||||
timelineFooter,
|
||||
refreshKey,
|
||||
activeCompositionPath,
|
||||
onIframeRef,
|
||||
onCompositionChange,
|
||||
renderClipContent,
|
||||
onCompIdToSrcChange,
|
||||
}: NLELayoutProps) {
|
||||
const {
|
||||
iframeRef,
|
||||
@@ -38,8 +54,17 @@ export const NLELayout = memo(function NLELayout({
|
||||
seek,
|
||||
onIframeLoad: baseOnIframeLoad,
|
||||
saveSeekPosition,
|
||||
resetPlayer,
|
||||
} = useTimelinePlayer();
|
||||
|
||||
// Reset timeline state when the project changes to prevent stale data from a
|
||||
// previous project leaking into the new one.
|
||||
const prevProjectIdRef = useRef<string | null>(null);
|
||||
if (prevProjectIdRef.current !== projectId) {
|
||||
prevProjectIdRef.current = projectId;
|
||||
resetPlayer();
|
||||
}
|
||||
|
||||
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
if (refreshKey !== prevRefreshKeyRef.current) {
|
||||
@@ -55,7 +80,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
// Composition ID → actual file path mapping, built from the raw index.html
|
||||
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/files/index.html`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content?: string }) => {
|
||||
@@ -70,15 +95,28 @@ export const NLELayout = memo(function NLELayout({
|
||||
if (id && src) map.set(id, src);
|
||||
}
|
||||
setCompIdToSrc(map);
|
||||
onCompIdToSrcChange?.(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
});
|
||||
|
||||
// Composition drill-down stack
|
||||
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
]);
|
||||
|
||||
// Wrap setCompositionStack to auto-notify parent on composition change
|
||||
const onCompositionChangeRef = useRef(onCompositionChange);
|
||||
onCompositionChangeRef.current = onCompositionChange;
|
||||
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
||||
setCompositionStack((prev) => {
|
||||
const next = typeof action === "function" ? action(prev) : action;
|
||||
const id = next[next.length - 1]?.id;
|
||||
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Resizable timeline height
|
||||
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
||||
const isDragging = useRef(false);
|
||||
@@ -126,7 +164,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
usePlayerStore.getState().setElements([]);
|
||||
|
||||
// Toggle: if already viewing this composition, go back to parent (like Premiere)
|
||||
setCompositionStack((prev) => {
|
||||
updateCompositionStack((prev) => {
|
||||
const currentId = prev[prev.length - 1].id;
|
||||
if (currentId === resolvedPath && prev.length > 1) {
|
||||
return prev.slice(0, -1);
|
||||
@@ -141,14 +179,15 @@ export const NLELayout = memo(function NLELayout({
|
||||
return [...prev, { id: resolvedPath, label, previewUrl }];
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef_ is a stable ref; .current mutates and should not be a dep
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[projectId, compIdToSrc],
|
||||
);
|
||||
|
||||
// Navigate back to a specific breadcrumb level
|
||||
const handleNavigateComposition = useCallback((index: number) => {
|
||||
usePlayerStore.getState().setElements([]);
|
||||
setCompositionStack((prev) => prev.slice(0, index + 1));
|
||||
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Navigate to a composition when activeCompositionPath changes
|
||||
@@ -157,11 +196,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
prevActiveCompRef.current = activeCompositionPath;
|
||||
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
||||
if (activeCompositionPath === "index.html") {
|
||||
setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
||||
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
||||
} else if (activeCompositionPath.startsWith("compositions/")) {
|
||||
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
||||
setCompositionStack((prev) => {
|
||||
updateCompositionStack((prev) => {
|
||||
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
|
||||
return [
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
@@ -201,9 +240,10 @@ export const NLELayout = memo(function NLELayout({
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && compositionStack.length > 1) {
|
||||
setCompositionStack((prev) => prev.slice(0, -1));
|
||||
updateCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[compositionStack.length],
|
||||
);
|
||||
|
||||
@@ -255,11 +295,16 @@ export const NLELayout = memo(function NLELayout({
|
||||
onDoubleClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
if (compositionStack.length > 1) {
|
||||
setCompositionStack((prev) => prev.slice(0, -1));
|
||||
updateCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Timeline onSeek={seek} onDrillDown={handleDrillDown} />
|
||||
{timelineToolbar}
|
||||
<Timeline
|
||||
onSeek={seek}
|
||||
onDrillDown={handleDrillDown}
|
||||
renderClipContent={renderClipContent}
|
||||
/>
|
||||
{timelineFooter}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface UseCodeEditorReturn {
|
||||
setActiveFile: (path: string) => void;
|
||||
updateContent: (content: string) => void;
|
||||
markSaved: (path: string) => void;
|
||||
/** External update from agent — updates saved content, shows reload indicator */
|
||||
/** External update — updates saved content, shows reload indicator */
|
||||
externalUpdate: (path: string, content: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,11 @@ export function useElementPicker(
|
||||
(
|
||||
elementId: string,
|
||||
selector: string,
|
||||
op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string },
|
||||
op: {
|
||||
type: "inline-style" | "attribute" | "text-content";
|
||||
property: string;
|
||||
value: string;
|
||||
},
|
||||
) => {
|
||||
const opts = optionsRef.current;
|
||||
if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
|
||||
|
||||
@@ -10,12 +10,14 @@ export {
|
||||
PlayerControls,
|
||||
Timeline,
|
||||
PreviewPanel,
|
||||
VideoThumbnail,
|
||||
CompositionThumbnail,
|
||||
useTimelinePlayer,
|
||||
usePlayerStore,
|
||||
liveTime,
|
||||
formatTime,
|
||||
} from "./player";
|
||||
export type { TimelineElement, ZoomMode } from "./player";
|
||||
export type { TimelineElement } from "./player";
|
||||
|
||||
// Editor
|
||||
export { SourceEditor } from "./components/editor/SourceEditor";
|
||||
@@ -27,4 +29,11 @@ export { StudioApp } from "./App";
|
||||
|
||||
// Hooks
|
||||
export { useCodeEditor } from "./hooks/useCodeEditor";
|
||||
export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
|
||||
export { useElementPicker } from "./hooks/useElementPicker";
|
||||
export type { PickedElement } from "./hooks/useElementPicker";
|
||||
|
||||
// Utilities
|
||||
export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
|
||||
export type { PatchOperation } from "./utils/sourcePatcher";
|
||||
export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { memo } from "react";
|
||||
|
||||
const TRACK_H = 20;
|
||||
const GUTTER = 32;
|
||||
|
||||
export interface AgentActivity {
|
||||
agentId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
/** Active work periods mapped to VIDEO time (not wall clock) */
|
||||
periods: Array<{ start: number; end: number }>;
|
||||
/** Element creation events at specific video times */
|
||||
events: Array<{ time: number; type: "create" | "modify" }>;
|
||||
}
|
||||
|
||||
interface AgentActivityTrackProps {
|
||||
agents: AgentActivity[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export const AgentActivityTrack = memo(function AgentActivityTrack({
|
||||
agents,
|
||||
duration,
|
||||
}: AgentActivityTrackProps) {
|
||||
if (agents.length === 0 || duration <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-neutral-800/30">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[9px] text-neutral-600 font-medium uppercase tracking-wider">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
Agent Activity
|
||||
</div>
|
||||
|
||||
{agents.map((agent) => (
|
||||
<div key={agent.agentId} className="relative flex" style={{ height: TRACK_H }}>
|
||||
{/* Gutter: agent name */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center"
|
||||
style={{ width: GUTTER }}
|
||||
title={agent.name}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: agent.color }} />
|
||||
</div>
|
||||
|
||||
{/* Lane */}
|
||||
<div className="flex-1 relative" style={{ backgroundColor: `${agent.color}06` }}>
|
||||
{/* Active work periods */}
|
||||
{agent.periods.map((period, i) => {
|
||||
const leftPct = (period.start / duration) * 100;
|
||||
const widthPct = ((period.end - period.start) / duration) * 100;
|
||||
return (
|
||||
<div
|
||||
key={`period-${i}`}
|
||||
className="absolute top-1 bottom-1 rounded-sm"
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
width: `${Math.max(widthPct, 0.5)}%`,
|
||||
backgroundColor: `${agent.color}30`,
|
||||
border: `1px solid ${agent.color}20`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Events: diamonds for create, circles for modify */}
|
||||
{agent.events.map((event, i) => {
|
||||
const leftPct = (event.time / duration) * 100;
|
||||
return (
|
||||
<div
|
||||
key={`event-${i}`}
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
{event.type === "create" ? (
|
||||
<div className="w-2 h-2 rotate-45" style={{ backgroundColor: agent.color }} />
|
||||
) : (
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -54,7 +54,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
|
||||
: undefined,
|
||||
border: isSelected
|
||||
? "2px solid rgba(255,255,255,0.9)"
|
||||
? `2px solid rgba(255,255,255,0.9)`
|
||||
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
|
||||
@@ -63,7 +63,6 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
: "none",
|
||||
transition: "border-color 120ms, box-shadow 120ms",
|
||||
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={
|
||||
isComposition
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a CSS inline style string into a key-value map.
|
||||
* e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" →
|
||||
* { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" }
|
||||
*/
|
||||
export function parseStyleString(style: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const decl of style.split(";")) {
|
||||
const colonIdx = decl.indexOf(":");
|
||||
if (colonIdx < 0) continue;
|
||||
const key = decl.slice(0, colonIdx).trim();
|
||||
const value = decl.slice(colonIdx + 1).trim();
|
||||
if (key && value) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `newStyles` into an opening tag string's `style` attribute.
|
||||
* - New values win over existing ones.
|
||||
* - If no `style` attribute is present, one is added before the closing `>`.
|
||||
*/
|
||||
export function mergeStyleIntoTag(tag: string, newStyles: string): string {
|
||||
if (!newStyles.trim()) return tag;
|
||||
|
||||
const incoming = parseStyleString(newStyles);
|
||||
|
||||
// Match style="..." or style='...' — handle multi-line attrs via dotall-like trick
|
||||
const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
|
||||
const match = tag.match(styleAttrRe);
|
||||
|
||||
if (match) {
|
||||
const quote = match[1];
|
||||
const existing = parseStyleString(match[2]);
|
||||
const merged = { ...existing, ...incoming };
|
||||
const serialized = Object.entries(merged)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("; ");
|
||||
return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`);
|
||||
}
|
||||
|
||||
// No style attribute — insert one before the closing `>`
|
||||
const serialized = Object.entries(incoming)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("; ");
|
||||
// Handle self-closing tags (`/>`) and regular closing (`>`)
|
||||
return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the full element block (opening tag through closing tag) in the source.
|
||||
* Uses quote-aware scanning to handle attributes containing >.
|
||||
* Uses depth counting to handle nested same-name tags.
|
||||
*/
|
||||
export function findElementBlock(
|
||||
html: string,
|
||||
elementId: string,
|
||||
): {
|
||||
start: number;
|
||||
end: number;
|
||||
openTag: string;
|
||||
tagName: string;
|
||||
indent: string;
|
||||
innerContent: string;
|
||||
isSelfClosing: boolean;
|
||||
} | null {
|
||||
let idIdx = html.indexOf(`id="${elementId}"`);
|
||||
if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`);
|
||||
if (idIdx < 0) return null;
|
||||
|
||||
// Walk backward to find < and capture indent
|
||||
let tagStart = idIdx;
|
||||
while (tagStart > 0 && html[tagStart] !== "<") tagStart--;
|
||||
|
||||
let indentStart = tagStart;
|
||||
while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--;
|
||||
const indent = html.slice(indentStart, tagStart);
|
||||
|
||||
// Walk forward from id to find the closing > of the opening tag
|
||||
let tagEnd = idIdx;
|
||||
let inQuote: string | null = null;
|
||||
while (tagEnd < html.length) {
|
||||
const ch = html[tagEnd];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) inQuote = null;
|
||||
} else {
|
||||
if (ch === '"' || ch === "'") inQuote = ch;
|
||||
if (ch === ">") {
|
||||
tagEnd++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
tagEnd++;
|
||||
}
|
||||
|
||||
const openTag = html.slice(tagStart, tagEnd);
|
||||
const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
|
||||
if (!tagNameMatch) return null;
|
||||
|
||||
const tagName = tagNameMatch[1];
|
||||
const isSelfClosing =
|
||||
openTag.trimEnd().endsWith("/>") ||
|
||||
["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase());
|
||||
|
||||
if (isSelfClosing) {
|
||||
return {
|
||||
start: tagStart,
|
||||
end: tagStart + openTag.length,
|
||||
openTag,
|
||||
tagName,
|
||||
indent: /^[\t ]*$/.test(indent) ? indent : "",
|
||||
innerContent: "",
|
||||
isSelfClosing: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Find matching closing tag using depth counting
|
||||
const closeTag = `</${tagName.toLowerCase()}>`;
|
||||
const openPattern = `<${tagName.toLowerCase()}`;
|
||||
let depth = 0;
|
||||
let pos = tagStart;
|
||||
const lower = html.toLowerCase();
|
||||
|
||||
while (pos < html.length) {
|
||||
if (lower.startsWith("<!--", pos)) {
|
||||
const commentEnd = lower.indexOf("-->", pos + 4);
|
||||
pos = commentEnd < 0 ? html.length : commentEnd + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) {
|
||||
depth++;
|
||||
pos += openPattern.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower.startsWith(closeTag, pos)) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const end = pos + closeTag.length;
|
||||
const innerContent = html.slice(tagStart + openTag.length, pos);
|
||||
return {
|
||||
start: tagStart,
|
||||
end,
|
||||
openTag,
|
||||
tagName,
|
||||
indent: /^[\t ]*$/.test(indent) ? indent : "",
|
||||
innerContent,
|
||||
isSelfClosing: false,
|
||||
};
|
||||
}
|
||||
pos += closeTag.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user