Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
import { useCaptionDetection } from "./hooks/useCaptionDetection";
import { useRenderClipContent } from "./hooks/useRenderClipContent";
Expand Down Expand Up @@ -109,11 +110,7 @@ export function StudioApp() {
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const reloadPreview = useCallback(() => {
try {
previewIframeRef.current?.contentWindow?.location.reload();
} catch {
setRefreshKey((k) => k + 1);
}
setRefreshKey((k) => k + 1);
}, []);

const fileManager = useFileManager({
Expand Down Expand Up @@ -162,15 +159,28 @@ export function StudioApp() {

const clearDomSelectionRef = useRef<() => void>(() => {});
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise<void>>(
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
async () => {},
);

const domEditDeleteBridge = async (s: DomEditSelection) =>
handleDomEditElementDeleteRef.current(s);
const { handleCopy, handlePaste, handleCut } = useClipboard({
projectId,
activeCompPath,
domEditSelectionRef: domEditSelectionBridgeRef,
showToast,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
domEditSaveTimestampRef,
reloadPreview,
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
handleDomEditElementDelete: domEditDeleteBridge,
previewIframeRef,
});
const appHotkeys = useAppHotkeys({
toggleTimelineVisibility,
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
handleDomEditElementDelete: async (s: DomEditSelection) =>
handleDomEditElementDeleteRef.current(s),
handleDomEditElementDelete: domEditDeleteBridge,
domEditSelectionRef: domEditSelectionBridgeRef,
clearDomSelectionRef,
editHistory,
Expand All @@ -182,6 +192,9 @@ export function StudioApp() {
syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
leftSidebarRef,
handleCopy,
handlePaste,
handleCut,
});

const domEditSession = useDomEditSession({
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function StudioRightPanel({
copiedAgentPrompt,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
Expand Down Expand Up @@ -168,6 +169,7 @@ export function StudioRightPanel({
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetManualRotation={handleDomRotationCommit}
Expand Down
70 changes: 69 additions & 1 deletion packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo } from "react";
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import {
collectDomEditLayerItems,
getDomEditLayerKey,
Expand Down Expand Up @@ -39,6 +39,7 @@ interface PropertyPanelProps {
copiedAgentPrompt: boolean;
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
Expand Down Expand Up @@ -114,6 +115,68 @@ function LayerTree({
);
}

/* ------------------------------------------------------------------ */
/* TimingSection */
/* ------------------------------------------------------------------ */

function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}

function parseTimingValue(input: string): number | null {
const cleaned = input.replace(/s$/i, "").trim();
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}

function TimingSection({
element,
onSetAttribute,
}: {
element: DomEditSelection;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
}) {
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0;
const end = start + duration;

const commitStart = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null) return;
void onSetAttribute("start", parsed.toFixed(2));
};

const commitDuration = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= 0) return;
void onSetAttribute("duration", parsed.toFixed(2));
};

const commitEnd = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= start) return;
void onSetAttribute("duration", (parsed - start).toFixed(2));
};

return (
<Section title="Timing" icon={<Clock size={15} />}>
<div className={RESPONSIVE_GRID}>
<MetricField label="Start" value={formatTimingValue(start)} scrub onCommit={commitStart} />
<MetricField label="End" value={formatTimingValue(end)} scrub onCommit={commitEnd} />
</div>
<div className="mt-3">
<MetricField
label="Duration"
value={formatTimingValue(duration)}
scrub
onCommit={commitDuration}
/>
</div>
</Section>
);
}

/* ------------------------------------------------------------------ */
/* PropertyPanel */
/* ------------------------------------------------------------------ */
Expand All @@ -126,6 +189,7 @@ export const PropertyPanel = memo(function PropertyPanel({
copiedAgentPrompt,
onClearSelection,
onSetStyle,
onSetAttribute,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
Expand Down Expand Up @@ -322,6 +386,10 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
</Section>

{element.dataAttributes.start != null && (
<TimingSection element={element} onSetAttribute={onSetAttribute} />
)}

{showEditableSections && (
<StyleSections
projectId={projectId}
Expand Down
44 changes: 39 additions & 5 deletions packages/studio/src/components/editor/domEditingLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,41 @@ function buildTextField(
}

export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
if (childFields.length > 0) {
return childFields.map((child, index) =>
buildTextField(child, index, childFields.length, "child"),
const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);

if (childElements.length > 0) {
const hasMixedContent = Array.from(el.childNodes).some(
(node) => node.nodeType === 3 && node.textContent?.trim(),
);

if (hasMixedContent) {
const fields: DomEditTextField[] = [];
let childIdx = 0;
for (const node of el.childNodes) {
if (node.nodeType === 3) {
const text = node.textContent ?? "";
if (!text.trim()) continue;
fields.push({
key: `text-node:${childIdx}`,
label: `Text ${childIdx + 1}`,
value: text,
tagName: "#text",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "text-node",
});
childIdx++;
} else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
fields.push(buildTextField(node, childIdx, childElements.length, "child"));
childIdx++;
}
}
return fields;
}

return childElements.map((child, index) =>
buildTextField(child, index, childElements.length, "child"),
);
}

Expand All @@ -99,8 +130,11 @@ function serializeTextFieldStyle(field: DomEditTextField): string {

export function serializeDomEditTextFields(fields: DomEditTextField[]): string {
return fields
.filter((field) => field.source === "child")
.filter((field) => field.source === "child" || field.source === "text-node")
.map((field) => {
if (field.source === "text-node") {
return escapeHtmlText(field.value);
}
const attrs = [
...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"),
{ name: "data-hf-text-key", value: field.key },
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/domEditingTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface DomEditTextField {
attributes: Array<{ name: string; value: string }>;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
source: "self" | "child";
source: "self" | "child" | "text-node";
}

export interface DomEditSelection extends PatchTarget {
Expand Down
11 changes: 7 additions & 4 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const NLELayout = memo(function NLELayout({
togglePlay,
seek,
onIframeLoad: baseOnIframeLoad,
saveSeekPosition,
refreshPlayer,
} = useTimelinePlayer();

// Reset timeline state when the project changes
Expand All @@ -109,13 +109,16 @@ export const NLELayout = memo(function NLELayout({
usePlayerStore.getState().reset();
}

// Save seek position before refresh
// Lightweight reload: change iframe src instead of destroying the Player.
// refreshPlayer() saves the seek position and appends a cache-busting _t
// param, avoiding the full web-component teardown + crossfade that the
// key-based path uses.
const prevRefreshKeyRef = useRef(refreshKey);
useEffect(() => {
if (refreshKey === prevRefreshKeyRef.current) return;
prevRefreshKeyRef.current = refreshKey;
saveSeekPosition();
}, [refreshKey, saveSeekPosition]);
refreshPlayer();
}, [refreshKey, refreshPlayer]);

const onIframeLoad = useCallback(() => {
baseOnIframeLoad();
Expand Down
53 changes: 9 additions & 44 deletions packages/studio/src/components/nle/NLEPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
import { memo, useCallback, useEffect, useRef, type Ref } from "react";
import { Player } from "../../player";
import {
DEFAULT_PREVIEW_ZOOM,
Expand Down Expand Up @@ -53,15 +53,14 @@ export const NLEPreview = memo(function NLEPreview({
onCompositionLoadingChange,
portrait,
directUrl,
refreshKey,
suppressLoadingOverlay,
}: NLEPreviewProps) {
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
const prevRefreshKeyRef = useRef(refreshKey);
// Player key only changes for structural changes (project switch, composition
// drill-down), NOT for content refreshes. Content refreshes use the lighter
// iframe.src reload path handled by NLELayout → refreshPlayer().
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
const viewportRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [retiringKey, setRetiringKey] = useState<string | null>(null);
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
const hudRef = useRef<HTMLDivElement>(null);
Expand All @@ -80,7 +79,6 @@ export const NLEPreview = memo(function NLEPreview({
return () => {
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
};
}, []);

Expand Down Expand Up @@ -130,31 +128,13 @@ export const NLEPreview = memo(function NLEPreview({
[writeTransform],
);

if (refreshKey !== prevRefreshKeyRef.current) {
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
prevRefreshKeyRef.current = refreshKey;
setRetiringKey(oldKey);
}

const activeKey = `${baseKey}:${refreshKey ?? 0}`;

const applyInitialZoom = useCallback(() => {
const z = zoomRef.current;
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
writeTransform(z);
}
}, [writeTransform]);

const handleNewPlayerLoad = () => {
onIframeLoad();
applyInitialZoom();
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
retiringTimerRef.current = setTimeout(() => {
setRetiringKey(null);
retiringTimerRef.current = null;
}, 160);
};

useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
Expand Down Expand Up @@ -282,32 +262,17 @@ export const NLEPreview = memo(function NLEPreview({
}}
data-testid="preview-zoom-stage"
>
{retiringKey && (
<Player
key={retiringKey}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {}}
portrait={portrait}
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
/>
)}
<Player
key={activeKey}
ref={iframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={
retiringKey
? handleNewPlayerLoad
: () => {
onIframeLoad();
applyInitialZoom();
}
}
onLoad={() => {
onIframeLoad();
applyInitialZoom();
}}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
suppressLoadingOverlay={suppressLoadingOverlay}
/>
</div>
Expand Down
Loading
Loading