mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +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.
|
// require intrusive middleware changes beyond this PR's scope.
|
||||||
"minLines": 6,
|
"minLines": 6,
|
||||||
"ignore": [
|
"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
|
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
|
||||||
// rendering lifecycle while retaining provider-specific SDK, storage, and
|
// rendering lifecycle while retaining provider-specific SDK, storage, and
|
||||||
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
|
// 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/BlockParamsPanel.tsx",
|
||||||
"packages/studio/src/components/editor/DomEditOverlay.tsx",
|
"packages/studio/src/components/editor/DomEditOverlay.tsx",
|
||||||
"packages/studio/src/components/editor/FileTree.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/FileTreeNodes.tsx",
|
||||||
"packages/studio/src/components/editor/LayersPanel.tsx",
|
"packages/studio/src/components/editor/LayersPanel.tsx",
|
||||||
"packages/studio/src/components/editor/MotionPathNode.tsx",
|
"packages/studio/src/components/editor/MotionPathNode.tsx",
|
||||||
|
|||||||
@@ -197,7 +197,8 @@ export const AnimationCard = memo(function AnimationCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
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
|
<span
|
||||||
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
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}
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="12"
|
width="12"
|
||||||
@@ -87,13 +88,15 @@ export function PropertyRow({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isVisible}
|
||||||
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
|
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 }}
|
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
|
||||||
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
|
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
|
||||||
>
|
>
|
||||||
<span
|
<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={{
|
style={{
|
||||||
width: 12,
|
width: 12,
|
||||||
height: 12,
|
height: 12,
|
||||||
|
|||||||
@@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
|
|||||||
<span className={LABEL}>Arc Motion</span>
|
<span className={LABEL}>Arc Motion</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={Boolean(arcPath.enabled)}
|
||||||
|
aria-label="Arc motion"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={disabled}
|
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 }}
|
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
|
||||||
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
|
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
|
||||||
>
|
>
|
||||||
<span
|
<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={{
|
style={{
|
||||||
width: 12,
|
width: 12,
|
||||||
height: 12,
|
height: 12,
|
||||||
@@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
|
|||||||
<span className={LABEL}>Auto-Rotate</span>
|
<span className={LABEL}>Auto-Rotate</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={Boolean(arcPath.autoRotate)}
|
||||||
|
aria-label="Auto-rotate along path"
|
||||||
onClick={handleAutoRotate}
|
onClick={handleAutoRotate}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="relative rounded-full transition-all duration-150"
|
className="relative rounded-full transition-colors duration-200"
|
||||||
style={{
|
style={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 16,
|
height: 16,
|
||||||
@@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<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={{
|
style={{
|
||||||
width: 12,
|
width: 12,
|
||||||
height: 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 type { BlockParam } from "@hyperframes/core/registry";
|
||||||
|
import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
|
||||||
|
import { useStudioPlaybackContext } from "../../contexts/StudioContext";
|
||||||
|
import { trackBlockParamCommit } from "../../telemetry/events";
|
||||||
|
|
||||||
interface BlockParamsPanelProps {
|
interface BlockParamsPanelProps {
|
||||||
blockName: string;
|
blockName: string;
|
||||||
@@ -9,12 +12,17 @@ interface BlockParamsPanelProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CommitState = { tone: "idle" | "saving" | "saved" | "error"; message?: string };
|
||||||
|
|
||||||
export const BlockParamsPanel = memo(function BlockParamsPanel({
|
export const BlockParamsPanel = memo(function BlockParamsPanel({
|
||||||
|
blockName,
|
||||||
blockTitle,
|
blockTitle,
|
||||||
params,
|
params,
|
||||||
compositionPath: _compositionPath,
|
compositionPath,
|
||||||
onClose,
|
onClose,
|
||||||
}: BlockParamsPanelProps) {
|
}: BlockParamsPanelProps) {
|
||||||
|
const fileManager = useFileManagerContextOptional();
|
||||||
|
const { setRefreshKey } = useStudioPlaybackContext();
|
||||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||||
const initial: Record<string, string> = {};
|
const initial: Record<string, string> = {};
|
||||||
for (const p of params) {
|
for (const p of params) {
|
||||||
@@ -22,10 +30,107 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|||||||
}
|
}
|
||||||
return initial;
|
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) => {
|
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 }));
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -34,7 +139,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
@@ -56,14 +162,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|||||||
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
Parameters
|
Parameters
|
||||||
</div>
|
</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) => (
|
{params.map((param) => (
|
||||||
<ParamControl
|
<ParamControl
|
||||||
key={param.key}
|
key={param.key}
|
||||||
param={param}
|
param={param}
|
||||||
value={values[param.key] ?? param.default}
|
value={values[param.key] ?? param.default}
|
||||||
|
disabled={!fileManager}
|
||||||
onChange={(v) => handleChange(param.key, v)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -72,10 +202,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
|
|||||||
function ParamControl({
|
function ParamControl({
|
||||||
param,
|
param,
|
||||||
value,
|
value,
|
||||||
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
param: BlockParam;
|
param: BlockParam;
|
||||||
value: string;
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -87,14 +219,18 @@ function ParamControl({
|
|||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`${param.label} color`}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`${param.label} value`}
|
||||||
onChange={(e) => onChange(e.target.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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -107,8 +243,10 @@ function ParamControl({
|
|||||||
max={param.max ?? 100}
|
max={param.max ?? 100}
|
||||||
step={param.step ?? 1}
|
step={param.step ?? 1}
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={param.label}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
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>
|
<span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,16 +256,20 @@ function ParamControl({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={param.label}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
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 && (
|
{param.type === "select" && param.options && (
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={param.label}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
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) => (
|
{param.options.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
|
|||||||
@@ -79,34 +79,15 @@ export function BorderRadiusEditor({
|
|||||||
stroke="rgba(255,255,255,0.24)"
|
stroke="rgba(255,255,255,0.24)"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle cx={sTL} cy={sTL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
||||||
cx={sTL}
|
<circle cx={PREVIEW_W - sTR} cy={sTR} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
|
||||||
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
|
<circle
|
||||||
cx={PREVIEW_W - sBR}
|
cx={PREVIEW_W - sBR}
|
||||||
cy={PREVIEW_H - sBR}
|
cy={PREVIEW_H - sBR}
|
||||||
r={3}
|
r={3}
|
||||||
fill={linked ? "#3b82f6" : "#a78bfa"}
|
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>
|
</svg>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ export const FileTree = memo(function FileTree({
|
|||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [inlineInput, setInlineInput] = useState<InlineInputState | 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 [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||||
const dragSourceRef = useRef<string | null>(null);
|
const dragSourceRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -145,13 +150,23 @@ export const FileTree = memo(function FileTree({
|
|||||||
|
|
||||||
// ── Delete ──
|
// ── Delete ──
|
||||||
|
|
||||||
const handleDelete = useCallback((path: string) => {
|
const handleDelete = useCallback(
|
||||||
setDeleteTarget(path);
|
(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(() => {
|
const handleDeleteConfirm = useCallback(() => {
|
||||||
if (deleteTarget) {
|
if (deleteTarget) {
|
||||||
onDeleteFile?.(deleteTarget);
|
onDeleteFile?.(deleteTarget.path);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}
|
}
|
||||||
}, [deleteTarget, onDeleteFile]);
|
}, [deleteTarget, onDeleteFile]);
|
||||||
@@ -227,15 +242,17 @@ export const FileTree = memo(function FileTree({
|
|||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleNewFile("")}
|
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"
|
title="New File"
|
||||||
|
aria-label="New File"
|
||||||
>
|
>
|
||||||
<Plus size={12} weight="bold" />
|
<Plus size={12} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleNewFolder("")}
|
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"
|
title="New Folder"
|
||||||
|
aria-label="New Folder"
|
||||||
>
|
>
|
||||||
<FolderSimplePlus size={12} weight="duotone" />
|
<FolderSimplePlus size={12} weight="duotone" />
|
||||||
</button>
|
</button>
|
||||||
@@ -274,6 +291,11 @@ export const FileTree = memo(function FileTree({
|
|||||||
onCancel={() => inlineInput.onCancel?.()}
|
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) =>
|
{children.map((child) =>
|
||||||
child.isFile && child.children.size === 0 ? (
|
child.isFile && child.children.size === 0 ? (
|
||||||
<TreeFile
|
<TreeFile
|
||||||
@@ -308,15 +330,22 @@ export const FileTree = memo(function FileTree({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete confirmation overlay */}
|
{/* Delete confirmation — anchored near the row it was invoked on */}
|
||||||
{deleteTarget && (
|
{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
|
<DeleteConfirm
|
||||||
name={
|
name={
|
||||||
deleteTarget.includes("/")
|
deleteTarget.path.includes("/")
|
||||||
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
|
? deleteTarget.path.slice(deleteTarget.path.lastIndexOf("/") + 1)
|
||||||
: deleteTarget
|
: deleteTarget.path
|
||||||
}
|
}
|
||||||
|
isFolder={deleteTarget.isFolder}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
onCancel={handleDeleteCancel}
|
onCancel={handleDeleteCancel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,9 +43,14 @@ export function ContextMenu({
|
|||||||
onDelete: (path: string) => void;
|
onDelete: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
useEffect(() => {
|
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) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -59,9 +64,28 @@ export function ContextMenu({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
document.removeEventListener("keydown", handleEscape);
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
restoreFocusRef.current?.focus();
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [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 adjustedX = Math.min(state.x, window.innerWidth - 180);
|
||||||
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
||||||
|
|
||||||
@@ -74,13 +98,16 @@ export function ContextMenu({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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]"
|
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 }}
|
style={{ left: adjustedX, top: adjustedY }}
|
||||||
>
|
>
|
||||||
{state.targetIsFolder && (
|
{state.targetIsFolder && (
|
||||||
<>
|
<>
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
onNewFile(state.targetPath);
|
onNewFile(state.targetPath);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -90,7 +117,8 @@ export function ContextMenu({
|
|||||||
New File
|
New File
|
||||||
</button>
|
</button>
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
onNewFolder(state.targetPath);
|
onNewFolder(state.targetPath);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -105,7 +133,8 @@ export function ContextMenu({
|
|||||||
{!state.targetIsFolder && (
|
{!state.targetIsFolder && (
|
||||||
<>
|
<>
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
onNewFile(parentPath);
|
onNewFile(parentPath);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -118,7 +147,8 @@ export function ContextMenu({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
onRename(state.targetPath);
|
onRename(state.targetPath);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -129,7 +159,8 @@ export function ContextMenu({
|
|||||||
</button>
|
</button>
|
||||||
{!state.targetIsFolder && (
|
{!state.targetIsFolder && (
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
onDuplicate(state.targetPath);
|
onDuplicate(state.targetPath);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -141,7 +172,8 @@ export function ContextMenu({
|
|||||||
)}
|
)}
|
||||||
<div className="border-t border-neutral-700 my-1" />
|
<div className="border-t border-neutral-700 my-1" />
|
||||||
<button
|
<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={() => {
|
onClick={() => {
|
||||||
onDelete(state.targetPath);
|
onDelete(state.targetPath);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -172,6 +204,13 @@ export function InlineInput({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const committedRef = useRef(false);
|
const committedRef = useRef(false);
|
||||||
const [value, setValue] = useState(defaultValue);
|
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
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,8 +235,16 @@ export function InlineInput({
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
|
if (!trimmed) {
|
||||||
else onCancel();
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invalid = validate(trimmed);
|
||||||
|
if (invalid) {
|
||||||
|
setError(invalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commit(trimmed);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCancel();
|
onCancel();
|
||||||
@@ -206,8 +253,7 @@ export function InlineInput({
|
|||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
|
if (trimmed && trimmed !== defaultValue && !validate(trimmed)) commit(trimmed);
|
||||||
commit(trimmed);
|
|
||||||
else onCancel();
|
else onCancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,15 +267,28 @@ export function InlineInput({
|
|||||||
) : (
|
) : (
|
||||||
<FileIcon path={value} />
|
<FileIcon path={value} />
|
||||||
)}
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
if (error) setError(null);
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={handleBlur}
|
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]"
|
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}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-red-400" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,10 +297,12 @@ export function InlineInput({
|
|||||||
|
|
||||||
export function DeleteConfirm({
|
export function DeleteConfirm({
|
||||||
name,
|
name,
|
||||||
|
isFolder,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
|
isFolder?: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => 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"
|
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">
|
<p className="text-neutral-300 mb-2">
|
||||||
|
{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>?
|
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export function GsapAnimationList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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) => (
|
{animations.map((animation, index) => (
|
||||||
<AnimationCard
|
<AnimationCard
|
||||||
{...trackedCallbacks}
|
{...trackedCallbacks}
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick();
|
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 }}
|
style={{ color, opacity }}
|
||||||
title={title}
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
aria-pressed={state === "active"}
|
||||||
>
|
>
|
||||||
<svg width={size} height={size} viewBox="0 0 10 10">
|
<svg width={size} height={size} viewBox="0 0 10 10">
|
||||||
{isHold ? (
|
{isHold ? (
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ export function KeyframeEaseList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
|
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="text-[10px] font-medium text-neutral-400">{label}</span>
|
||||||
<span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
|
<span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
|
||||||
@@ -113,7 +114,7 @@ export function KeyframeEaseList({
|
|||||||
height="8"
|
height="8"
|
||||||
viewBox="0 0 10 10"
|
viewBox="0 0 10 10"
|
||||||
fill="currentColor"
|
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" />
|
<path d="M2 3l3 4 3-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!prevKf}
|
disabled={!prevKf}
|
||||||
onClick={() => prevKf && onSeek(prevKf.percentage)}
|
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} />
|
<ArrowLeft disabled={!prevKf} />
|
||||||
</button>
|
</button>
|
||||||
@@ -197,7 +199,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!nextKf}
|
disabled={!nextKf}
|
||||||
onClick={() => nextKf && onSeek(nextKf.percentage)}
|
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} />
|
<ArrowRight disabled={!nextKf} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -469,14 +469,23 @@ export const LayersPanel = memo(function LayersPanel() {
|
|||||||
: selected
|
: selected
|
||||||
? "bg-panel-accent/14 text-panel-accent"
|
? "bg-panel-accent/14 text-panel-accent"
|
||||||
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
|
: "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 }}
|
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 ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => toggleCollapse(layer.key, e)}
|
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
|
<svg
|
||||||
width="8"
|
width="8"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function MotionPathNode(props: {
|
|||||||
onPointerDown={props.onPointerDown}
|
onPointerDown={props.onPointerDown}
|
||||||
onPointerMove={props.onPointerMove}
|
onPointerMove={props.onPointerMove}
|
||||||
onPointerUp={props.onPointerUp}
|
onPointerUp={props.onPointerUp}
|
||||||
|
onPointerCancel={props.onPointerUp}
|
||||||
onContextMenu={props.onContextMenu}
|
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");
|
if (!xRow) throw new Error("expected an X row");
|
||||||
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
||||||
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
||||||
// The diamond button always carries a `title`; the two plain arrow
|
// At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
|
||||||
// buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev
|
// disabled (no earlier keyframe) and the next arrow seeks to the 50%
|
||||||
// arrow is disabled (no earlier keyframe) and the next arrow seeks to
|
// keyframe — exactly the case the coherence bug affected.
|
||||||
// the 50% keyframe — exactly the case the coherence bug affected.
|
|
||||||
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
|
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");
|
if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
|
||||||
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
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");
|
if (!xRow) throw new Error("expected an X row");
|
||||||
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
||||||
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
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");
|
if (!diamond) throw new Error("expected a keyframe diamond button");
|
||||||
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
|
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
|
||||||
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
|
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
|
||||||
|
|||||||
@@ -234,11 +234,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|||||||
|
|
||||||
const handleCopyElementInfo = () => {
|
const handleCopyElementInfo = () => {
|
||||||
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
|
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
|
||||||
void navigator.clipboard.writeText(text);
|
// 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");
|
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
|
||||||
setClipboardCopied(true);
|
setClipboardCopied(true);
|
||||||
clearTimeout(clipboardTimerRef.current);
|
clearTimeout(clipboardTimerRef.current);
|
||||||
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast("Couldn't copy to the clipboard — check browser permissions", "error");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|||||||
{motionPathCreateAvailable && (
|
{motionPathCreateAvailable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-md p-1.5 transition-colors ${
|
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
||||||
motionPathArmed
|
motionPathArmed
|
||||||
? "bg-studio-accent/20 text-studio-accent"
|
? "bg-studio-accent/20 text-studio-accent"
|
||||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
: "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
|
<button
|
||||||
type="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
|
prefs.snapEnabled
|
||||||
? "bg-studio-accent/20 text-studio-accent"
|
? "bg-studio-accent/20 text-studio-accent"
|
||||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
: "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
|
<button
|
||||||
ref={gridButtonRef}
|
ref={gridButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded-md p-1.5 transition-colors ${
|
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
||||||
prefs.gridVisible
|
prefs.gridVisible
|
||||||
? "bg-studio-accent/20 text-studio-accent"
|
? "bg-studio-accent/20 text-studio-accent"
|
||||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
: "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();
|
e.preventDefault();
|
||||||
setGridPopoverOpen((v) => !v);
|
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"
|
aria-label="Toggle grid"
|
||||||
>
|
>
|
||||||
<GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
|
<GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
|
||||||
</button>
|
</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 && (
|
{gridPopoverOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -163,6 +163,25 @@ export function Transform3DCube({
|
|||||||
setDraft(null);
|
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 (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
|
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
|
||||||
<svg
|
<svg
|
||||||
@@ -174,8 +193,10 @@ export function Transform3DCube({
|
|||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
tabIndex={0}
|
||||||
role="slider"
|
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(
|
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
|
||||||
shown.rotationY,
|
shown.rotationY,
|
||||||
)}°, Z ${Math.round(shown.rotationZ)}°`}
|
)}°, Z ${Math.round(shown.rotationZ)}°`}
|
||||||
|
|||||||
@@ -322,11 +322,19 @@ export function PropertyPanel3dTransform({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
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>
|
<span>3D Transform</span>
|
||||||
<svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
|
<svg
|
||||||
{collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{collapsed ? null : (
|
{collapsed ? null : (
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ export function ColorField({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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 handlePointerDown = (event: PointerEvent) => {
|
||||||
const target = event.target as Node | null;
|
const target = event.target as Node | null;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -279,6 +283,7 @@ export function ColorField({
|
|||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
cancelColorGesture();
|
cancelColorGesture();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
restoreTarget?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
@@ -320,7 +325,10 @@ export function ColorField({
|
|||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
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={{
|
style={{
|
||||||
left: panelPosition?.left ?? -9999,
|
left: panelPosition?.left ?? -9999,
|
||||||
top: panelPosition?.top ?? -9999,
|
top: panelPosition?.top ?? -9999,
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ export function ColorGradingControls({
|
|||||||
const lutInputRef = useRef<HTMLInputElement>(null);
|
const lutInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [lutOpen, setLutOpen] = useState(false);
|
const [lutOpen, setLutOpen] = useState(false);
|
||||||
const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
|
const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
|
||||||
|
const [lutImporting, setLutImporting] = useState(false);
|
||||||
|
const [lutImportError, setLutImportError] = useState<string | null>(null);
|
||||||
const lutAssets = useMemo(
|
const lutAssets = useMemo(
|
||||||
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
|
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
|
||||||
[assets],
|
[assets],
|
||||||
@@ -282,6 +284,9 @@ export function ColorGradingControls({
|
|||||||
const actions = createColorGradingActions(grading, onCommitColorGrading);
|
const actions = createColorGradingActions(grading, onCommitColorGrading);
|
||||||
|
|
||||||
const applyPreset = (preset: string) => {
|
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 });
|
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
|
||||||
if (next) {
|
if (next) {
|
||||||
track("select", "Preset");
|
track("select", "Preset");
|
||||||
@@ -391,16 +396,21 @@ export function ColorGradingControls({
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!onImportAssets}
|
disabled={!onImportAssets || lutImporting}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
lutInputRef.current?.click();
|
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"
|
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="Import .cube LUT"
|
title={lutImporting ? "Importing…" : "Import .cube LUT"}
|
||||||
aria-label="Import .cube LUT"
|
aria-label="Import .cube LUT"
|
||||||
|
aria-busy={lutImporting}
|
||||||
>
|
>
|
||||||
|
{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} />
|
<Plus size={13} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={lutInputRef}
|
ref={lutInputRef}
|
||||||
@@ -409,13 +419,24 @@ export function ColorGradingControls({
|
|||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
void actions.importLut(event.currentTarget.files, onImportAssets, () =>
|
const files = event.currentTarget.files;
|
||||||
track("button", "Import LUT"),
|
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 = "";
|
event.currentTarget.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{lutImportError && (
|
||||||
|
<div className="text-[10px] text-red-400" role="alert">
|
||||||
|
{lutImportError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{grading.lut && (
|
{grading.lut && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{selectedProjectLut && (
|
{selectedProjectLut && (
|
||||||
|
|||||||
@@ -134,7 +134,15 @@ export function CommitField({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function ImageFillField({
|
|||||||
const track = useTrackDesignInput();
|
const track = useTrackDesignInput();
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
|
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
|
||||||
const selectedAsset = useMemo(
|
const selectedAsset = useMemo(
|
||||||
() => resolveSelectedAsset(value, sourceFile, imageAssets),
|
() => resolveSelectedAsset(value, sourceFile, imageAssets),
|
||||||
@@ -101,6 +102,7 @@ export function ImageFillField({
|
|||||||
const handleUpload = async (files: FileList | null) => {
|
const handleUpload = async (files: FileList | null) => {
|
||||||
if (!files?.length || !onImportAssets) return;
|
if (!files?.length || !onImportAssets) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadError(null);
|
||||||
try {
|
try {
|
||||||
const uploaded = await onImportAssets(files);
|
const uploaded = await onImportAssets(files);
|
||||||
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
|
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
|
||||||
@@ -108,6 +110,8 @@ export function ImageFillField({
|
|||||||
track("button", "Upload image");
|
track("button", "Upload image");
|
||||||
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setUploadError("Upload failed — check the file and try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -144,6 +148,11 @@ export function ImageFillField({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{uploadError && (
|
||||||
|
<div className="text-[10px] text-red-400" role="alert">
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{imageAssets.length > 0 ? (
|
{imageAssets.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedAsset && (
|
{selectedAsset && (
|
||||||
@@ -262,11 +271,49 @@ export function GradientField({
|
|||||||
{parsed.stops.map((stop, index) => (
|
{parsed.stops.map((stop, index) => (
|
||||||
<div
|
<div
|
||||||
key={`stop-preview-${index}`}
|
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={{
|
style={{
|
||||||
left: `calc(${stop.position}% - 8px)`,
|
left: `calc(${stop.position}% - 8px)`,
|
||||||
backgroundColor: stop.color,
|
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>
|
</div>
|
||||||
@@ -392,7 +439,8 @@ export function GradientField({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || parsed.stops.length >= 6}
|
disabled={disabled || parsed.stops.length >= 6}
|
||||||
onClick={() => addStop()}
|
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} />
|
<Plus size={12} />
|
||||||
Add stop
|
Add stop
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export function FontFamilyField({
|
|||||||
const fontInputRef = useRef<HTMLInputElement | null>(null);
|
const fontInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
const [localFonts, setLocalFonts] = useState<string[]>([]);
|
const [localFonts, setLocalFonts] = useState<string[]>([]);
|
||||||
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
|
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
|
||||||
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
|
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
|
||||||
@@ -259,6 +260,8 @@ export function FontFamilyField({
|
|||||||
} else {
|
} else {
|
||||||
setFontNotice("No supported font files were imported.");
|
setFontNotice("No supported font files were imported.");
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setFontNotice("Font import failed — the files were not added. Try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setImportingFonts(false);
|
setImportingFonts(false);
|
||||||
}
|
}
|
||||||
@@ -358,11 +361,17 @@ export function FontFamilyField({
|
|||||||
commitFontFamily(buildFontFamilyValue(imported.family));
|
commitFontFamily(buildFontFamilyValue(imported.family));
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setOpen(false);
|
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 {
|
} finally {
|
||||||
setImportingFonts(false);
|
setImportingFonts(false);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (option.source === "Google") loadGoogleFontStylesheet(option.family);
|
if (option.source === "Google") loadGoogleFontStylesheet(option.family);
|
||||||
const imported = importedFonts.find(
|
const imported = importedFonts.find(
|
||||||
@@ -383,17 +392,39 @@ export function FontFamilyField({
|
|||||||
value={query}
|
value={query}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
|
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Enter" && filteredOptions[0]) {
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
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"
|
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 && (
|
{canQueryLocalFonts && (
|
||||||
@@ -437,19 +468,24 @@ export function FontFamilyField({
|
|||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
|
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
|
||||||
) : (
|
) : (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.map((option, index) => (
|
||||||
<button
|
<button
|
||||||
key={`${option.source}-${option.family}`}
|
key={`${option.source}-${option.family}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-font-option-index={index}
|
||||||
onClick={() => commitFamily(option)}
|
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 ${
|
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
|
index === activeIndex
|
||||||
|
? "bg-neutral-800 text-neutral-50"
|
||||||
|
: option.family === currentFamily
|
||||||
? "bg-studio-accent/15 text-neutral-50"
|
? "bg-studio-accent/15 text-neutral-50"
|
||||||
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
|
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<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) && (
|
{renderAliasFor(option.family) && (
|
||||||
<span className="flex-shrink-0 text-[9px] text-neutral-500">
|
<span className="flex-shrink-0 text-[9px] text-neutral-500">
|
||||||
→ {renderAliasFor(option.family)}
|
→ {renderAliasFor(option.family)}
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export function MetricField({
|
|||||||
onPointerDown: handleScrubPointerDown,
|
onPointerDown: handleScrubPointerDown,
|
||||||
onPointerMove: handleScrubPointerMove,
|
onPointerMove: handleScrubPointerMove,
|
||||||
onPointerUp: handleScrubPointerUp,
|
onPointerUp: handleScrubPointerUp,
|
||||||
|
onPointerCancel: handleScrubPointerUp,
|
||||||
|
onLostPointerCapture: handleScrubPointerUp,
|
||||||
} as const)
|
} as const)
|
||||||
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
|
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
|
||||||
|
|
||||||
@@ -180,6 +182,7 @@ export function SliderControl({
|
|||||||
step={step}
|
step={step}
|
||||||
value={draft}
|
value={draft}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={trackName}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const n = Number(e.target.value);
|
const n = Number(e.target.value);
|
||||||
setDraft(n);
|
setDraft(n);
|
||||||
@@ -228,6 +231,7 @@ export function SegmentedControl({
|
|||||||
if (option.value !== value) track("segmented", trackName);
|
if (option.value !== value) track("segmented", trackName);
|
||||||
onChange(option.value);
|
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 ${
|
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
||||||
option.value === value
|
option.value === value
|
||||||
? "bg-panel-hover text-white"
|
? "bg-panel-hover text-white"
|
||||||
@@ -292,23 +296,15 @@ export function Section({
|
|||||||
defaultCollapsed?: boolean;
|
defaultCollapsed?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||||
const collapseIcon = collapsed ? (
|
const collapseIcon = (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<svg
|
<svg
|
||||||
width="10"
|
width="10"
|
||||||
height="10"
|
height="10"
|
||||||
viewBox="0 0 10 10"
|
viewBox="0 0 10 10"
|
||||||
fill="currentColor"
|
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" />
|
<path d="M2 3l3 4 3-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -322,6 +318,7 @@ export function Section({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
|
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>
|
<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 { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
|
||||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||||
@@ -116,20 +116,39 @@ export function StyleSections({
|
|||||||
: "Solid";
|
: "Solid";
|
||||||
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
||||||
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
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(() => {
|
useEffect(() => {
|
||||||
setPreferredFillMode(fillMode);
|
setPreferredFillMode(fillMode);
|
||||||
|
if (backgroundImage.includes("gradient")) {
|
||||||
|
lastGradientRef.current = backgroundImage;
|
||||||
|
}
|
||||||
}, [fillMode, element.id, element.selector, backgroundImage]);
|
}, [fillMode, element.id, element.selector, backgroundImage]);
|
||||||
|
|
||||||
const handleFillModeChange = (nextMode: string) => {
|
const handleFillModeChange = (nextMode: string) => {
|
||||||
setPreferredFillMode(nextMode);
|
setPreferredFillMode(nextMode);
|
||||||
if (nextMode === "Solid") {
|
if (nextMode === "Solid") {
|
||||||
|
if (backgroundImage.includes("gradient")) {
|
||||||
|
lastGradientRef.current = backgroundImage;
|
||||||
|
}
|
||||||
onSetStyle("background-image", "none");
|
onSetStyle("background-image", "none");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
|
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
|
||||||
onSetStyle(
|
onSetStyle(
|
||||||
"background-image",
|
"background-image",
|
||||||
|
lastGradientRef.current ??
|
||||||
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function createTransformCommitHandlers({
|
|||||||
await commitAnimatedTransformValue(
|
await commitAnimatedTransformValue(
|
||||||
axis,
|
axis,
|
||||||
parsed,
|
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;
|
return;
|
||||||
@@ -91,7 +91,7 @@ export function createTransformCommitHandlers({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasGsapAnimation) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const current = readStudioBoxSize(element.element);
|
const current = readStudioBoxSize(element.element);
|
||||||
@@ -118,7 +118,7 @@ export function createTransformCommitHandlers({
|
|||||||
await commitAnimatedTransformValue(
|
await commitAnimatedTransformValue(
|
||||||
"rotation",
|
"rotation",
|
||||||
parsed,
|
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;
|
return;
|
||||||
|
|||||||
@@ -186,3 +186,15 @@ export function trackStudioFeedback(
|
|||||||
source: "studio",
|
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