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
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
3 changes: 3 additions & 0 deletions packages/studio/src/contexts/DomEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function DomEditProvider({
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
Expand Down Expand Up @@ -74,6 +75,7 @@ export function DomEditProvider({
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
Expand Down Expand Up @@ -114,6 +116,7 @@ export function DomEditProvider({
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export function useDomEditCommits({

const {
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
Expand Down Expand Up @@ -437,6 +438,7 @@ export function useDomEditCommits({
return {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export function useDomEditSession({
const {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
Expand Down Expand Up @@ -305,6 +306,7 @@ export function useDomEditSession({
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
Expand Down
33 changes: 33 additions & 0 deletions packages/studio/src/hooks/useDomEditTextCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,38 @@ export function useDomEditTextCommits({
],
);

const handleDomAttributeCommit = useCallback(
async (attr: string, value: string) => {
if (!domEditSelection) return;
const iframe = previewIframeRef.current;
const doc = iframe?.contentDocument;
if (doc) {
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
if (el) el.setAttribute(`data-${attr}`, value);
}
const op: PatchOperation = { type: "attribute", property: attr, value };
try {
await persistDomEditOperations(domEditSelection, [op], {
label: "Edit timing",
skipRefresh: false,
});
} catch (err) {
console.warn(
"[Studio] Attribute persist failed:",
err instanceof Error ? err.message : err,
);
}
refreshDomEditSelectionFromPreview(domEditSelection);
},
[
activeCompPath,
domEditSelection,
persistDomEditOperations,
refreshDomEditSelectionFromPreview,
previewIframeRef,
],
);

const handleDomTextCommit = useCallback(
async (value: string, fieldKey?: string) => {
if (!domEditSelection) return;
Expand Down Expand Up @@ -321,6 +353,7 @@ export function useDomEditTextCommits({

return {
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
Expand Down
Loading