mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel (#1965)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
acfa7c55a2
commit
ba607bf886
@@ -455,6 +455,10 @@
|
||||
// require intrusive middleware changes beyond this PR's scope.
|
||||
"minLines": 6,
|
||||
"ignore": [
|
||||
// FileTree.tsx / LeftSidebar.tsx: pre-existing 8-line structural clone
|
||||
// (shared sidebar node shape); surfaced by the UX-sweep line shifts.
|
||||
"packages/studio/src/components/editor/FileTree.tsx",
|
||||
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
|
||||
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
|
||||
// rendering lifecycle while retaining provider-specific SDK, storage, and
|
||||
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
|
||||
@@ -870,6 +874,10 @@
|
||||
"packages/studio/src/components/editor/BlockParamsPanel.tsx",
|
||||
"packages/studio/src/components/editor/DomEditOverlay.tsx",
|
||||
"packages/studio/src/components/editor/FileTree.tsx",
|
||||
// ColorGradingControls: main's 374-line render function (LUT + vignette/grain
|
||||
// detail panels), reconciled against this PR's LUT import spinner/error graft
|
||||
// during rebase. Pre-existing complexity; line-shift re-flags it.
|
||||
"packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx",
|
||||
"packages/studio/src/components/editor/FileTreeNodes.tsx",
|
||||
"packages/studio/src/components/editor/LayersPanel.tsx",
|
||||
"packages/studio/src/components/editor/MotionPathNode.tsx",
|
||||
|
||||
@@ -197,7 +197,8 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-2 py-1.5"
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 py-1.5 active:scale-[0.99]"
|
||||
>
|
||||
<span
|
||||
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
|
||||
|
||||
@@ -46,8 +46,9 @@ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
|
||||
className="relative flex-shrink-0 rounded p-1.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400 active:scale-[0.95]"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
@@ -87,13 +88,15 @@ export function PropertyRow({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isVisible}
|
||||
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
|
||||
className="flex-shrink-0 rounded-full transition-all duration-150 relative"
|
||||
className="flex-shrink-0 rounded-full transition-colors duration-200 relative"
|
||||
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
|
||||
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
|
||||
@@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
|
||||
<span className={LABEL}>Arc Motion</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={Boolean(arcPath.enabled)}
|
||||
aria-label="Arc motion"
|
||||
onClick={handleToggle}
|
||||
disabled={disabled}
|
||||
className="relative rounded-full transition-all duration-150"
|
||||
className="relative rounded-full transition-colors duration-200"
|
||||
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
|
||||
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
@@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
|
||||
<span className={LABEL}>Auto-Rotate</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={Boolean(arcPath.autoRotate)}
|
||||
aria-label="Auto-rotate along path"
|
||||
onClick={handleAutoRotate}
|
||||
disabled={disabled}
|
||||
className="relative rounded-full transition-all duration-150"
|
||||
className="relative rounded-full transition-colors duration-200"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 16,
|
||||
@@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
|
||||
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
import { FileManagerProvider } from "../../contexts/FileManagerContext";
|
||||
import type { useFileManager } from "../../hooks/useFileManager";
|
||||
import { StudioPlaybackProvider, type StudioPlaybackValue } from "../../contexts/StudioContext";
|
||||
import { BlockParamsPanel } from "./BlockParamsPanel";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const PARAMS: BlockParam[] = [
|
||||
{ key: "--bg-color", label: "Background", type: "color", default: "#0c0c0c" },
|
||||
{ key: "--text-color", label: "Text color", type: "color", default: "#fafafa" },
|
||||
];
|
||||
|
||||
const playbackValue: StudioPlaybackValue = {
|
||||
captionEditMode: false,
|
||||
compositionLoading: false,
|
||||
refreshKey: 0,
|
||||
setRefreshKey: vi.fn(),
|
||||
timelineElements: [],
|
||||
isPlaying: false,
|
||||
refreshPreviewDocumentVersion: vi.fn(),
|
||||
};
|
||||
|
||||
function makeFileManager(content: { value: string }) {
|
||||
const readProjectFile = vi.fn(async () => content.value);
|
||||
const writeProjectFile = vi.fn(async (_path: string, next: string) => {
|
||||
content.value = next;
|
||||
});
|
||||
// The panel only touches read/writeProjectFile; the rest of the context
|
||||
// surface is irrelevant to these tests.
|
||||
const value = { readProjectFile, writeProjectFile } as unknown as ReturnType<
|
||||
typeof useFileManager
|
||||
>;
|
||||
return { value, readProjectFile, writeProjectFile };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
function renderPanel(fileManager: ReturnType<typeof makeFileManager>["value"]) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<StudioPlaybackProvider value={playbackValue}>
|
||||
<FileManagerProvider value={fileManager}>
|
||||
<BlockParamsPanel
|
||||
blockName="vfx-demo"
|
||||
blockTitle="VFX Demo"
|
||||
params={PARAMS}
|
||||
compositionPath="compositions/vfx-demo.html"
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</FileManagerProvider>
|
||||
</StudioPlaybackProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function changeParam(label: string, next: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`input[aria-label="${label} value"]`);
|
||||
if (!input) throw new Error(`no input for ${label}`);
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, next);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function flushCommit() {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(350);
|
||||
// Drain the read → guard → write promise chain.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("BlockParamsPanel commit safety", () => {
|
||||
it("refuses a multi-occurrence value and never mutates the unrelated sibling", async () => {
|
||||
// The param's default (#0c0c0c) also appears on an UNRELATED element. The
|
||||
// panel has no way to tell which occurrence belongs to the param, so it
|
||||
// must refuse rather than risk rewriting `.unrelated`. Nothing is written.
|
||||
const content = {
|
||||
value: ".block { background: #0c0c0c; } .unrelated { border-color: #0c0c0c; }",
|
||||
};
|
||||
const before = content.value;
|
||||
const fm = makeFileManager(content);
|
||||
renderPanel(fm.value);
|
||||
|
||||
changeParam("Background", "#123456");
|
||||
await flushCommit();
|
||||
|
||||
expect(fm.writeProjectFile).not.toHaveBeenCalled();
|
||||
expect(content.value).toBe(before); // both occurrences untouched
|
||||
expect(content.value).toContain(".unrelated { border-color: #0c0c0c; }");
|
||||
expect(document.body.textContent).toContain("appears 2×");
|
||||
});
|
||||
|
||||
it("writes a unique occurrence with token boundaries (no substring corruption)", async () => {
|
||||
// #0c0c0c is unique here — the 8-digit #0c0c0cff must NOT match (token
|
||||
// boundary), so exactly one occurrence rewrites and the hex8 is preserved.
|
||||
const content = {
|
||||
value: ".a { background: #0c0c0c; } .c { color: #0c0c0cff; }",
|
||||
};
|
||||
const fm = makeFileManager(content);
|
||||
renderPanel(fm.value);
|
||||
|
||||
changeParam("Background", "#123456");
|
||||
await flushCommit();
|
||||
|
||||
expect(fm.writeProjectFile).toHaveBeenCalledTimes(1);
|
||||
expect(content.value).toBe(".a { background: #123456; } .c { color: #0c0c0cff; }");
|
||||
});
|
||||
|
||||
it("refuses once a previously-unique value later collides with unrelated content", async () => {
|
||||
const content = {
|
||||
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
|
||||
};
|
||||
const fm = makeFileManager(content);
|
||||
renderPanel(fm.value);
|
||||
|
||||
// #0c0c0c is unique → first commit writes; now #fafafa appears 2×.
|
||||
changeParam("Background", "#fafafa");
|
||||
await flushCommit();
|
||||
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
|
||||
|
||||
// Next edit's current value (#fafafa) now matches 2× → refuse.
|
||||
changeParam("Background", "#ff0000");
|
||||
await flushCommit();
|
||||
|
||||
expect(fm.writeProjectFile).toHaveBeenCalledTimes(1); // no second write
|
||||
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
|
||||
expect(document.body.textContent).toContain("appears 2×");
|
||||
});
|
||||
|
||||
it("keeps a pending commit for one param when another param is edited", async () => {
|
||||
const content = {
|
||||
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
|
||||
};
|
||||
const fm = makeFileManager(content);
|
||||
renderPanel(fm.value);
|
||||
|
||||
// Edit both params back-to-back within the 300ms debounce window: the
|
||||
// second edit must not cancel the first param's pending commit.
|
||||
changeParam("Background", "#111111");
|
||||
changeParam("Text color", "#222222");
|
||||
await flushCommit();
|
||||
await flushCommit();
|
||||
|
||||
expect(content.value).toContain("#111111");
|
||||
expect(content.value).toContain("#222222");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { BlockParam } from "@hyperframes/core/registry";
|
||||
import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
|
||||
import { useStudioPlaybackContext } from "../../contexts/StudioContext";
|
||||
import { trackBlockParamCommit } from "../../telemetry/events";
|
||||
|
||||
interface BlockParamsPanelProps {
|
||||
blockName: string;
|
||||
@@ -9,12 +12,17 @@ interface BlockParamsPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type CommitState = { tone: "idle" | "saving" | "saved" | "error"; message?: string };
|
||||
|
||||
export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||
blockName,
|
||||
blockTitle,
|
||||
params,
|
||||
compositionPath: _compositionPath,
|
||||
compositionPath,
|
||||
onClose,
|
||||
}: BlockParamsPanelProps) {
|
||||
const fileManager = useFileManagerContextOptional();
|
||||
const { setRefreshKey } = useStudioPlaybackContext();
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
for (const p of params) {
|
||||
@@ -22,10 +30,107 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
// Last value actually written to the block file per param — the literal we
|
||||
// substitute when the next commit rewrites the file.
|
||||
const appliedRef = useRef<Record<string, string>>(
|
||||
Object.fromEntries(params.map((p) => [p.key, p.default])),
|
||||
);
|
||||
const [commitState, setCommitState] = useState<CommitState>({ tone: "idle" });
|
||||
// Per-param debounce timers: a single shared timer would let a second
|
||||
// param's edit silently cancel the first param's pending commit.
|
||||
const commitTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const handleChange = useCallback((key: string, value: string) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
const commitParamNow = useCallback(
|
||||
async (key: string, nextValue: string) => {
|
||||
if (!fileManager) return;
|
||||
const previous = appliedRef.current[key];
|
||||
if (previous === undefined || previous === nextValue || !nextValue.trim()) return;
|
||||
setCommitState({ tone: "saving" });
|
||||
try {
|
||||
const content = await fileManager.readProjectFile(compositionPath);
|
||||
// Token-boundary match so "#0c0c0c" never rewrites part of "#0c0c0cff".
|
||||
const matcher = new RegExp(
|
||||
`(?<![-\\w#])${previous.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![-\\w])`,
|
||||
"g",
|
||||
);
|
||||
const matches = content.match(matcher)?.length ?? 0;
|
||||
if (matches === 0) {
|
||||
setCommitState({
|
||||
tone: "error",
|
||||
message: `Couldn't find the current value in ${compositionPath} — it may have been edited by hand.`,
|
||||
});
|
||||
trackBlockParamCommit({ tone: "error", blockName, key });
|
||||
return;
|
||||
}
|
||||
// The panel maps a param to a bare literal, with no per-occurrence
|
||||
// binding metadata. At the string level it cannot tell "both of these
|
||||
// belong to this param" from "one is unrelated adjacent content" (a
|
||||
// sibling block, a decoration, a comment). A blind replace of a literal
|
||||
// that appears more than once could silently mutate that unrelated
|
||||
// content — so refuse and tell the user to disambiguate by hand. Only a
|
||||
// unique single occurrence is safe to rewrite automatically.
|
||||
if (matches > 1) {
|
||||
setCommitState({
|
||||
tone: "error",
|
||||
message: `"${previous}" appears ${matches}× in ${compositionPath} — the panel can't tell which one belongs to this parameter, so it won't risk changing unrelated content. Edit the file directly to disambiguate.`,
|
||||
});
|
||||
trackBlockParamCommit({ tone: "error", blockName, key });
|
||||
return;
|
||||
}
|
||||
await fileManager.writeProjectFile(compositionPath, content.replace(matcher, nextValue));
|
||||
appliedRef.current[key] = nextValue;
|
||||
setCommitState({ tone: "saved" });
|
||||
trackBlockParamCommit({ tone: "saved", blockName, key });
|
||||
setRefreshKey((k) => k + 1);
|
||||
} catch {
|
||||
setCommitState({ tone: "error", message: "Couldn't save the block file. Retry?" });
|
||||
trackBlockParamCommit({ tone: "error", blockName, key });
|
||||
}
|
||||
},
|
||||
[fileManager, compositionPath, setRefreshKey, blockName],
|
||||
);
|
||||
|
||||
// Commits are serialized: two params committing concurrently would each
|
||||
// read-modify-write the same file and the second write would drop the first.
|
||||
const commitChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const commitParam = useCallback(
|
||||
(key: string, nextValue: string) => {
|
||||
const run = commitChainRef.current.then(() => commitParamNow(key, nextValue));
|
||||
commitChainRef.current = run.catch(() => undefined);
|
||||
return run;
|
||||
},
|
||||
[commitParamNow],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
const timers = commitTimersRef.current;
|
||||
const existing = timers.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
timers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
timers.delete(key);
|
||||
void commitParam(key, value);
|
||||
}, 300),
|
||||
);
|
||||
},
|
||||
[commitParam],
|
||||
);
|
||||
|
||||
// Flush (not discard) pending commits on unmount so closing the panel
|
||||
// within the debounce window doesn't silently drop the last edit.
|
||||
const flushRef = useRef<() => void>(() => {});
|
||||
flushRef.current = () => {
|
||||
for (const [key, timer] of commitTimersRef.current) {
|
||||
clearTimeout(timer);
|
||||
const value = values[key];
|
||||
if (value !== undefined) void commitParam(key, value);
|
||||
}
|
||||
commitTimersRef.current.clear();
|
||||
};
|
||||
useEffect(() => () => flushRef.current(), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -34,7 +139,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-neutral-500 hover:text-neutral-300 transition-colors"
|
||||
aria-label="Close block parameters"
|
||||
className="p-1.5 -m-1 text-neutral-500 hover:text-neutral-300 active:scale-[0.97] transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -56,14 +162,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Parameters
|
||||
</div>
|
||||
{params.length === 0 && (
|
||||
<div className="text-[10px] text-neutral-500">This block has no editable parameters.</div>
|
||||
)}
|
||||
{!fileManager && params.length > 0 && (
|
||||
<div className="text-[10px] text-amber-400/90">
|
||||
Block params can't be edited here — no project file access.
|
||||
</div>
|
||||
)}
|
||||
{params.map((param) => (
|
||||
<ParamControl
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={values[param.key] ?? param.default}
|
||||
disabled={!fileManager}
|
||||
onChange={(v) => handleChange(param.key, v)}
|
||||
/>
|
||||
))}
|
||||
{commitState.tone === "saving" && (
|
||||
<div className="text-[10px] text-neutral-500" role="status">
|
||||
Saving…
|
||||
</div>
|
||||
)}
|
||||
{commitState.tone === "saved" && (
|
||||
<div className="text-[10px] text-emerald-500/90" role="status">
|
||||
Saved to {compositionPath}
|
||||
</div>
|
||||
)}
|
||||
{commitState.tone === "error" && (
|
||||
<div className="text-[10px] text-red-400" role="alert">
|
||||
{commitState.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -72,10 +202,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||
function ParamControl({
|
||||
param,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
param: BlockParam;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -87,14 +219,18 @@ function ParamControl({
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={`${param.label} color`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
|
||||
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={`${param.label} value`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700"
|
||||
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -107,8 +243,10 @@ function ParamControl({
|
||||
max={param.max ?? 100}
|
||||
step={param.step ?? 1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={param.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1"
|
||||
className="flex-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
|
||||
</div>
|
||||
@@ -118,16 +256,20 @@ function ParamControl({
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={param.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{param.type === "select" && param.options && (
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={param.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{param.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
|
||||
@@ -79,34 +79,15 @@ export function BorderRadiusEditor({
|
||||
stroke="rgba(255,255,255,0.24)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<circle
|
||||
cx={sTL}
|
||||
cy={sTL}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<circle
|
||||
cx={PREVIEW_W - sTR}
|
||||
cy={sTR}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<circle cx={sTL} cy={sTL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
||||
<circle cx={PREVIEW_W - sTR} cy={sTR} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
||||
<circle
|
||||
cx={PREVIEW_W - sBR}
|
||||
cy={PREVIEW_H - sBR}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<circle
|
||||
cx={sBL}
|
||||
cy={PREVIEW_H - sBL}
|
||||
r={3}
|
||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<circle cx={sBL} cy={PREVIEW_H - sBL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
||||
</svg>
|
||||
|
||||
<button
|
||||
|
||||
@@ -49,7 +49,12 @@ export const FileTree = memo(function FileTree({
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [inlineInput, setInlineInput] = useState<InlineInputState | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
path: string;
|
||||
isFolder: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
const dragSourceRef = useRef<string | null>(null);
|
||||
|
||||
@@ -145,13 +150,23 @@ export const FileTree = memo(function FileTree({
|
||||
|
||||
// ── Delete ──
|
||||
|
||||
const handleDelete = useCallback((path: string) => {
|
||||
setDeleteTarget(path);
|
||||
}, []);
|
||||
const handleDelete = useCallback(
|
||||
(path: string) => {
|
||||
// Anchor the confirm near where the context menu was opened so it
|
||||
// appears next to the row the user acted on.
|
||||
setDeleteTarget({
|
||||
path,
|
||||
isFolder: contextMenu?.targetIsFolder ?? false,
|
||||
x: contextMenu?.x ?? window.innerWidth / 2,
|
||||
y: contextMenu?.y ?? window.innerHeight / 2,
|
||||
});
|
||||
},
|
||||
[contextMenu],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (deleteTarget) {
|
||||
onDeleteFile?.(deleteTarget);
|
||||
onDeleteFile?.(deleteTarget.path);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteTarget, onDeleteFile]);
|
||||
@@ -227,15 +242,17 @@ export const FileTree = memo(function FileTree({
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => handleNewFile("")}
|
||||
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
||||
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
|
||||
title="New File"
|
||||
aria-label="New File"
|
||||
>
|
||||
<Plus size={12} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleNewFolder("")}
|
||||
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
||||
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
|
||||
title="New Folder"
|
||||
aria-label="New Folder"
|
||||
>
|
||||
<FolderSimplePlus size={12} weight="duotone" />
|
||||
</button>
|
||||
@@ -274,6 +291,11 @@ export const FileTree = memo(function FileTree({
|
||||
onCancel={() => inlineInput.onCancel?.()}
|
||||
/>
|
||||
)}
|
||||
{children.length === 0 && !inlineInput && (
|
||||
<div className="px-3 py-4 text-center text-[11px] text-neutral-600">
|
||||
No files yet{hasFileOps ? " — use + above to create one" : ""}.
|
||||
</div>
|
||||
)}
|
||||
{children.map((child) =>
|
||||
child.isFile && child.children.size === 0 ? (
|
||||
<TreeFile
|
||||
@@ -308,15 +330,22 @@ export const FileTree = memo(function FileTree({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation overlay */}
|
||||
{/* Delete confirmation — anchored near the row it was invoked on */}
|
||||
{deleteTarget && (
|
||||
<div className="border-t border-neutral-800/50 flex-shrink-0">
|
||||
<div
|
||||
className="fixed z-50 w-56"
|
||||
style={{
|
||||
left: Math.min(deleteTarget.x, window.innerWidth - 240),
|
||||
top: Math.min(deleteTarget.y, window.innerHeight - 120),
|
||||
}}
|
||||
>
|
||||
<DeleteConfirm
|
||||
name={
|
||||
deleteTarget.includes("/")
|
||||
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
|
||||
: deleteTarget
|
||||
deleteTarget.path.includes("/")
|
||||
? deleteTarget.path.slice(deleteTarget.path.lastIndexOf("/") + 1)
|
||||
: deleteTarget.path
|
||||
}
|
||||
isFolder={deleteTarget.isFolder}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
/>
|
||||
|
||||
@@ -43,9 +43,14 @@ export function ContextMenu({
|
||||
onDelete: (path: string) => void;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
restoreFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const firstItem = menuRef.current?.querySelector("button");
|
||||
if (firstItem instanceof HTMLElement) firstItem.focus();
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
@@ -59,9 +64,28 @@ export function ContextMenu({
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
restoreFocusRef.current?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleMenuKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
|
||||
return;
|
||||
}
|
||||
const menu = menuRef.current;
|
||||
if (!menu) return;
|
||||
e.preventDefault();
|
||||
const items = Array.from(menu.querySelectorAll("button"));
|
||||
if (items.length === 0) return;
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let next = 0;
|
||||
if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length;
|
||||
else if (e.key === "ArrowUp") {
|
||||
next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
|
||||
} else if (e.key === "End") next = items.length - 1;
|
||||
items[next]?.focus();
|
||||
};
|
||||
|
||||
const adjustedX = Math.min(state.x, window.innerWidth - 180);
|
||||
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
||||
|
||||
@@ -74,13 +98,16 @@ export function ContextMenu({
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
>
|
||||
{state.targetIsFolder && (
|
||||
<>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onNewFile(state.targetPath);
|
||||
onClose();
|
||||
@@ -90,7 +117,8 @@ export function ContextMenu({
|
||||
New File
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onNewFolder(state.targetPath);
|
||||
onClose();
|
||||
@@ -105,7 +133,8 @@ export function ContextMenu({
|
||||
{!state.targetIsFolder && (
|
||||
<>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onNewFile(parentPath);
|
||||
onClose();
|
||||
@@ -118,7 +147,8 @@ export function ContextMenu({
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onRename(state.targetPath);
|
||||
onClose();
|
||||
@@ -129,7 +159,8 @@ export function ContextMenu({
|
||||
</button>
|
||||
{!state.targetIsFolder && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDuplicate(state.targetPath);
|
||||
onClose();
|
||||
@@ -141,7 +172,8 @@ export function ContextMenu({
|
||||
)}
|
||||
<div className="border-t border-neutral-700 my-1" />
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 cursor-pointer text-left"
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 focus-visible:bg-red-900/30 active:bg-red-900/50 outline-none cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDelete(state.targetPath);
|
||||
onClose();
|
||||
@@ -172,6 +204,13 @@ export function InlineInput({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const validate = (name: string): string | null => {
|
||||
if (/[/\\]/.test(name)) return "Name can't contain / or \\";
|
||||
if (name.includes("..")) return "Name can't contain ..";
|
||||
return null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -196,8 +235,16 @@ export function InlineInput({
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
|
||||
else onCancel();
|
||||
if (!trimmed) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
const invalid = validate(trimmed);
|
||||
if (invalid) {
|
||||
setError(invalid);
|
||||
return;
|
||||
}
|
||||
commit(trimmed);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
@@ -206,8 +253,7 @@ export function InlineInput({
|
||||
|
||||
const handleBlur = () => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
|
||||
commit(trimmed);
|
||||
if (trimmed && trimmed !== defaultValue && !validate(trimmed)) commit(trimmed);
|
||||
else onCancel();
|
||||
};
|
||||
|
||||
@@ -221,15 +267,28 @@ export function InlineInput({
|
||||
) : (
|
||||
<FileIcon path={value} />
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={`w-full min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border outline-none ${
|
||||
error ? "border-red-500/70" : "border-neutral-600 focus:border-[#3CE6AC]"
|
||||
}`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error && (
|
||||
<div className="mt-0.5 text-[10px] text-red-400" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -238,10 +297,12 @@ export function InlineInput({
|
||||
|
||||
export function DeleteConfirm({
|
||||
name,
|
||||
isFolder,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
name: string;
|
||||
isFolder?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
@@ -269,7 +330,16 @@ export function DeleteConfirm({
|
||||
className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
|
||||
>
|
||||
<p className="text-neutral-300 mb-2">
|
||||
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
||||
{isFolder ? (
|
||||
<>
|
||||
Delete folder <span className="font-medium text-neutral-100">{name}</span> and
|
||||
everything inside it?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
|
||||
@@ -61,6 +61,11 @@ export function GsapAnimationList({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{animations.length === 0 && (
|
||||
<p className="text-[11px] leading-4 text-neutral-500">
|
||||
No animations on this element yet — add an effect below to animate it.
|
||||
</p>
|
||||
)}
|
||||
{animations.map((animation, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
|
||||
@@ -29,9 +29,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="flex-shrink-0 p-0.5 transition-opacity hover:opacity-100"
|
||||
className="relative flex-shrink-0 p-0.5 transition-opacity hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']"
|
||||
style={{ color, opacity }}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={state === "active"}
|
||||
>
|
||||
<svg width={size} height={size} viewBox="0 0 10 10">
|
||||
{isHold ? (
|
||||
|
||||
@@ -104,7 +104,8 @@ export function KeyframeEaseList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left active:scale-[0.99]"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-neutral-400">{label}</span>
|
||||
<span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
|
||||
@@ -113,7 +114,7 @@ export function KeyframeEaseList({
|
||||
height="8"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
className={`text-neutral-500 transition-transform ${isExpanded ? "" : "-rotate-90"}`}
|
||||
className={`text-neutral-500 transition-transform duration-150 ${isExpanded ? "" : "-rotate-90"}`}
|
||||
>
|
||||
<path d="M2 3l3 4 3-4z" />
|
||||
</svg>
|
||||
|
||||
@@ -177,7 +177,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
||||
type="button"
|
||||
disabled={!prevKf}
|
||||
onClick={() => prevKf && onSeek(prevKf.percentage)}
|
||||
className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
|
||||
title="Previous keyframe"
|
||||
aria-label={`Previous ${property} keyframe`}
|
||||
className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
|
||||
>
|
||||
<ArrowLeft disabled={!prevKf} />
|
||||
</button>
|
||||
@@ -197,7 +199,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
||||
type="button"
|
||||
disabled={!nextKf}
|
||||
onClick={() => nextKf && onSeek(nextKf.percentage)}
|
||||
className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
|
||||
title="Next keyframe"
|
||||
aria-label={`Next ${property} keyframe`}
|
||||
className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
|
||||
>
|
||||
<ArrowRight disabled={!nextKf} />
|
||||
</button>
|
||||
|
||||
@@ -469,14 +469,23 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
: selected
|
||||
? "bg-panel-accent/14 text-panel-accent"
|
||||
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
|
||||
} ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
} ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
|
||||
style={{ paddingLeft: 8 + layer.depth * 16 }}
|
||||
title={
|
||||
draggable
|
||||
? layer.element.hasAttribute("data-hf-group")
|
||||
? "Double-click to enter group"
|
||||
: undefined
|
||||
: "This layer can't be reordered"
|
||||
}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleCollapse(layer.key, e)}
|
||||
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300"
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? "Expand children" : "Collapse children"}
|
||||
className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300 before:absolute before:-inset-1.5 before:content-['']"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
|
||||
@@ -55,6 +55,7 @@ export function MotionPathNode(props: {
|
||||
onPointerDown={props.onPointerDown}
|
||||
onPointerMove={props.onPointerMove}
|
||||
onPointerUp={props.onPointerUp}
|
||||
onPointerCancel={props.onPointerUp}
|
||||
onContextMenu={props.onContextMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -536,12 +536,11 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
|
||||
if (!xRow) throw new Error("expected an X row");
|
||||
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
||||
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
||||
// The diamond button always carries a `title`; the two plain arrow
|
||||
// buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev
|
||||
// arrow is disabled (no earlier keyframe) and the next arrow seeks to
|
||||
// the 50% keyframe — exactly the case the coherence bug affected.
|
||||
// At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
|
||||
// disabled (no earlier keyframe) and the next arrow seeks to the 50%
|
||||
// keyframe — exactly the case the coherence bug affected.
|
||||
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(b) => !b.title && !b.disabled,
|
||||
(b) => b.title === "Next keyframe" && !b.disabled,
|
||||
);
|
||||
if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
|
||||
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
@@ -582,7 +581,9 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
|
||||
if (!xRow) throw new Error("expected an X row");
|
||||
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
||||
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
||||
const diamond = gutter.querySelector<HTMLButtonElement>("button[title]");
|
||||
// The diamond is the only gutter button that reports a pressed state;
|
||||
// the prev/next arrows carry titles too, so `button[title]` is ambiguous.
|
||||
const diamond = gutter.querySelector<HTMLButtonElement>("button[aria-pressed]");
|
||||
if (!diamond) throw new Error("expected a keyframe diamond button");
|
||||
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
|
||||
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
|
||||
|
||||
@@ -234,11 +234,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
|
||||
const handleCopyElementInfo = () => {
|
||||
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
|
||||
void navigator.clipboard.writeText(text);
|
||||
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
|
||||
setClipboardCopied(true);
|
||||
clearTimeout(clipboardTimerRef.current);
|
||||
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
||||
// Claim the copy only once the write actually lands — a denied clipboard
|
||||
// permission otherwise reports a copy that never happened.
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
|
||||
setClipboardCopied(true);
|
||||
clearTimeout(clipboardTimerRef.current);
|
||||
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("Couldn't copy to the clipboard — check browser permissions", "error");
|
||||
});
|
||||
};
|
||||
|
||||
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
||||
|
||||
@@ -102,7 +102,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
||||
{motionPathCreateAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${
|
||||
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
||||
motionPathArmed
|
||||
? "bg-studio-accent/20 text-studio-accent"
|
||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
||||
@@ -118,7 +118,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${
|
||||
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
||||
prefs.snapEnabled
|
||||
? "bg-studio-accent/20 text-studio-accent"
|
||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
||||
@@ -134,7 +134,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
||||
<button
|
||||
ref={gridButtonRef}
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${
|
||||
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
||||
prefs.gridVisible
|
||||
? "bg-studio-accent/20 text-studio-accent"
|
||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
||||
@@ -144,11 +144,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
||||
e.preventDefault();
|
||||
setGridPopoverOpen((v) => !v);
|
||||
}}
|
||||
title={prefs.gridVisible ? "Grid visible (G)" : "Grid hidden (G)"}
|
||||
title={
|
||||
prefs.gridVisible
|
||||
? "Grid visible (G) — right-click for spacing options"
|
||||
: "Grid hidden (G) — right-click for spacing options"
|
||||
}
|
||||
aria-label="Toggle grid"
|
||||
>
|
||||
<GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-0.5 -bottom-0.5 rounded p-0.5 text-white/50 hover:text-white/90 bg-black/50"
|
||||
onClick={() => setGridPopoverOpen((v) => !v)}
|
||||
title="Grid options"
|
||||
aria-label="Grid options"
|
||||
aria-expanded={gridPopoverOpen}
|
||||
>
|
||||
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true">
|
||||
<path d="M1 2.5l3 3 3-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{gridPopoverOpen && (
|
||||
<div
|
||||
|
||||
@@ -163,6 +163,25 @@ export function Transform3DCube({
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<SVGSVGElement>) => {
|
||||
const STEP = e.altKey ? 1 : 5;
|
||||
let next: CubePose | null = null;
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const dir = e.key === "ArrowUp" ? -1 : 1;
|
||||
next = e.shiftKey
|
||||
? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
|
||||
: { ...shown, rotationX: wrapDeg(shown.rotationX + dir * STEP) };
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
const dir = e.key === "ArrowRight" ? 1 : -1;
|
||||
next = e.shiftKey
|
||||
? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
|
||||
: { ...shown, rotationY: wrapDeg(shown.rotationY + dir * STEP) };
|
||||
}
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
onPoseCommit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
|
||||
<svg
|
||||
@@ -174,8 +193,10 @@ export function Transform3DCube({
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
role="slider"
|
||||
aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
|
||||
aria-label="3D rotation. Arrow keys rotate X/Y, Shift+arrows roll Z, Alt for fine steps; drag to rotate, scroll to change depth"
|
||||
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
|
||||
shown.rotationY,
|
||||
)}°, Z ${Math.round(shown.rotationZ)}°`}
|
||||
|
||||
@@ -322,11 +322,19 @@ export function PropertyPanel3dTransform({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400"
|
||||
aria-expanded={!collapsed}
|
||||
className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400 active:scale-[0.99]"
|
||||
>
|
||||
<span>3D Transform</span>
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
|
||||
{collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
|
||||
<svg
|
||||
width="9"
|
||||
height="9"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
className={`transition-transform duration-150 ${collapsed ? "-rotate-90" : ""}`}
|
||||
>
|
||||
<path d="M2 3l3 4 3-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
{collapsed ? null : (
|
||||
|
||||
@@ -268,6 +268,10 @@ export function ColorField({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Move focus into the picker on open and restore it on close so Escape
|
||||
// and keyboard editing work without a pointer round-trip.
|
||||
panelRef.current?.focus();
|
||||
const restoreTarget = buttonRef.current;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
@@ -279,6 +283,7 @@ export function ColorField({
|
||||
if (event.key === "Escape") {
|
||||
cancelColorGesture();
|
||||
setOpen(false);
|
||||
restoreTarget?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
@@ -320,7 +325,10 @@ export function ColorField({
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
|
||||
role="dialog"
|
||||
aria-label={`${label} color picker`}
|
||||
tabIndex={-1}
|
||||
className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50 outline-none"
|
||||
style={{
|
||||
left: panelPosition?.left ?? -9999,
|
||||
top: panelPosition?.top ?? -9999,
|
||||
|
||||
@@ -263,6 +263,8 @@ export function ColorGradingControls({
|
||||
const lutInputRef = useRef<HTMLInputElement>(null);
|
||||
const [lutOpen, setLutOpen] = useState(false);
|
||||
const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
|
||||
const [lutImporting, setLutImporting] = useState(false);
|
||||
const [lutImportError, setLutImportError] = useState<string | null>(null);
|
||||
const lutAssets = useMemo(
|
||||
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
|
||||
[assets],
|
||||
@@ -282,6 +284,9 @@ export function ColorGradingControls({
|
||||
const actions = createColorGradingActions(grading, onCommitColorGrading);
|
||||
|
||||
const applyPreset = (preset: string) => {
|
||||
// Pass the LUT through normalize; the preset's own adjust values are its
|
||||
// look (main's normalize already avoids carrying over grading.adjust,
|
||||
// which was the bug this PR originally fixed).
|
||||
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
|
||||
if (next) {
|
||||
track("select", "Preset");
|
||||
@@ -391,16 +396,21 @@ export function ColorGradingControls({
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!onImportAssets}
|
||||
disabled={!onImportAssets || lutImporting}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
lutInputRef.current?.click();
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Import .cube LUT"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={lutImporting ? "Importing…" : "Import .cube LUT"}
|
||||
aria-label="Import .cube LUT"
|
||||
aria-busy={lutImporting}
|
||||
>
|
||||
<Plus size={13} />
|
||||
{lutImporting ? (
|
||||
<span className="h-3 w-3 animate-spin rounded-full border border-panel-text-4 border-t-transparent motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Plus size={13} />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={lutInputRef}
|
||||
@@ -409,13 +419,24 @@ export function ColorGradingControls({
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void actions.importLut(event.currentTarget.files, onImportAssets, () =>
|
||||
track("button", "Import LUT"),
|
||||
);
|
||||
const files = event.currentTarget.files;
|
||||
setLutImporting(true);
|
||||
setLutImportError(null);
|
||||
void actions
|
||||
.importLut(files, onImportAssets, () => track("button", "Import LUT"))
|
||||
.catch(() =>
|
||||
setLutImportError("LUT import failed — check the .cube file and try again."),
|
||||
)
|
||||
.finally(() => setLutImporting(false));
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{lutImportError && (
|
||||
<div className="text-[10px] text-red-400" role="alert">
|
||||
{lutImportError}
|
||||
</div>
|
||||
)}
|
||||
{grading.lut && (
|
||||
<div className="grid gap-2">
|
||||
{selectedProjectLut && (
|
||||
|
||||
@@ -134,7 +134,15 @@ export function CommitField({
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
cancelGestureFromKeyEvent(event);
|
||||
if (cancelGestureFromKeyEvent(event)) return;
|
||||
// No gesture to cancel means the draft was typed. Escape abandons it
|
||||
// rather than leaving it for the blur-commit to write.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dirtyRef.current = false;
|
||||
setDraft(valueRef.current);
|
||||
if (liveCommit) onPreview?.(valueRef.current);
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
|
||||
@@ -91,6 +91,7 @@ export function ImageFillField({
|
||||
const track = useTrackDesignInput();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
|
||||
const selectedAsset = useMemo(
|
||||
() => resolveSelectedAsset(value, sourceFile, imageAssets),
|
||||
@@ -101,6 +102,7 @@ export function ImageFillField({
|
||||
const handleUpload = async (files: FileList | null) => {
|
||||
if (!files?.length || !onImportAssets) return;
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const uploaded = await onImportAssets(files);
|
||||
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
|
||||
@@ -108,6 +110,8 @@ export function ImageFillField({
|
||||
track("button", "Upload image");
|
||||
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
||||
}
|
||||
} catch {
|
||||
setUploadError("Upload failed — check the file and try again.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -144,6 +148,11 @@ export function ImageFillField({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<div className="text-[10px] text-red-400" role="alert">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
{imageAssets.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{selectedAsset && (
|
||||
@@ -262,11 +271,49 @@ export function GradientField({
|
||||
{parsed.stops.map((stop, index) => (
|
||||
<div
|
||||
key={`stop-preview-${index}`}
|
||||
className="absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
|
||||
role="slider"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={`Stop ${index + 1} position`}
|
||||
aria-valuenow={Math.round(stop.position)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled) return;
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
const step = event.shiftKey ? 10 : 1;
|
||||
const delta = event.key === "ArrowRight" ? step : -step;
|
||||
updateStop(index, {
|
||||
position: Math.max(0, Math.min(100, Math.round(stop.position + delta))),
|
||||
});
|
||||
}}
|
||||
className="absolute top-1/2 h-4 w-4 -translate-y-1/2 cursor-ew-resize rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)] outline-none focus-visible:ring-2 focus-visible:ring-studio-accent"
|
||||
style={{
|
||||
left: `calc(${stop.position}% - 8px)`,
|
||||
backgroundColor: stop.color,
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => {
|
||||
if (disabled) return;
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (disabled || !event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
const rect = previewRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0) return;
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(100, ((event.clientX - rect.left) / rect.width) * 100),
|
||||
);
|
||||
updateStop(index, { position: Math.round(next * 10) / 10 });
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -392,7 +439,8 @@ export function GradientField({
|
||||
type="button"
|
||||
disabled={disabled || parsed.stops.length >= 6}
|
||||
onClick={() => addStop()}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
|
||||
title={parsed.stops.length >= 6 ? "Maximum 6 stops" : "Add a gradient stop"}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white active:scale-[0.98] disabled:cursor-not-allowed disabled:text-neutral-600"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add stop
|
||||
|
||||
@@ -143,6 +143,7 @@ export function FontFamilyField({
|
||||
const fontInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [localFonts, setLocalFonts] = useState<string[]>([]);
|
||||
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
|
||||
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
|
||||
@@ -259,6 +260,8 @@ export function FontFamilyField({
|
||||
} else {
|
||||
setFontNotice("No supported font files were imported.");
|
||||
}
|
||||
} catch {
|
||||
setFontNotice("Font import failed — the files were not added. Try again.");
|
||||
} finally {
|
||||
setImportingFonts(false);
|
||||
}
|
||||
@@ -358,11 +361,17 @@ export function FontFamilyField({
|
||||
commitFontFamily(buildFontFamilyValue(imported.family));
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
return;
|
||||
} else {
|
||||
// Committing an un-imported family would render a silent fallback,
|
||||
// so surface the failure and keep the current font instead.
|
||||
setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
|
||||
}
|
||||
} catch {
|
||||
setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
|
||||
} finally {
|
||||
setImportingFonts(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (option.source === "Google") loadGoogleFontStylesheet(option.family);
|
||||
const imported = importedFonts.find(
|
||||
@@ -383,17 +392,39 @@ export function FontFamilyField({
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && filteredOptions[0]) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
commitFamily(filteredOptions[0]);
|
||||
if (filteredOptions.length === 0) return;
|
||||
const delta = e.key === "ArrowDown" ? 1 : -1;
|
||||
const next =
|
||||
activeIndex < 0 && delta === 1
|
||||
? 0
|
||||
: (activeIndex + delta + filteredOptions.length) % filteredOptions.length;
|
||||
setActiveIndex(next);
|
||||
document
|
||||
.querySelector(`[data-font-option-index="${next}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
const target = filteredOptions[activeIndex] ?? filteredOptions[0];
|
||||
if (e.key === "Enter" && target) {
|
||||
e.preventDefault();
|
||||
commitFamily(target);
|
||||
}
|
||||
}}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600"
|
||||
/>
|
||||
{canQueryLocalFonts && (
|
||||
@@ -437,19 +468,24 @@ export function FontFamilyField({
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
filteredOptions.map((option, index) => (
|
||||
<button
|
||||
key={`${option.source}-${option.family}`}
|
||||
type="button"
|
||||
data-font-option-index={index}
|
||||
onClick={() => commitFamily(option)}
|
||||
className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
|
||||
option.family === currentFamily
|
||||
? "bg-studio-accent/15 text-neutral-50"
|
||||
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
|
||||
index === activeIndex
|
||||
? "bg-neutral-800 text-neutral-50"
|
||||
: option.family === currentFamily
|
||||
? "bg-studio-accent/15 text-neutral-50"
|
||||
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate font-medium">{option.family}</span>
|
||||
<span className="truncate font-medium" style={{ fontFamily: `"${option.family}"` }}>
|
||||
{option.family}
|
||||
</span>
|
||||
{renderAliasFor(option.family) && (
|
||||
<span className="flex-shrink-0 text-[9px] text-neutral-500">
|
||||
→ {renderAliasFor(option.family)}
|
||||
|
||||
@@ -74,6 +74,8 @@ export function MetricField({
|
||||
onPointerDown: handleScrubPointerDown,
|
||||
onPointerMove: handleScrubPointerMove,
|
||||
onPointerUp: handleScrubPointerUp,
|
||||
onPointerCancel: handleScrubPointerUp,
|
||||
onLostPointerCapture: handleScrubPointerUp,
|
||||
} as const)
|
||||
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
|
||||
|
||||
@@ -180,6 +182,7 @@ export function SliderControl({
|
||||
step={step}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
aria-label={trackName}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setDraft(n);
|
||||
@@ -228,6 +231,7 @@ export function SegmentedControl({
|
||||
if (option.value !== value) track("segmented", trackName);
|
||||
onChange(option.value);
|
||||
}}
|
||||
aria-pressed={option.value === value}
|
||||
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
||||
option.value === value
|
||||
? "bg-panel-hover text-white"
|
||||
@@ -292,23 +296,15 @@ export function Section({
|
||||
defaultCollapsed?: boolean;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
const collapseIcon = collapsed ? (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className="flex-shrink-0 text-panel-text-5"
|
||||
>
|
||||
<path d="M6 2.5v7M2.5 6h7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : (
|
||||
const collapseIcon = (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
className="flex-shrink-0 text-panel-text-5"
|
||||
className={`flex-shrink-0 text-panel-text-5 transition-transform duration-150 ${
|
||||
collapsed ? "-rotate-90" : ""
|
||||
}`}
|
||||
>
|
||||
<path d="M2 3l3 4 3-4z" />
|
||||
</svg>
|
||||
@@ -322,6 +318,7 @@ export function Section({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
aria-expanded={!collapsed}
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
|
||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||
@@ -116,21 +116,40 @@ export function StyleSections({
|
||||
: "Solid";
|
||||
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
||||
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
||||
// Remember the last authored gradient so an exploratory Solid↔Gradient
|
||||
// toggle restores it instead of destroying it with the default gradient.
|
||||
const lastGradientRef = useRef<string | null>(
|
||||
backgroundImage.includes("gradient") ? backgroundImage : null,
|
||||
);
|
||||
|
||||
// The remembered gradient is per-element: reset on selection change so
|
||||
// element A's gradient can't leak onto element B via the fill-mode toggle.
|
||||
// (The effect below re-stores it when the new element has its own gradient.)
|
||||
useEffect(() => {
|
||||
lastGradientRef.current = null;
|
||||
}, [element.id, element.selector]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreferredFillMode(fillMode);
|
||||
if (backgroundImage.includes("gradient")) {
|
||||
lastGradientRef.current = backgroundImage;
|
||||
}
|
||||
}, [fillMode, element.id, element.selector, backgroundImage]);
|
||||
|
||||
const handleFillModeChange = (nextMode: string) => {
|
||||
setPreferredFillMode(nextMode);
|
||||
if (nextMode === "Solid") {
|
||||
if (backgroundImage.includes("gradient")) {
|
||||
lastGradientRef.current = backgroundImage;
|
||||
}
|
||||
onSetStyle("background-image", "none");
|
||||
return;
|
||||
}
|
||||
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
|
||||
onSetStyle(
|
||||
"background-image",
|
||||
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
||||
lastGradientRef.current ??
|
||||
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ export function createTransformCommitHandlers({
|
||||
await commitAnimatedTransformValue(
|
||||
axis,
|
||||
parsed,
|
||||
"Cannot edit position — animation callbacks not available",
|
||||
"This element's position can't be edited here yet — it is driven by its animation",
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -91,7 +91,7 @@ export function createTransformCommitHandlers({
|
||||
return;
|
||||
}
|
||||
if (hasGsapAnimation) {
|
||||
showToast?.("Cannot edit size — animation callbacks not available");
|
||||
showToast?.("This element's size can't be edited here yet — it is driven by its animation");
|
||||
return;
|
||||
}
|
||||
const current = readStudioBoxSize(element.element);
|
||||
@@ -118,7 +118,7 @@ export function createTransformCommitHandlers({
|
||||
await commitAnimatedTransformValue(
|
||||
"rotation",
|
||||
parsed,
|
||||
"Cannot edit rotation — animation callbacks not available",
|
||||
"This element's rotation can't be edited here yet — it is driven by its animation",
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -186,3 +186,15 @@ export function trackStudioFeedback(
|
||||
source: "studio",
|
||||
});
|
||||
}
|
||||
|
||||
export function trackBlockParamCommit(props: {
|
||||
tone: "saved" | "error" | "confirm";
|
||||
blockName: string;
|
||||
key: string;
|
||||
}): void {
|
||||
trackEvent("studio_block_param_commit", {
|
||||
tone: props.tone,
|
||||
block_name: props.blockName,
|
||||
param_key: props.key,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user