Skip to content
Merged
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
25 changes: 21 additions & 4 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 @@ -162,15 +163,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 +196,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
69 changes: 68 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,67 @@ 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)} onCommit={commitStart} />
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
</div>
<div className="mt-3">
<MetricField
label="Duration"
value={formatTimingValue(duration)}
onCommit={commitDuration}
/>
</div>
</Section>
);
}

/* ------------------------------------------------------------------ */
/* PropertyPanel */
/* ------------------------------------------------------------------ */
Expand All @@ -126,6 +188,7 @@ export const PropertyPanel = memo(function PropertyPanel({
copiedAgentPrompt,
onClearSelection,
onSetStyle,
onSetAttribute,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
Expand Down Expand Up @@ -322,6 +385,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
60 changes: 60 additions & 0 deletions packages/studio/src/components/editor/domEditingTextFields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { serializeDomEditTextFields } from "./domEditing";

describe("serializeDomEditTextFields — mixed content", () => {
it("round-trips text-node + child element fields", () => {
expect(
serializeDomEditTextFields([
{
key: "text-node:0",
label: "Text 1",
value: "If you're ",
tagName: "#text",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "text-node",
},
{
key: "child:1:span",
label: "Text 2",
value: "turning 65",
tagName: "span",
attributes: [{ name: "class", value: "accent" }],
inlineStyles: { color: "red" },
computedStyles: {},
source: "child",
},
{
key: "text-node:2",
label: "Text 3",
value: " soon...",
tagName: "#text",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "text-node",
},
]),
).toBe(
`If you're <span class="accent" data-hf-text-key="child:1:span" style="color: red">turning 65</span> soon...`,
);
});

it("escapes HTML entities in text-node values", () => {
expect(
serializeDomEditTextFields([
{
key: "text-node:0",
label: "Text 1",
value: "A < B & C > D",
tagName: "#text",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "text-node",
},
]),
).toBe("A &lt; B &amp; C &gt; D");
});
});
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
Loading
Loading