mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #941 from heygen-com/worktree-feat+media-design-panel
feat(studio): media properties panel for video/audio elements
This commit is contained in:
@@ -3,3 +3,4 @@ packages/studio/src/hooks/useManifestPersistence.ts
|
||||
packages/studio/src/player/components/PlayerControls.tsx
|
||||
packages/studio/src/components/editor/manualEdits.test.ts
|
||||
packages/studio/src/components/editor/manualEditsDom.ts
|
||||
packages/studio/src/utils/sourcePatcher.ts
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
|
||||
/** Motion data without targeting metadata. */
|
||||
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
|
||||
import { useCallback } from "react";
|
||||
import { resolveDomEditSelection, type DomEditLayerItem } from "./editor/domEditing";
|
||||
|
||||
import { useStudioContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
||||
@@ -56,6 +55,7 @@ export function StudioRightPanel({
|
||||
clearDomSelection,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
handleDomRotationCommit,
|
||||
@@ -66,23 +66,10 @@ export function StudioRightPanel({
|
||||
handleAskAgent,
|
||||
handleDomMotionCommit,
|
||||
handleDomMotionClear,
|
||||
applyDomSelection,
|
||||
} = useDomEditContext();
|
||||
|
||||
const { assets, fontAssets, handleImportFiles, handleImportFonts } = useFileManagerContext();
|
||||
|
||||
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
||||
const handleSelectLayer = useCallback(
|
||||
(layer: DomEditLayerItem) => {
|
||||
const selection = resolveDomEditSelection(layer.element, {
|
||||
activeCompositionPath: activeCompPath,
|
||||
isMasterView,
|
||||
preferClipAncestor: false,
|
||||
});
|
||||
if (selection) applyDomSelection(selection);
|
||||
},
|
||||
[activeCompPath, isMasterView, applyDomSelection],
|
||||
);
|
||||
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
|
||||
useFileManagerContext();
|
||||
|
||||
const renderJobs = renderQueue.jobs as RenderJob[];
|
||||
|
||||
@@ -163,6 +150,7 @@ export function StudioRightPanel({
|
||||
) : designPanelActive ? (
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
projectDir={projectDir}
|
||||
assets={assets}
|
||||
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
multiSelectCount={domEditGroupSelections.length}
|
||||
@@ -170,6 +158,7 @@ export function StudioRightPanel({
|
||||
onClearSelection={clearDomSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
||||
onSetManualOffset={handleDomPathOffsetCommit}
|
||||
onSetManualSize={handleDomBoxSizeCommit}
|
||||
onSetManualRotation={handleDomRotationCommit}
|
||||
@@ -181,8 +170,6 @@ export function StudioRightPanel({
|
||||
onImportAssets={handleImportFiles}
|
||||
fontAssets={fontAssets}
|
||||
onImportFonts={handleImportFonts}
|
||||
activeCompositionPath={activeCompPath}
|
||||
onSelectLayer={handleSelectLayer}
|
||||
/>
|
||||
) : motionPanelActive ? (
|
||||
<MotionPanel
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { memo } from "react";
|
||||
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
|
||||
import {
|
||||
collectDomEditLayerItems,
|
||||
getDomEditLayerKey,
|
||||
type DomEditSelection,
|
||||
type DomEditLayerItem,
|
||||
} from "./domEditing";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
import type { ImportedFontAsset } from "./fontAssets";
|
||||
import {
|
||||
@@ -16,6 +11,7 @@ import {
|
||||
RESPONSIVE_GRID,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
|
||||
import { TextSection, StyleSections } from "./propertyPanelSections";
|
||||
|
||||
// Re-export helpers that external consumers import from this module
|
||||
@@ -33,6 +29,7 @@ export {
|
||||
|
||||
interface PropertyPanelProps {
|
||||
projectId: string;
|
||||
projectDir: string | null;
|
||||
assets: string[];
|
||||
element: DomEditSelection | null;
|
||||
multiSelectCount?: number;
|
||||
@@ -40,6 +37,7 @@ interface PropertyPanelProps {
|
||||
onClearSelection: () => void;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => 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;
|
||||
@@ -51,68 +49,6 @@ interface PropertyPanelProps {
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
fontAssets?: ImportedFontAsset[];
|
||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||
activeCompositionPath?: string | null;
|
||||
onSelectLayer?: (layer: DomEditLayerItem) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* LayerTree */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function LayerTree({
|
||||
element,
|
||||
activeCompositionPath,
|
||||
onSelectLayer,
|
||||
}: {
|
||||
element: DomEditSelection | null;
|
||||
activeCompositionPath: string | null;
|
||||
onSelectLayer: (layer: DomEditLayerItem) => void;
|
||||
}) {
|
||||
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
|
||||
const layers = collectDomEditLayerItems(element?.element, {
|
||||
activeCompositionPath,
|
||||
isMasterView,
|
||||
});
|
||||
if (layers.length <= 1) return null;
|
||||
|
||||
const selectedKey = element ? getDomEditLayerKey(element) : null;
|
||||
|
||||
return (
|
||||
<Section title="Layers" icon={<Layers size={15} />}>
|
||||
<div className="space-y-0.5">
|
||||
{layers.map((layer) => {
|
||||
const selected = layer.key === selectedKey;
|
||||
return (
|
||||
<button
|
||||
key={layer.key}
|
||||
type="button"
|
||||
onClick={() => onSelectLayer(layer)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "bg-studio-accent/14 text-studio-accent"
|
||||
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + layer.depth * 12 }}
|
||||
>
|
||||
<span
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold uppercase ${
|
||||
selected
|
||||
? "bg-studio-accent/18 text-studio-accent"
|
||||
: "bg-neutral-800 text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{layer.tagName.slice(0, 2)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{layer.label}</span>
|
||||
{layer.childCount > 0 && (
|
||||
<span className="text-[9px] tabular-nums text-neutral-500">{layer.childCount}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -182,6 +118,7 @@ function TimingSection({
|
||||
|
||||
export const PropertyPanel = memo(function PropertyPanel({
|
||||
projectId,
|
||||
projectDir,
|
||||
assets,
|
||||
element,
|
||||
multiSelectCount = 0,
|
||||
@@ -189,6 +126,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onClearSelection,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetHtmlAttribute,
|
||||
onSetManualOffset,
|
||||
onSetManualSize,
|
||||
onSetManualRotation,
|
||||
@@ -200,8 +138,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
onImportFonts,
|
||||
activeCompositionPath = null,
|
||||
onSelectLayer,
|
||||
}: PropertyPanelProps) {
|
||||
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
||||
|
||||
@@ -331,11 +267,18 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onRemoveTextField={onRemoveTextField}
|
||||
/>
|
||||
|
||||
{onSelectLayer && (
|
||||
<LayerTree
|
||||
{element.dataAttributes.start != null && (
|
||||
<TimingSection element={element} onSetAttribute={onSetAttribute} />
|
||||
)}
|
||||
|
||||
{isMediaElement(element) && (
|
||||
<MediaSection
|
||||
projectDir={projectDir}
|
||||
element={element}
|
||||
activeCompositionPath={activeCompositionPath}
|
||||
onSelectLayer={onSelectLayer}
|
||||
styles={styles}
|
||||
onSetStyle={onSetStyle}
|
||||
onSetAttribute={onSetAttribute}
|
||||
onSetHtmlAttribute={onSetHtmlAttribute}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -385,10 +328,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{element.dataAttributes.start != null && (
|
||||
<TimingSection element={element} onSetAttribute={onSetAttribute} />
|
||||
)}
|
||||
|
||||
{showEditableSections && (
|
||||
<StyleSections
|
||||
projectId={projectId}
|
||||
|
||||
@@ -42,6 +42,8 @@ export const CURATED_STYLE_PROPERTIES = [
|
||||
"backdrop-filter",
|
||||
"z-index",
|
||||
"transform",
|
||||
"object-fit",
|
||||
"object-position",
|
||||
] as const;
|
||||
|
||||
export interface DomEditCapabilities {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import { Check, ClipboardList, Film, Music } from "../../icons/SystemIcons";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
formatNumericValue,
|
||||
LABEL,
|
||||
parseNumericValue,
|
||||
RESPONSIVE_GRID,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
|
||||
|
||||
const MEDIA_TAGS = new Set(["video", "audio"]);
|
||||
|
||||
export function isMediaElement(element: DomEditSelection): boolean {
|
||||
return MEDIA_TAGS.has(element.tagName);
|
||||
}
|
||||
|
||||
function formatTimingValue(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
|
||||
return `${seconds.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
export function MediaSection({
|
||||
projectDir,
|
||||
element,
|
||||
styles,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetHtmlAttribute,
|
||||
}: {
|
||||
projectDir: string | null;
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
||||
}) {
|
||||
const isVideo = element.tagName === "video";
|
||||
const el = element.element;
|
||||
|
||||
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
|
||||
const volumePercent = Math.round(volume * 100);
|
||||
|
||||
const mediaStart =
|
||||
Number.parseFloat(
|
||||
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
|
||||
) || 0;
|
||||
|
||||
const hasLoop = el.hasAttribute("loop");
|
||||
const hasMuted = el.hasAttribute("muted");
|
||||
const hasAudio = element.dataAttributes["has-audio"] === "true";
|
||||
|
||||
const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
|
||||
|
||||
const objectFit = styles["object-fit"] || "contain";
|
||||
const objectPosition = styles["object-position"] || "center";
|
||||
|
||||
const sourceDuration =
|
||||
Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
|
||||
(el as HTMLMediaElement).duration ||
|
||||
0;
|
||||
const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10));
|
||||
|
||||
const srcAttr = el.getAttribute("src") ?? "";
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const absoluteSrc =
|
||||
projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={isVideo ? "Video" : "Audio"}
|
||||
icon={isVideo ? <Film size={15} /> : <Music size={15} />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{srcAttr && (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">Source</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(absoluteSrc).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}}
|
||||
className="flex h-6 items-center gap-1 rounded-lg border border-neutral-700 bg-neutral-950 px-2 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200"
|
||||
>
|
||||
{copied ? <Check size={11} /> : <ClipboardList size={11} />}
|
||||
<span>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 truncate text-[11px] font-medium text-neutral-300"
|
||||
title={absoluteSrc}
|
||||
>
|
||||
{absoluteSrc}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Volume</span>
|
||||
<SliderControl
|
||||
value={volumePercent}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
displayValue={`${volumePercent}%`}
|
||||
formatDisplayValue={(next) => `${Math.round(next)}%`}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("volume", formatNumericValue(next / 100));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Playback rate</span>
|
||||
<SliderControl
|
||||
value={playbackRate * 100}
|
||||
min={25}
|
||||
max={300}
|
||||
step={5}
|
||||
displayValue={`${formatNumericValue(playbackRate)}x`}
|
||||
formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("playback-rate", formatNumericValue(next / 100));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Media start</span>
|
||||
<SliderControl
|
||||
value={Math.round(mediaStart * 100)}
|
||||
min={0}
|
||||
max={mediaStartMax * 100}
|
||||
step={10}
|
||||
displayValue={formatTimingValue(mediaStart)}
|
||||
formatDisplayValue={(next) => formatTimingValue(next / 100)}
|
||||
onCommit={(next) => {
|
||||
void onSetAttribute("media-start", (next / 100).toFixed(2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Loop</span>
|
||||
<SegmentedControl
|
||||
value={hasLoop ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("loop", next === "on" ? "true" : null);
|
||||
}}
|
||||
options={[
|
||||
{ label: "On", value: "on" },
|
||||
{ label: "Off", value: "off" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Muted</span>
|
||||
<SegmentedControl
|
||||
value={hasMuted ? "on" : "off"}
|
||||
onChange={(next) => {
|
||||
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
|
||||
}}
|
||||
options={[
|
||||
{ label: "On", value: "on" },
|
||||
{ label: "Off", value: "off" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isVideo && (
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Has audio track</span>
|
||||
<SegmentedControl
|
||||
value={hasAudio ? "yes" : "no"}
|
||||
onChange={(next) => {
|
||||
if (next === "yes") {
|
||||
void onSetAttribute("has-audio", "true");
|
||||
void onSetHtmlAttribute("muted", null);
|
||||
} else {
|
||||
void onSetAttribute("has-audio", "");
|
||||
void onSetHtmlAttribute("muted", "true");
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideo && (
|
||||
<>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<SelectField
|
||||
label="Fit"
|
||||
value={objectFit}
|
||||
onChange={(next) => {
|
||||
void onSetStyle("object-fit", next);
|
||||
}}
|
||||
options={["contain", "cover", "fill", "none", "scale-down"]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Position"
|
||||
value={objectPosition}
|
||||
onChange={(next) => {
|
||||
void onSetStyle("object-position", next);
|
||||
}}
|
||||
options={[
|
||||
"center",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"left top",
|
||||
"right top",
|
||||
"left bottom",
|
||||
"right bottom",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export function DomEditProvider({
|
||||
clearDomSelection,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
@@ -76,6 +77,7 @@ export function DomEditProvider({
|
||||
clearDomSelection,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
@@ -117,6 +119,7 @@ export function DomEditProvider({
|
||||
clearDomSelection,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
|
||||
@@ -57,7 +57,6 @@ export function useAppHotkeys({
|
||||
handleTimelineElementDelete,
|
||||
handleDomEditElementDelete,
|
||||
domEditSelectionRef,
|
||||
clearDomSelectionRef,
|
||||
editHistory,
|
||||
readOptionalProjectFile,
|
||||
readProjectFile,
|
||||
@@ -116,12 +115,10 @@ export function useAppHotkeys({
|
||||
return;
|
||||
}
|
||||
if (result.ok && result.label) {
|
||||
clearDomSelectionRef.current();
|
||||
await syncHistoryPreviewAfterApply(result.paths);
|
||||
showToast(`Undid ${result.label}`, "info");
|
||||
}
|
||||
}, [
|
||||
clearDomSelectionRef,
|
||||
editHistory,
|
||||
readHistoryProjectFile,
|
||||
showToast,
|
||||
@@ -141,12 +138,10 @@ export function useAppHotkeys({
|
||||
return;
|
||||
}
|
||||
if (result.ok && result.label) {
|
||||
clearDomSelectionRef.current();
|
||||
await syncHistoryPreviewAfterApply(result.paths);
|
||||
showToast(`Redid ${result.label}`, "info");
|
||||
}
|
||||
}, [
|
||||
clearDomSelectionRef,
|
||||
editHistory,
|
||||
readHistoryProjectFile,
|
||||
showToast,
|
||||
|
||||
@@ -190,6 +190,7 @@ export function useDomEditCommits({
|
||||
const {
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomTextCommit,
|
||||
commitDomTextFields,
|
||||
handleDomTextFieldStyleCommit,
|
||||
@@ -439,6 +440,7 @@ export function useDomEditCommits({
|
||||
resolveImportedFontAsset,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomTextCommit,
|
||||
commitDomTextFields,
|
||||
handleDomTextFieldStyleCommit,
|
||||
|
||||
@@ -194,6 +194,7 @@ export function useDomEditSession({
|
||||
resolveImportedFontAsset,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomTextCommit,
|
||||
handleDomTextFieldStyleCommit,
|
||||
handleDomAddTextField,
|
||||
@@ -307,6 +308,7 @@ export function useDomEditSession({
|
||||
clearDomSelection,
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildDomEditStylePatchOperation,
|
||||
buildDomEditTextPatchOperation,
|
||||
findElementForSelection,
|
||||
getDomEditTargetKey,
|
||||
isTextEditableSelection,
|
||||
serializeDomEditTextFields,
|
||||
buildDefaultDomEditTextField,
|
||||
@@ -125,7 +126,8 @@ export function useDomEditTextCommits({
|
||||
const op: PatchOperation = { type: "attribute", property: attr, value };
|
||||
try {
|
||||
await persistDomEditOperations(domEditSelection, [op], {
|
||||
label: "Edit timing",
|
||||
label: `Edit ${attr.replace(/-/g, " ")}`,
|
||||
coalesceKey: `attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
|
||||
skipRefresh: false,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -145,6 +147,45 @@ export function useDomEditTextCommits({
|
||||
],
|
||||
);
|
||||
|
||||
const handleDomHtmlAttributeCommit = useCallback(
|
||||
async (attr: string, value: string | null) => {
|
||||
if (!domEditSelection) return;
|
||||
const iframe = previewIframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (doc) {
|
||||
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
||||
if (el) {
|
||||
if (value === null || value === "" || value === "false") {
|
||||
el.removeAttribute(attr);
|
||||
} else {
|
||||
el.setAttribute(attr, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const op: PatchOperation = { type: "html-attribute", property: attr, value };
|
||||
try {
|
||||
await persistDomEditOperations(domEditSelection, [op], {
|
||||
label: `Edit ${attr}`,
|
||||
coalesceKey: `html-attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
|
||||
skipRefresh: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[Studio] HTML 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;
|
||||
@@ -354,6 +395,7 @@ export function useDomEditTextCommits({
|
||||
return {
|
||||
handleDomStyleCommit,
|
||||
handleDomAttributeCommit,
|
||||
handleDomHtmlAttributeCommit,
|
||||
handleDomTextCommit,
|
||||
commitDomTextFields,
|
||||
handleDomTextFieldStyleCommit,
|
||||
|
||||
@@ -87,7 +87,7 @@ function splitInlineStyleDeclarations(style: string): string[] {
|
||||
}
|
||||
|
||||
export interface PatchOperation {
|
||||
type: "inline-style" | "attribute" | "text-content";
|
||||
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
|
||||
property: string;
|
||||
value: string | null;
|
||||
}
|
||||
@@ -413,6 +413,91 @@ function findMatchingClosingTagIndex(html: string, tagName: string, contentStart
|
||||
return -1;
|
||||
}
|
||||
|
||||
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
||||
"loop",
|
||||
"muted",
|
||||
"autoplay",
|
||||
"playsinline",
|
||||
"controls",
|
||||
"default",
|
||||
"defer",
|
||||
"disabled",
|
||||
"hidden",
|
||||
"nomodule",
|
||||
"open",
|
||||
"readonly",
|
||||
"required",
|
||||
"reversed",
|
||||
"selected",
|
||||
]);
|
||||
|
||||
function patchHtmlAttributeInTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
if (!tag) return html;
|
||||
|
||||
const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
|
||||
|
||||
if (isBoolean) {
|
||||
const escapedAttr = escapeRegex(attr);
|
||||
const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
|
||||
|
||||
if (value === null || value === "" || value === "false") {
|
||||
if (!hasBoolAttr) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
if (hasBoolAttr) return html;
|
||||
const newTag = tag + ` ${attr}`;
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
|
||||
if (value === null) {
|
||||
if (!attrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
const escaped = escapeHtmlAttribute(value);
|
||||
if (attrPattern.test(tag)) {
|
||||
const newTag = tag.replace(attrPattern, `${attr}="${escaped}"`);
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
const newTag = tag + ` ${attr}="${escaped}"`;
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
function patchHtmlAttribute(
|
||||
html: string,
|
||||
elementId: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
||||
const match = idPattern.exec(html);
|
||||
if (!match) return html;
|
||||
return patchHtmlAttributeInTag(html, match[1], attr, value);
|
||||
}
|
||||
|
||||
function patchHtmlAttributeByTarget(
|
||||
html: string,
|
||||
target: PatchTarget,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
const newTag = patchHtmlAttributeInTag(match.tag, match.tag, attr, value);
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
|
||||
function patchTextContentByTarget(html: string, target: PatchTarget, value: string): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
@@ -436,6 +521,8 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation):
|
||||
return patchInlineStyle(html, elementId, op.property, op.value);
|
||||
case "attribute":
|
||||
return patchAttribute(html, elementId, op.property, op.value);
|
||||
case "html-attribute":
|
||||
return patchHtmlAttribute(html, elementId, op.property, op.value);
|
||||
case "text-content":
|
||||
return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
|
||||
default:
|
||||
@@ -456,6 +543,8 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
|
||||
return patchInlineStyleByTarget(html, target, op.property, op.value);
|
||||
case "attribute":
|
||||
return patchAttributeByTarget(html, target, op.property, op.value);
|
||||
case "html-attribute":
|
||||
return patchHtmlAttributeByTarget(html, target, op.property, op.value);
|
||||
case "text-content":
|
||||
return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vite adapter that wires the shared Studio API to the local filesystem and build tools.
|
||||
|
||||
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, existsSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import { join, relative, resolve, isAbsolute } from "node:path";
|
||||
import type { ViteDevServer } from "vite";
|
||||
import {
|
||||
@@ -132,7 +132,11 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
|
||||
if (session.projectId) {
|
||||
projectDir = join(dataDir, session.projectId);
|
||||
if (existsSync(projectDir)) {
|
||||
return { id: session.projectId, dir: projectDir, title: session.title };
|
||||
return {
|
||||
id: session.projectId,
|
||||
dir: realpathSync(projectDir),
|
||||
title: session.title,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -141,7 +145,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { id, dir: projectDir };
|
||||
return { id, dir: realpathSync(projectDir) };
|
||||
},
|
||||
|
||||
async bundle(dir: string) {
|
||||
|
||||
Reference in New Issue
Block a user