@hyperframes/studio 0.7.60 → 0.7.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
- package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4392 -3314
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -15
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/StudioRightPanel.tsx +34 -26
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +50 -42
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +9 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +26 -12
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
- package/dist/assets/index-DXbu6IPT.css +0 -1
- package/dist/assets/index-cH6NfVV_.js +0 -426
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -12,16 +12,3 @@ export const STROKE_STYLE_OPTIONS: string[] = [
|
|
|
12
12
|
"inset",
|
|
13
13
|
"outset",
|
|
14
14
|
];
|
|
15
|
-
|
|
16
|
-
export function formatStrokeSummary(widthPx: number, style: string): string {
|
|
17
|
-
return `${widthPx}px ${style}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function parseStrokeSummary(text: string): { widthPx: number; style: string } | null {
|
|
21
|
-
const match = /^\s*(-?\d+(?:\.\d+)?)px\s+(\S+)\s*$/.exec(text);
|
|
22
|
-
if (!match) return null;
|
|
23
|
-
const widthPx = Number.parseFloat(match[1]);
|
|
24
|
-
const style = match[2];
|
|
25
|
-
if (!Number.isFinite(widthPx) || !style) return null;
|
|
26
|
-
return { widthPx, style };
|
|
27
|
-
}
|
|
@@ -175,31 +175,43 @@ function setInputValue(input: HTMLInputElement, nextValue: string) {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
describe("FlatStyleSection — Stroke and Radius", () => {
|
|
178
|
-
it("renders
|
|
178
|
+
it("renders a width-only Stroke width row — style is only ever set through the Stroke style select below it", () => {
|
|
179
179
|
const { host, root } = renderSection(STROKE_STYLES);
|
|
180
|
-
expect(host.textContent).toContain("Stroke");
|
|
181
|
-
expect(
|
|
180
|
+
expect(host.textContent).toContain("Stroke width");
|
|
181
|
+
expect(host.textContent).toContain("Stroke style");
|
|
182
|
+
expect(getFlatRowInput(host, "Stroke width").value).toBe("1px");
|
|
182
183
|
act(() => root.unmount());
|
|
183
184
|
});
|
|
184
185
|
|
|
185
|
-
it("commits
|
|
186
|
+
it("commits a new stroke width without touching the existing style", async () => {
|
|
186
187
|
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
|
187
|
-
await commitFlatRowInput(host, "Stroke", "2px
|
|
188
|
+
await commitFlatRowInput(host, "Stroke width", "2px");
|
|
188
189
|
expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px");
|
|
189
|
-
expect(onSetStyle).toHaveBeenCalledWith("border-style",
|
|
190
|
+
expect(onSetStyle).not.toHaveBeenCalledWith("border-style", expect.anything());
|
|
191
|
+
act(() => root.unmount());
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("defaults a from-zero width commit to solid, since a width with no style renders no visible border", async () => {
|
|
195
|
+
const { host, root, onSetStyle } = renderSection({
|
|
196
|
+
"border-width": "0px",
|
|
197
|
+
"border-style": "none",
|
|
198
|
+
"border-color": "rgba(255,255,255,.12)",
|
|
199
|
+
});
|
|
200
|
+
await commitFlatRowInput(host, "Stroke width", "3px");
|
|
201
|
+
expect(onSetStyle).toHaveBeenCalledWith("border-style", "solid");
|
|
190
202
|
act(() => root.unmount());
|
|
191
203
|
});
|
|
192
204
|
|
|
193
205
|
it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => {
|
|
194
206
|
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
|
195
|
-
await commitFlatRowInput(host, "Stroke", "9999px
|
|
207
|
+
await commitFlatRowInput(host, "Stroke width", "9999px");
|
|
196
208
|
expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px");
|
|
197
209
|
act(() => root.unmount());
|
|
198
210
|
});
|
|
199
211
|
|
|
200
|
-
it("
|
|
212
|
+
it("ignores an unparseable stroke width commit", async () => {
|
|
201
213
|
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
|
202
|
-
await commitFlatRowInput(host, "Stroke", "
|
|
214
|
+
await commitFlatRowInput(host, "Stroke width", "bogus");
|
|
203
215
|
expect(onSetStyle).not.toHaveBeenCalled();
|
|
204
216
|
act(() => root.unmount());
|
|
205
217
|
});
|
|
@@ -226,7 +238,11 @@ describe("FlatStyleSection — Stroke and Radius", () => {
|
|
|
226
238
|
(input) => !host.contains(input),
|
|
227
239
|
);
|
|
228
240
|
if (!hexInput) throw new Error("expected the color picker's hex input");
|
|
229
|
-
act(() =>
|
|
241
|
+
act(() => {
|
|
242
|
+
setInputValue(hexInput, "#112233");
|
|
243
|
+
hexInput.focus();
|
|
244
|
+
hexInput.blur();
|
|
245
|
+
});
|
|
230
246
|
expect(onSetStyle).toHaveBeenCalledWith("border-color", "rgb(17, 34, 51)");
|
|
231
247
|
act(() => root.unmount());
|
|
232
248
|
});
|
|
@@ -3,11 +3,7 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
|
4
4
|
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
|
5
5
|
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
|
6
|
-
import {
|
|
7
|
-
formatStrokeSummary,
|
|
8
|
-
parseStrokeSummary,
|
|
9
|
-
STROKE_STYLE_OPTIONS,
|
|
10
|
-
} from "./propertyPanelFlatStyleHelpers";
|
|
6
|
+
import { STROKE_STYLE_OPTIONS } from "./propertyPanelFlatStyleHelpers";
|
|
11
7
|
import {
|
|
12
8
|
buildBoxShadowPresetValue,
|
|
13
9
|
buildClipPathValue,
|
|
@@ -47,6 +43,7 @@ function FlatFillFields({
|
|
|
47
43
|
styles,
|
|
48
44
|
assets,
|
|
49
45
|
onSetStyle,
|
|
46
|
+
onPreviewStyle,
|
|
50
47
|
onImportAssets,
|
|
51
48
|
}: {
|
|
52
49
|
projectId: string;
|
|
@@ -54,6 +51,7 @@ function FlatFillFields({
|
|
|
54
51
|
styles: Record<string, string>;
|
|
55
52
|
assets: string[];
|
|
56
53
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
54
|
+
onPreviewStyle?: (prop: string, value: string) => void;
|
|
57
55
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
58
56
|
}) {
|
|
59
57
|
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
|
@@ -109,6 +107,7 @@ function FlatFillFields({
|
|
|
109
107
|
label="Color"
|
|
110
108
|
value={styles["background-color"] ?? "transparent"}
|
|
111
109
|
disabled={styleEditingDisabled}
|
|
110
|
+
onPreview={(next) => onPreviewStyle?.("background-color", next)}
|
|
112
111
|
onCommit={(next) => onSetStyle("background-color", next)}
|
|
113
112
|
/>
|
|
114
113
|
) : preferredFillMode === "Gradient" ? (
|
|
@@ -147,10 +146,9 @@ function FlatFillFields({
|
|
|
147
146
|
}
|
|
148
147
|
|
|
149
148
|
/* ------------------------------------------------------------------ */
|
|
150
|
-
/* Flat Stroke row —
|
|
149
|
+
/* Flat Stroke row — width (numeric), style (select), color */
|
|
151
150
|
/* ------------------------------------------------------------------ */
|
|
152
151
|
|
|
153
|
-
// fallow-ignore-next-line complexity
|
|
154
152
|
function FlatStrokeRow({
|
|
155
153
|
styles,
|
|
156
154
|
disabled,
|
|
@@ -167,52 +165,40 @@ function FlatStrokeRow({
|
|
|
167
165
|
const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
|
|
168
166
|
const borderColorValue =
|
|
169
167
|
styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
|
|
170
|
-
const
|
|
171
|
-
const tier = resolveValueTier(
|
|
172
|
-
styles["border-width"] != null || styles["border-style"] != null ? summary : undefined,
|
|
173
|
-
formatStrokeSummary(0, "none"),
|
|
174
|
-
);
|
|
168
|
+
const widthDisplay = formatPxMetricValue(borderWidthValue);
|
|
175
169
|
|
|
176
170
|
return (
|
|
177
171
|
<>
|
|
178
172
|
<FlatRow
|
|
179
|
-
label="Stroke"
|
|
180
|
-
value={
|
|
181
|
-
tier={
|
|
173
|
+
label="Stroke width"
|
|
174
|
+
value={widthDisplay}
|
|
175
|
+
tier={resolveValueTier(styles["border-width"], "0px")}
|
|
182
176
|
disabled={disabled}
|
|
183
177
|
onCommit={async (next) => {
|
|
184
|
-
const
|
|
185
|
-
if (!parsed) return;
|
|
186
|
-
if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return;
|
|
187
|
-
const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, {
|
|
178
|
+
const normalizedWidth = normalizePanelPxValue(next, {
|
|
188
179
|
min: 0,
|
|
189
180
|
max: 200,
|
|
190
181
|
fallback: borderWidthValue,
|
|
191
182
|
});
|
|
192
183
|
if (!normalizedWidth) return;
|
|
184
|
+
// buildStrokeWidthStyleUpdates already covers "a width typed in
|
|
185
|
+
// from 0 needs a style to actually render a visible border" —
|
|
186
|
+
// it only defaults to solid when the current style is none/hidden,
|
|
187
|
+
// never clobbering an already-chosen style (dashed, dotted, …).
|
|
193
188
|
for (const [property, value] of buildStrokeWidthStyleUpdates(
|
|
194
189
|
normalizedWidth,
|
|
195
|
-
|
|
190
|
+
borderStyleValue,
|
|
196
191
|
)) {
|
|
197
192
|
await onSetStyle(property, value);
|
|
198
193
|
}
|
|
199
|
-
for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) {
|
|
200
|
-
await onSetStyle(property, value);
|
|
201
|
-
}
|
|
202
194
|
}}
|
|
203
|
-
suffix={
|
|
204
|
-
<>
|
|
205
|
-
<span
|
|
206
|
-
className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
|
|
207
|
-
style={{ backgroundColor: borderColorValue }}
|
|
208
|
-
/>
|
|
209
|
-
<span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
|
|
210
|
-
</>
|
|
211
|
-
}
|
|
212
195
|
/>
|
|
213
196
|
<FlatSelectRow
|
|
214
197
|
label="Stroke style"
|
|
215
198
|
value={borderStyleValue}
|
|
199
|
+
// Valid border-style keywords — the ONLY way to set this, since a
|
|
200
|
+
// free-text field here would require typing an exact CSS keyword
|
|
201
|
+
// (e.g. "dashed") with no indication of which ones are valid.
|
|
216
202
|
options={STROKE_STYLE_OPTIONS}
|
|
217
203
|
tier={resolveValueTier(styles["border-style"], "none")}
|
|
218
204
|
disabled={disabled}
|
|
@@ -470,6 +456,7 @@ export function FlatStyleSection({
|
|
|
470
456
|
styles,
|
|
471
457
|
assets,
|
|
472
458
|
onSetStyle,
|
|
459
|
+
onPreviewStyle,
|
|
473
460
|
onImportAssets,
|
|
474
461
|
gsapBorderRadius,
|
|
475
462
|
}: {
|
|
@@ -478,6 +465,7 @@ export function FlatStyleSection({
|
|
|
478
465
|
styles: Record<string, string>;
|
|
479
466
|
assets: string[];
|
|
480
467
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
468
|
+
onPreviewStyle?: (prop: string, value: string) => void;
|
|
481
469
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
482
470
|
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
483
471
|
}) {
|
|
@@ -490,6 +478,7 @@ export function FlatStyleSection({
|
|
|
490
478
|
styles={styles}
|
|
491
479
|
assets={assets}
|
|
492
480
|
onSetStyle={onSetStyle}
|
|
481
|
+
onPreviewStyle={onPreviewStyle}
|
|
493
482
|
onImportAssets={onImportAssets}
|
|
494
483
|
/>
|
|
495
484
|
<FlatStrokeRow styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act, useState } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
+
import postcss from "postcss";
|
|
6
|
+
import tailwindcss from "tailwindcss";
|
|
5
7
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
8
|
import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection";
|
|
7
9
|
import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
|
|
@@ -256,8 +258,9 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
256
258
|
act(() => root.unmount());
|
|
257
259
|
});
|
|
258
260
|
|
|
259
|
-
it("
|
|
261
|
+
it("previews Size input immediately and persists once on blur", () => {
|
|
260
262
|
const onSetTextFieldStyle = vi.fn();
|
|
263
|
+
const onPreviewTextFieldStyle = vi.fn();
|
|
261
264
|
const { host, root } = renderInto(
|
|
262
265
|
<FlatTextSection
|
|
263
266
|
element={makeSingleFieldElement()}
|
|
@@ -265,6 +268,7 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
265
268
|
fontAssets={[]}
|
|
266
269
|
onSetText={vi.fn()}
|
|
267
270
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
271
|
+
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
|
268
272
|
onAddTextField={vi.fn()}
|
|
269
273
|
onRemoveTextField={vi.fn()}
|
|
270
274
|
/>,
|
|
@@ -275,6 +279,7 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
275
279
|
const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
|
|
276
280
|
if (!input) throw new Error("expected the Size row's input");
|
|
277
281
|
act(() => {
|
|
282
|
+
input.focus();
|
|
278
283
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
279
284
|
window.HTMLInputElement.prototype,
|
|
280
285
|
"value",
|
|
@@ -282,9 +287,10 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
282
287
|
nativeInputValueSetter?.call(input, "24px");
|
|
283
288
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
284
289
|
});
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
290
|
+
expect(onPreviewTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
|
|
291
|
+
expect(onSetTextFieldStyle).not.toHaveBeenCalled();
|
|
292
|
+
act(() => {
|
|
293
|
+
input.blur();
|
|
288
294
|
});
|
|
289
295
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
|
|
290
296
|
act(() => root.unmount());
|
|
@@ -462,4 +468,41 @@ describe("FlatTextSection — multi-field", () => {
|
|
|
462
468
|
|
|
463
469
|
act(() => root.unmount());
|
|
464
470
|
});
|
|
471
|
+
|
|
472
|
+
it("keeps Content expandable and grows it with multiline text", async () => {
|
|
473
|
+
const element = makeMultiFieldElement();
|
|
474
|
+
element.textFields[0].value = "First line\nSecond line\nThird line";
|
|
475
|
+
|
|
476
|
+
const host = document.createElement("div");
|
|
477
|
+
document.body.append(host);
|
|
478
|
+
const root = createRoot(host);
|
|
479
|
+
act(() => {
|
|
480
|
+
root.render(
|
|
481
|
+
<FlatTextSection
|
|
482
|
+
element={element}
|
|
483
|
+
styles={{}}
|
|
484
|
+
fontAssets={[]}
|
|
485
|
+
onSetText={vi.fn()}
|
|
486
|
+
onSetTextFieldStyle={vi.fn()}
|
|
487
|
+
onAddTextField={vi.fn()}
|
|
488
|
+
onRemoveTextField={vi.fn()}
|
|
489
|
+
/>,
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const contentTextarea = host.querySelector<HTMLTextAreaElement>("textarea");
|
|
494
|
+
expect(contentTextarea?.value).toBe("First line\nSecond line\nThird line");
|
|
495
|
+
expect(contentTextarea?.classList).toContain("resize-y");
|
|
496
|
+
expect(contentTextarea?.classList).toContain("overflow-y-auto");
|
|
497
|
+
|
|
498
|
+
const compiled = await postcss([
|
|
499
|
+
tailwindcss({
|
|
500
|
+
content: [{ raw: host.innerHTML, extension: "html" }],
|
|
501
|
+
corePlugins: { preflight: false },
|
|
502
|
+
}),
|
|
503
|
+
]).process("@tailwind utilities;", { from: undefined });
|
|
504
|
+
expect(compiled.css).toContain("field-sizing: content");
|
|
505
|
+
|
|
506
|
+
act(() => root.unmount());
|
|
507
|
+
});
|
|
465
508
|
});
|
|
@@ -47,6 +47,7 @@ function FlatTextFieldEditor({
|
|
|
47
47
|
onImportFonts,
|
|
48
48
|
onSetText,
|
|
49
49
|
onSetTextFieldStyle,
|
|
50
|
+
onPreviewTextFieldStyle,
|
|
50
51
|
autoFocus = false,
|
|
51
52
|
}: {
|
|
52
53
|
field: DomEditSelection["textFields"][number];
|
|
@@ -55,6 +56,7 @@ function FlatTextFieldEditor({
|
|
|
55
56
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
56
57
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
57
58
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
59
|
+
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
58
60
|
autoFocus?: boolean;
|
|
59
61
|
}) {
|
|
60
62
|
const track = useTrackDesignInput();
|
|
@@ -100,6 +102,7 @@ function FlatTextFieldEditor({
|
|
|
100
102
|
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
|
101
103
|
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
|
|
102
104
|
liveCommit
|
|
105
|
+
onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "font-size", next)}
|
|
103
106
|
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
|
|
104
107
|
/>
|
|
105
108
|
<div className="flex min-h-[30px] items-center justify-between">
|
|
@@ -220,6 +223,7 @@ function FlatTextFieldEditor({
|
|
|
220
223
|
flat
|
|
221
224
|
label="Color"
|
|
222
225
|
value={value ?? getTextFieldColor(field, styles)}
|
|
226
|
+
onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "color", next)}
|
|
223
227
|
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
|
|
224
228
|
/>
|
|
225
229
|
)}
|
|
@@ -235,6 +239,7 @@ export function FlatTextSection({
|
|
|
235
239
|
onImportFonts,
|
|
236
240
|
onSetText,
|
|
237
241
|
onSetTextFieldStyle,
|
|
242
|
+
onPreviewTextFieldStyle,
|
|
238
243
|
onAddTextField,
|
|
239
244
|
onRemoveTextField,
|
|
240
245
|
}: {
|
|
@@ -244,6 +249,7 @@ export function FlatTextSection({
|
|
|
244
249
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
245
250
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
246
251
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
252
|
+
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
247
253
|
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
248
254
|
onRemoveTextField: (fieldKey: string) => void;
|
|
249
255
|
}) {
|
|
@@ -267,7 +273,7 @@ export function FlatTextSection({
|
|
|
267
273
|
|
|
268
274
|
if (textFields.length > 1) {
|
|
269
275
|
return (
|
|
270
|
-
<div className="space-y-
|
|
276
|
+
<div className="space-y-2.5">
|
|
271
277
|
<FlatTextLayerList
|
|
272
278
|
fields={textFields}
|
|
273
279
|
activeFieldKey={activeField.key}
|
|
@@ -288,6 +294,7 @@ export function FlatTextSection({
|
|
|
288
294
|
onImportFonts={onImportFonts}
|
|
289
295
|
onSetText={onSetText}
|
|
290
296
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
297
|
+
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
|
291
298
|
autoFocus
|
|
292
299
|
/>
|
|
293
300
|
</div>
|
|
@@ -295,7 +302,7 @@ export function FlatTextSection({
|
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
return (
|
|
298
|
-
<div className="space-y-
|
|
305
|
+
<div className="space-y-2.5">
|
|
299
306
|
<FlatTextFieldEditor
|
|
300
307
|
field={activeField}
|
|
301
308
|
styles={styles}
|
|
@@ -303,6 +310,7 @@ export function FlatTextSection({
|
|
|
303
310
|
onImportFonts={onImportFonts}
|
|
304
311
|
onSetText={onSetText}
|
|
305
312
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
313
|
+
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
|
306
314
|
/>
|
|
307
315
|
<button
|
|
308
316
|
type="button"
|
|
@@ -134,6 +134,13 @@ describe("classic property-panel primitive telemetry", () => {
|
|
|
134
134
|
const input = host.querySelector("input");
|
|
135
135
|
if (!input) throw new Error("expected metric input");
|
|
136
136
|
|
|
137
|
+
// Regression guard for CommitField's shared `align` default: the classic
|
|
138
|
+
// panel lays out label-then-value inline, where left-aligned reads
|
|
139
|
+
// naturally — this must stay left even though the flat inspector's
|
|
140
|
+
// FlatRow now opts into `align="right"` for its own justify-between rows.
|
|
141
|
+
expect(input.className).toContain("text-left");
|
|
142
|
+
expect(input.className).not.toContain("text-right");
|
|
143
|
+
|
|
137
144
|
act(() => blurInput(input));
|
|
138
145
|
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
139
146
|
|
|
@@ -349,7 +356,10 @@ describe.each(["classic", "flat"] as const)("shared %s input telemetry", (ui) =>
|
|
|
349
356
|
(input) => input.value === "#FF0000",
|
|
350
357
|
);
|
|
351
358
|
if (!hex) throw new Error("expected color hex input");
|
|
352
|
-
act(() =>
|
|
359
|
+
act(() => {
|
|
360
|
+
changeInput(hex, "#00FF00");
|
|
361
|
+
blurInput(hex);
|
|
362
|
+
});
|
|
353
363
|
|
|
354
364
|
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
|
355
365
|
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
|
|
@@ -448,6 +458,13 @@ function representativeElement() {
|
|
|
448
458
|
|
|
449
459
|
describe("classic PropertyPanel input coverage", () => {
|
|
450
460
|
it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
|
|
461
|
+
vi.resetModules();
|
|
462
|
+
vi.doMock("./manualEditingAvailability", async () => {
|
|
463
|
+
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
|
|
464
|
+
"./manualEditingAvailability",
|
|
465
|
+
);
|
|
466
|
+
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
|
|
467
|
+
});
|
|
451
468
|
const { PropertyPanel } = await import("./PropertyPanel");
|
|
452
469
|
const host = render(
|
|
453
470
|
<PropertyPanel
|
|
@@ -3,99 +3,10 @@ import {
|
|
|
3
3
|
DesignPanelInputProvider,
|
|
4
4
|
useTrackDesignInput,
|
|
5
5
|
} from "../../contexts/DesignPanelInputContext";
|
|
6
|
-
import {
|
|
6
|
+
import { FIELD, LABEL } from "./propertyPanelHelpers";
|
|
7
|
+
import { CommitField } from "./propertyPanelCommitField";
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
-
value,
|
|
10
|
-
disabled,
|
|
11
|
-
liveCommit,
|
|
12
|
-
onCommit,
|
|
13
|
-
}: {
|
|
14
|
-
value: string;
|
|
15
|
-
disabled?: boolean;
|
|
16
|
-
liveCommit?: boolean;
|
|
17
|
-
onCommit: (nextValue: string) => void;
|
|
18
|
-
}) {
|
|
19
|
-
const [draft, setDraft] = useState(value);
|
|
20
|
-
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
21
|
-
const valueRef = useRef(value);
|
|
22
|
-
const draftRef = useRef(draft);
|
|
23
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
|
-
|
|
25
|
-
valueRef.current = value;
|
|
26
|
-
draftRef.current = draft;
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
setDraft(value);
|
|
30
|
-
}, [value]);
|
|
31
|
-
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
const el = inputRef.current;
|
|
34
|
-
if (!el) return;
|
|
35
|
-
const handler = (e: WheelEvent) => {
|
|
36
|
-
if (disabled || document.activeElement !== el) return;
|
|
37
|
-
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
|
|
38
|
-
if (delta === 0) return;
|
|
39
|
-
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
|
|
40
|
-
if (!nextDraft) return;
|
|
41
|
-
e.preventDefault();
|
|
42
|
-
e.stopPropagation();
|
|
43
|
-
setDraft(nextDraft);
|
|
44
|
-
scheduleCommitRef.current(nextDraft);
|
|
45
|
-
};
|
|
46
|
-
el.addEventListener("wheel", handler, { passive: false });
|
|
47
|
-
return () => el.removeEventListener("wheel", handler);
|
|
48
|
-
}, [disabled]);
|
|
49
|
-
|
|
50
|
-
useEffect(
|
|
51
|
-
() => () => {
|
|
52
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
53
|
-
},
|
|
54
|
-
[],
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
const commitDraft = (nextDraft: string) => {
|
|
58
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
59
|
-
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const scheduleCommit = (nextDraft: string) => {
|
|
63
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
64
|
-
commitTimerRef.current = setTimeout(() => {
|
|
65
|
-
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
66
|
-
}, 120);
|
|
67
|
-
};
|
|
68
|
-
const scheduleCommitRef = useRef(scheduleCommit);
|
|
69
|
-
scheduleCommitRef.current = scheduleCommit;
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<input
|
|
73
|
-
ref={inputRef}
|
|
74
|
-
type="text"
|
|
75
|
-
value={draft}
|
|
76
|
-
disabled={disabled}
|
|
77
|
-
onChange={(e) => {
|
|
78
|
-
setDraft(e.target.value);
|
|
79
|
-
if (liveCommit) scheduleCommit(e.target.value);
|
|
80
|
-
}}
|
|
81
|
-
onBlur={() => commitDraft(draft)}
|
|
82
|
-
onKeyDown={(e) => {
|
|
83
|
-
if (e.key === "Enter") {
|
|
84
|
-
(e.target as HTMLInputElement).blur();
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
88
|
-
const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
|
|
89
|
-
if (!nextDraft) return;
|
|
90
|
-
e.preventDefault();
|
|
91
|
-
setDraft(nextDraft);
|
|
92
|
-
scheduleCommit(nextDraft);
|
|
93
|
-
}}
|
|
94
|
-
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
|
|
95
|
-
className="min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
96
|
-
/>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
9
|
+
export { CommitField } from "./propertyPanelCommitField";
|
|
99
10
|
|
|
100
11
|
/* ------------------------------------------------------------------ */
|
|
101
12
|
/* MetricField */
|
|
@@ -147,7 +147,7 @@ export function TextAreaField({
|
|
|
147
147
|
onFocus={handleFocus}
|
|
148
148
|
onChange={handleChange}
|
|
149
149
|
onBlur={handleBlur}
|
|
150
|
-
className="w-full resize-
|
|
150
|
+
className="[field-sizing:content] max-h-[40vh] min-h-12 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4"
|
|
151
151
|
/>
|
|
152
152
|
</div>
|
|
153
153
|
);
|
|
@@ -165,7 +165,7 @@ export function TextAreaField({
|
|
|
165
165
|
onFocus={handleFocus}
|
|
166
166
|
onChange={handleChange}
|
|
167
167
|
onBlur={handleBlur}
|
|
168
|
-
className="w-full resize-
|
|
168
|
+
className="[field-sizing:content] max-h-[40vh] min-h-20 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
169
169
|
/>
|
|
170
170
|
</div>
|
|
171
171
|
</label>
|
|
@@ -32,6 +32,7 @@ export interface PropertyPanelProps {
|
|
|
32
32
|
onClearSelection: () => void;
|
|
33
33
|
onUngroup?: () => void;
|
|
34
34
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
35
|
+
onPreviewStyle?: (prop: string, value: string) => void;
|
|
35
36
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
36
37
|
/** Commits several data-* attributes on the SAME element in ONE atomic
|
|
37
38
|
* persist call — e.g. a pinned timing range's start+duration together, so
|
|
@@ -62,6 +63,7 @@ export interface PropertyPanelProps {
|
|
|
62
63
|
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
|
|
63
64
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
64
65
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
66
|
+
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
65
67
|
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
66
68
|
onRemoveTextField: (fieldKey: string) => void;
|
|
67
69
|
onAskAgent: () => void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
describe("useInspectorGestureTransaction", () => {
|
|
11
|
+
it("keeps a new gesture active when the prior async commit is acknowledged", () => {
|
|
12
|
+
const host = document.createElement("div");
|
|
13
|
+
const root = createRoot(host);
|
|
14
|
+
const onPreview = vi.fn();
|
|
15
|
+
const onCommit = vi.fn();
|
|
16
|
+
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
|
|
17
|
+
|
|
18
|
+
function Probe({ sourceValue }: { sourceValue: number }) {
|
|
19
|
+
gesture = useInspectorGestureTransaction({ sourceValue, onPreview, onCommit });
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
act(() => root.render(<Probe sourceValue={10} />));
|
|
24
|
+
act(() => {
|
|
25
|
+
gesture?.preview(20);
|
|
26
|
+
gesture?.settle();
|
|
27
|
+
gesture?.preview(30);
|
|
28
|
+
});
|
|
29
|
+
act(() => root.render(<Probe sourceValue={20} />));
|
|
30
|
+
|
|
31
|
+
expect(onPreview).toHaveBeenLastCalledWith(30);
|
|
32
|
+
act(() => gesture?.settle());
|
|
33
|
+
expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
|
|
34
|
+
|
|
35
|
+
act(() => root.unmount());
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/** One owner for continuous inspector edits: preview freely, persist once. */
|
|
4
|
+
export function useInspectorGestureTransaction<T>({
|
|
5
|
+
sourceValue,
|
|
6
|
+
onPreview,
|
|
7
|
+
onCommit,
|
|
8
|
+
}: {
|
|
9
|
+
sourceValue: T;
|
|
10
|
+
onPreview: (value: T) => void;
|
|
11
|
+
onCommit: (value: T) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const sourceRef = useRef(sourceValue);
|
|
14
|
+
const activeRef = useRef<{ before: T; latest: T } | null>(null);
|
|
15
|
+
const previewRef = useRef(onPreview);
|
|
16
|
+
const commitRef = useRef(onCommit);
|
|
17
|
+
if (!activeRef.current) sourceRef.current = sourceValue;
|
|
18
|
+
previewRef.current = onPreview;
|
|
19
|
+
commitRef.current = onCommit;
|
|
20
|
+
|
|
21
|
+
const begin = useCallback(() => {
|
|
22
|
+
if (!activeRef.current) {
|
|
23
|
+
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
|
|
24
|
+
}
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const preview = useCallback((value: T) => {
|
|
28
|
+
if (!activeRef.current) {
|
|
29
|
+
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
|
|
30
|
+
}
|
|
31
|
+
activeRef.current.latest = value;
|
|
32
|
+
previewRef.current(value);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const settle = useCallback(() => {
|
|
36
|
+
const active = activeRef.current;
|
|
37
|
+
activeRef.current = null;
|
|
38
|
+
if (active && !Object.is(active.before, active.latest)) {
|
|
39
|
+
sourceRef.current = active.latest;
|
|
40
|
+
// Restore the captured baseline before the persistent commit captures
|
|
41
|
+
// rollback state. The commit reapplies `latest` synchronously, so this
|
|
42
|
+
// is not visible but a failed save can now correctly restore `before`.
|
|
43
|
+
previewRef.current(active.before);
|
|
44
|
+
commitRef.current(active.latest);
|
|
45
|
+
}
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const cancel = useCallback(() => {
|
|
49
|
+
const active = activeRef.current;
|
|
50
|
+
activeRef.current = null;
|
|
51
|
+
if (active && !Object.is(active.before, active.latest)) {
|
|
52
|
+
sourceRef.current = active.before;
|
|
53
|
+
previewRef.current(active.before);
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => cancel, [cancel]);
|
|
58
|
+
|
|
59
|
+
return { begin, preview, settle, cancel, activeRef };
|
|
60
|
+
}
|