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
7 changes: 6 additions & 1 deletion packages/core/src/studio-api/helpers/safePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ describe("walkDir", () => {
it("hides internal HyperFrames backup files from project listings", () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), "preset");
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");

expect(walkDir(projectDir)).toEqual(["compositions/scene.html"]);
expect(walkDir(projectDir)).toEqual([
".hyperframes/examples/preset.html",
"compositions/scene.html",
]);
});
});
8 changes: 6 additions & 2 deletions packages/core/src/studio-api/helpers/safePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export function isSafePath(base: string, resolved: string): boolean {
return resolved.startsWith(norm) || resolved === resolve(base);
}

const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]);
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);

function shouldIgnoreDir(rel: string): boolean {
return rel === ".hyperframes/backup";
}

/**
* True when any directory segment of a relative path is a dot-directory or
Expand All @@ -25,8 +29,8 @@ export function isInHiddenOrVendorDir(relPath: string): boolean {
export function walkDir(dir: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {
Expand Down
8 changes: 8 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSid
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player";
import { LintModal } from "./components/LintModal";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
Expand Down Expand Up @@ -478,6 +479,13 @@ export function StudioApp() {
onExport={() => void renderQueue.startRender()}
/>

{previewPersistence.domEditSaveQueuePaused && (
<SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused}
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
/>
)}

<div className="flex flex-1 min-h-0">
<StudioLeftSidebar
leftSidebarRef={leftSidebarRef}
Expand Down
23 changes: 23 additions & 0 deletions packages/studio/src/components/SaveQueuePausedBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
interface SaveQueuePausedBannerProps {
message: string;
onDismiss: () => void;
}

/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
return (
<div
className="absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
role="alert"
>
<span>{message}</span>
<button
type="button"
onClick={onDismiss}
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
>
Dismiss
</button>
</div>
);
}
33 changes: 33 additions & 0 deletions packages/studio/src/hooks/gsapScriptCommitHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { DomEditSelection } from "../components/editor/domEditingTypes";

export const PROPERTY_DEFAULTS: Record<string, number> = {
opacity: 1,
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotation: 0,
width: 100,
height: 100,
};

export function ensureElementAddressable(selection: DomEditSelection): {
selector: string;
autoId?: string;
} {
if (selection.id) return { selector: `#${selection.id}` };
if (selection.selector) return { selector: selection.selector };

const el = selection.element;
const doc = el.ownerDocument;
const tag = el.tagName.toLowerCase();
let id = tag;
let n = 1;
while (doc.getElementById(id)) {
n += 1;
id = `${tag}-${n}`;
}
el.setAttribute("id", id);
return { selector: `#${id}`, autoId: id };
}
68 changes: 29 additions & 39 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
import {
buildDomEditPatchTarget,
getDomEditTargetKey,
Expand All @@ -31,8 +32,10 @@ import {
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";

// ── Helpers ──
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };

export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
Expand Down Expand Up @@ -69,6 +72,8 @@ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLEl
return false;
}

// ── Types ──

interface RecordEditInput {
label: string;
kind: EditHistoryKind;
Expand Down Expand Up @@ -102,6 +107,7 @@ export interface UseDomEditCommitsParams {
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;

// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
Expand All @@ -115,6 +121,8 @@ export interface UseDomEditCommitsParams {
) => Promise<DomEditSelection | null>;
}

// ── Hook ──

export function useDomEditCommits({
activeCompPath,
previewIframeRef,
Expand Down Expand Up @@ -172,7 +180,9 @@ export function useDomEditCommits({
const readResponse = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
if (!readResponse.ok) {
throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
}
const readData = (await readResponse.json()) as { content?: string };
const originalContent = readData.content;
if (typeof originalContent !== "string") {
Expand All @@ -196,14 +206,15 @@ export function useDomEditCommits({
body: JSON.stringify({ target: patchTarget, operations }),
},
);
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
if (!patchResponse.ok) {
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
}

const patchData = (await patchResponse.json()) as {
ok?: boolean;
changed?: boolean;
matched?: boolean;
content?: string;
path?: string;
};

if (!patchData.changed) {
Expand Down Expand Up @@ -243,7 +254,6 @@ export function useDomEditCommits({
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
showToast(`Updated ${patchData.path ?? targetPath}`, "info");

if (!options?.skipRefresh) {
reloadPreview();
Expand All @@ -256,7 +266,6 @@ export function useDomEditCommits({
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
showToast,
],
);

Expand All @@ -282,38 +291,12 @@ export function useDomEditCommits({
resolveImportedFontAsset,
});

// ── Position patch helper ──

// fallow-ignore-next-line complexity
const commitPositionPatchToHtml = useCallback(
(
selection: DomEditSelection,
patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => {
return queueDomEditSave(async () => {
await persistDomEditOperations(selection, patches, {
label: options.label,
coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true,
});
// fallow-ignore-next-line complexity
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message);
trackStudioEvent("save_failure", {
source: "dom_edit",
label: options.label,
error_message: message,
target_id: selection.id ?? undefined,
target_selector: selection.selector ?? undefined,
target_source_file: selection.sourceFile ?? undefined,
});
throw error;
});
},
[persistDomEditOperations, queueDomEditSave, showToast],
);
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
activeCompPath,
persistDomEditOperations,
queueDomEditSave,
showToast,
});

// ── Position commits ──

Expand Down Expand Up @@ -426,7 +409,9 @@ export function useDomEditCommits({
const response = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
if (!response.ok) {
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
}

const data = (await response.json()) as { content?: string };
const originalContent = data.content;
Expand All @@ -447,7 +432,12 @@ export function useDomEditCommits({
body: JSON.stringify({ target: patchTarget }),
},
);
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
if (!removeResponse.ok) {
throw await createStudioSaveHttpError(
removeResponse,
`Failed to delete element from ${targetPath}`,
);
}

const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
const patchedContent =
Expand Down
53 changes: 53 additions & 0 deletions packages/studio/src/hooks/useDomEditPositionPatchCommit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { PatchOperation } from "../utils/sourcePatcher";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
import type { PersistDomEditOperations } from "./useDomEditCommits";

interface UseDomEditPositionPatchCommitParams {
activeCompPath: string | null;
persistDomEditOperations: PersistDomEditOperations;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
showToast: (message: string, tone?: "error" | "info") => void;
}

interface PositionPatchOptions {
label: string;
coalesceKey: string;
skipRefresh?: boolean;
}

export function useDomEditPositionPatchCommit({
activeCompPath,
persistDomEditOperations,
queueDomEditSave,
showToast,
}: UseDomEditPositionPatchCommitParams) {
return useCallback(
(selection: DomEditSelection, patches: PatchOperation[], options: PositionPatchOptions) => {
return queueDomEditSave(async () => {
await persistDomEditOperations(selection, patches, {
label: options.label,
coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true,
});
}).catch((error) => {
if (error instanceof DomEditSaveQueueOpenError) return;
showToast(error instanceof Error ? error.message : "Failed to save position");
trackStudioSaveFailure({
source: "dom_edit",
error,
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
mutationType: "position",
label: options.label,
targetId: selection.id,
targetSelector: selection.selector,
targetSourceFile: selection.sourceFile,
});
throw error;
});
},
[activeCompPath, persistDomEditOperations, queueDomEditSave, showToast],
);
}
Loading
Loading