@hyperframes/studio 0.7.38 → 0.7.39
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/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-ClUipc8i.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B9YvRJz1.js} +194 -194
- package/dist/assets/{index-Ykq7ihge.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.html +2 -2
- package/dist/index.js +2171 -1458
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +7 -6
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +6 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +9 -30
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelHelpers.ts +10 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
|
-
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
2
|
import { type DomEditSelection } from "./domEditing";
|
|
4
3
|
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
|
5
4
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
@@ -21,6 +20,12 @@ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures
|
|
|
21
20
|
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
|
22
21
|
import { GridOverlay } from "./GridOverlay";
|
|
23
22
|
import type { GestureRecordingState } from "./GestureRecordControl";
|
|
23
|
+
import { DomEditCropHandles } from "./DomEditCropHandles";
|
|
24
|
+
import { DomEditRotateHandle } from "./DomEditRotateHandle";
|
|
25
|
+
import { hugRectForElement } from "./domEditOverlayCrop";
|
|
26
|
+
import { useCropOverlay } from "../../hooks/useCropMode";
|
|
27
|
+
import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
|
|
28
|
+
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
24
29
|
|
|
25
30
|
// Re-exports for external consumers — preserving existing import paths.
|
|
26
31
|
export {
|
|
@@ -69,6 +74,9 @@ interface DomEditOverlayProps {
|
|
|
69
74
|
next: { width: number; height: number },
|
|
70
75
|
) => Promise<void> | void;
|
|
71
76
|
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
|
77
|
+
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
|
78
|
+
cropMode?: boolean;
|
|
79
|
+
onCropModeChange?: (active: boolean) => void;
|
|
72
80
|
gridVisible?: boolean;
|
|
73
81
|
gridSpacing?: number;
|
|
74
82
|
recordingState?: GestureRecordingState;
|
|
@@ -96,6 +104,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
96
104
|
onGroupPathOffsetCommit,
|
|
97
105
|
onBoxSizeCommit,
|
|
98
106
|
onRotationCommit,
|
|
107
|
+
onStyleCommit,
|
|
108
|
+
cropMode = false,
|
|
109
|
+
onCropModeChange,
|
|
99
110
|
onMarqueeSelect,
|
|
100
111
|
}: DomEditOverlayProps) {
|
|
101
112
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -103,29 +114,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
103
114
|
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
|
104
115
|
onMarqueeSelectRef.current = onMarqueeSelect;
|
|
105
116
|
|
|
106
|
-
|
|
107
|
-
const selectionShapeStyles = (() => {
|
|
108
|
-
const fallback = {
|
|
109
|
-
borderRadius: 8 as string | number,
|
|
110
|
-
clipPath: undefined as string | undefined,
|
|
111
|
-
};
|
|
112
|
-
if (!selection?.element) return fallback;
|
|
113
|
-
try {
|
|
114
|
-
const tag = selection.element.tagName.toLowerCase();
|
|
115
|
-
if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
|
|
116
|
-
const win = selection.element.ownerDocument.defaultView;
|
|
117
|
-
if (!win) return fallback;
|
|
118
|
-
const cs = win.getComputedStyle(selection.element);
|
|
119
|
-
const br = cs.borderRadius;
|
|
120
|
-
const cp = cs.clipPath;
|
|
121
|
-
return {
|
|
122
|
-
borderRadius: br && br !== "0px" ? br : 4,
|
|
123
|
-
clipPath: cp && cp !== "none" ? cp : undefined,
|
|
124
|
-
};
|
|
125
|
-
} catch {
|
|
126
|
-
return fallback;
|
|
127
|
-
}
|
|
128
|
-
})();
|
|
117
|
+
const selectionShapeStyles = readDomEditSelectionShapeStyles(selection);
|
|
129
118
|
const gestureRef = useRef<GestureState | null>(null);
|
|
130
119
|
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
|
131
120
|
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
|
@@ -151,6 +140,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
151
140
|
onBoxSizeCommitRef.current = onBoxSizeCommit;
|
|
152
141
|
const onRotationCommitRef = useRef(onRotationCommit);
|
|
153
142
|
onRotationCommitRef.current = onRotationCommit;
|
|
143
|
+
const onStyleCommitRef = useRef(onStyleCommit);
|
|
144
|
+
onStyleCommitRef.current = onStyleCommit;
|
|
154
145
|
const onBlockedMoveRef = useRef(onBlockedMove);
|
|
155
146
|
onBlockedMoveRef.current = onBlockedMove;
|
|
156
147
|
const onManualDragStartRef = useRef(onManualDragStart);
|
|
@@ -181,49 +172,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
181
172
|
rafPausedRef,
|
|
182
173
|
});
|
|
183
174
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
useMountEffect(() => {
|
|
193
|
-
let frame = 0;
|
|
194
|
-
// fallow-ignore-next-line complexity
|
|
195
|
-
const update = () => {
|
|
196
|
-
frame = requestAnimationFrame(update);
|
|
197
|
-
const iframe = iframeRef.current;
|
|
198
|
-
const overlayEl = overlayRef.current;
|
|
199
|
-
if (!iframe || !overlayEl) return;
|
|
200
|
-
const iRect = iframe.getBoundingClientRect();
|
|
201
|
-
const oRect = overlayEl.getBoundingClientRect();
|
|
202
|
-
const left = iRect.left - oRect.left;
|
|
203
|
-
const top = iRect.top - oRect.top;
|
|
204
|
-
if (iRect.width <= 0 || iRect.height <= 0) return;
|
|
205
|
-
const doc = iframe.contentDocument;
|
|
206
|
-
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
|
|
207
|
-
const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
|
|
208
|
-
const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
|
|
209
|
-
const scaleX = dw > 0 ? iRect.width / dw : 1;
|
|
210
|
-
const scaleY = dh > 0 ? iRect.height / dh : 1;
|
|
211
|
-
setCompRect((prev) => {
|
|
212
|
-
if (
|
|
213
|
-
Math.abs(prev.left - left) < 0.5 &&
|
|
214
|
-
Math.abs(prev.top - top) < 0.5 &&
|
|
215
|
-
Math.abs(prev.width - iRect.width) < 0.5 &&
|
|
216
|
-
Math.abs(prev.height - iRect.height) < 0.5 &&
|
|
217
|
-
Math.abs(prev.scaleX - scaleX) < 0.001 &&
|
|
218
|
-
Math.abs(prev.scaleY - scaleY) < 0.001
|
|
219
|
-
)
|
|
220
|
-
return prev;
|
|
221
|
-
return { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
|
|
222
|
-
});
|
|
223
|
-
};
|
|
224
|
-
frame = requestAnimationFrame(update);
|
|
225
|
-
return () => cancelAnimationFrame(frame);
|
|
175
|
+
const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
|
|
176
|
+
|
|
177
|
+
const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
|
|
178
|
+
selection,
|
|
179
|
+
groupCount: groupSelections.length,
|
|
180
|
+
cropMode,
|
|
181
|
+
onCropModeChange,
|
|
182
|
+
overlayRect,
|
|
226
183
|
});
|
|
184
|
+
// Inset crops draw their own outline child; other clip shapes keep the raw mirror.
|
|
185
|
+
const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
|
|
186
|
+
const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
|
|
227
187
|
|
|
228
188
|
// Off-canvas element indicators — dashed outlines for elements positioned
|
|
229
189
|
// outside the composition bounds so users can find them.
|
|
@@ -251,8 +211,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
251
211
|
if (!isElementComputedVisible(item.element)) continue;
|
|
252
212
|
// Groups use their members' union (where they actually render), so a group
|
|
253
213
|
// whose members sit inside the canvas isn't flagged off-canvas by a stale
|
|
254
|
-
// wrapper box.
|
|
255
|
-
|
|
214
|
+
// wrapper box. Crop-hug the result so an inset crop that keeps the visible
|
|
215
|
+
// part on-canvas doesn't flag the element either.
|
|
216
|
+
const base = groupAwareOverlayRect(overlay, iframe, item.element);
|
|
217
|
+
const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
|
|
256
218
|
if (!r) continue;
|
|
257
219
|
// Any edge crossing the composition border → gray-zone indicator (the
|
|
258
220
|
// in-canvas portion is clipped away below, so only the sliver shows).
|
|
@@ -325,6 +287,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
325
287
|
|
|
326
288
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
327
289
|
if (!allowCanvasMovement) return;
|
|
290
|
+
if (cropMode) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
event.stopPropagation();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
328
295
|
if (suppressNextOverlayMouseDownRef.current) {
|
|
329
296
|
suppressNextOverlayMouseDownRef.current = false;
|
|
330
297
|
suppressNextBoxMouseDownRef.current = false;
|
|
@@ -348,6 +315,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
348
315
|
// fallow-ignore-next-line complexity
|
|
349
316
|
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
350
317
|
if (!allowCanvasMovement || event.button !== 0) return;
|
|
318
|
+
if (cropMode) {
|
|
319
|
+
// Reaching here = click outside the element (crop UI swallows its own) — exit crop mode.
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
event.stopPropagation();
|
|
322
|
+
onCropModeChange?.(false);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
351
325
|
if (event.shiftKey) {
|
|
352
326
|
// Use the already-updated hover selection rather than re-resolving async
|
|
353
327
|
const candidate = hoverSelectionRef.current;
|
|
@@ -395,8 +369,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
395
369
|
}
|
|
396
370
|
};
|
|
397
371
|
|
|
372
|
+
// Selection re-resolves (and the box re-keys) on every click, so native
|
|
373
|
+
// dblclick never fires on the box — detect double-click by pointerdown
|
|
374
|
+
// timestamp (a no-move drag gesture suppresses the click event entirely).
|
|
375
|
+
const lastBoxPointerDownAtRef = useRef(0);
|
|
398
376
|
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
399
377
|
if (!allowCanvasMovement) return;
|
|
378
|
+
if (cropMode) {
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
event.stopPropagation();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
400
383
|
if (gestureRef.current || groupGestureRef.current) return;
|
|
401
384
|
if (suppressNextBoxClickRef.current) {
|
|
402
385
|
suppressNextBoxClickRef.current = false;
|
|
@@ -426,22 +409,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
426
409
|
}
|
|
427
410
|
onPointerDown={handleOverlayPointerDown}
|
|
428
411
|
onMouseDown={handleOverlayMouseDown}
|
|
429
|
-
onPointerMove={marquee.onPointerMove}
|
|
412
|
+
onPointerMove={cropMode ? undefined : marquee.onPointerMove}
|
|
430
413
|
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
|
431
|
-
onPointerUp={marquee.onPointerUp}
|
|
432
|
-
onPointerCancel={marquee.onPointerCancel}
|
|
414
|
+
onPointerUp={cropMode ? undefined : marquee.onPointerUp}
|
|
415
|
+
onPointerCancel={cropMode ? undefined : marquee.onPointerCancel}
|
|
433
416
|
>
|
|
434
417
|
{hoverSelection && hoverRect && compRect.width > 0 && (
|
|
435
418
|
<div
|
|
436
419
|
aria-hidden="true"
|
|
437
420
|
data-dom-edit-hover-box="true"
|
|
438
|
-
className="pointer-events-none absolute rounded-md border border-studio-accent/80
|
|
439
|
-
style={
|
|
440
|
-
left: hoverRect.left,
|
|
441
|
-
top: hoverRect.top,
|
|
442
|
-
width: hoverRect.width,
|
|
443
|
-
height: hoverRect.height,
|
|
444
|
-
}}
|
|
421
|
+
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
|
422
|
+
style={hugRectForElement(hoverRect, hoverSelection.element)}
|
|
445
423
|
/>
|
|
446
424
|
)}
|
|
447
425
|
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
|
|
@@ -450,7 +428,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
450
428
|
<div
|
|
451
429
|
key={item.key}
|
|
452
430
|
aria-hidden="true"
|
|
453
|
-
className="pointer-events-none absolute rounded-xl border border-studio-accent/70
|
|
431
|
+
className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
|
|
454
432
|
style={{
|
|
455
433
|
left: item.rect.left,
|
|
456
434
|
top: item.rect.top,
|
|
@@ -461,7 +439,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
461
439
|
))}
|
|
462
440
|
<div
|
|
463
441
|
data-dom-edit-selection-box="true"
|
|
464
|
-
className="pointer-events-auto absolute rounded-xl border border-studio-accent
|
|
442
|
+
className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
|
|
465
443
|
style={{
|
|
466
444
|
left: groupBounds.left,
|
|
467
445
|
top: groupBounds.top,
|
|
@@ -480,49 +458,49 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
480
458
|
)}
|
|
481
459
|
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
|
|
482
460
|
<>
|
|
483
|
-
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
|
|
484
|
-
<
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
height: 34,
|
|
491
|
-
transform: "translateX(-50%)",
|
|
461
|
+
{allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualRotation && (
|
|
462
|
+
<DomEditRotateHandle
|
|
463
|
+
overlayRect={overlayRect}
|
|
464
|
+
cropOutlineInsetPx={cropOutlineInsetPx}
|
|
465
|
+
onStartRotate={(e) => {
|
|
466
|
+
e.stopPropagation();
|
|
467
|
+
gestures.startGesture("rotate", e);
|
|
492
468
|
}}
|
|
493
|
-
|
|
494
|
-
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
|
|
495
|
-
<button
|
|
496
|
-
type="button"
|
|
497
|
-
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
|
|
498
|
-
style={{ cursor: "grab", touchAction: "none" }}
|
|
499
|
-
title="Rotate"
|
|
500
|
-
aria-label="Rotate selection"
|
|
501
|
-
onPointerDown={(e) => {
|
|
502
|
-
e.stopPropagation();
|
|
503
|
-
gestures.startGesture("rotate", e);
|
|
504
|
-
}}
|
|
505
|
-
/>
|
|
506
|
-
</div>
|
|
469
|
+
/>
|
|
507
470
|
)}
|
|
508
471
|
<div
|
|
509
472
|
key={selectionKey}
|
|
510
473
|
ref={boxRef}
|
|
511
474
|
data-dom-edit-selection-box="true"
|
|
512
|
-
className={`pointer-events-auto absolute rounded-md ${
|
|
475
|
+
className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
|
|
513
476
|
style={{
|
|
514
477
|
left: overlayRect.left,
|
|
515
478
|
top: overlayRect.top,
|
|
516
479
|
width: overlayRect.width,
|
|
517
480
|
height: overlayRect.height,
|
|
518
|
-
clipPath:
|
|
481
|
+
clipPath: boxClipPath,
|
|
519
482
|
cursor:
|
|
520
|
-
allowCanvasMovement && selection.capabilities.canApplyManualOffset
|
|
483
|
+
allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualOffset
|
|
521
484
|
? "move"
|
|
522
485
|
: "default",
|
|
523
486
|
}}
|
|
524
487
|
onPointerDown={(e) => {
|
|
488
|
+
if (cropMode) {
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
e.stopPropagation();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
525
493
|
if (!allowCanvasMovement || e.shiftKey) return;
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
const isDoubleClick = now - lastBoxPointerDownAtRef.current < 400;
|
|
496
|
+
lastBoxPointerDownAtRef.current = now;
|
|
497
|
+
if (isDoubleClick && onCropModeChange && selection.capabilities.canCrop) {
|
|
498
|
+
lastBoxPointerDownAtRef.current = 0;
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
e.stopPropagation();
|
|
501
|
+
onCropModeChange(true);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
526
504
|
if (selection.capabilities.canApplyManualOffset) {
|
|
527
505
|
gestures.startGesture("drag", e);
|
|
528
506
|
return;
|
|
@@ -540,10 +518,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
540
518
|
onMouseDown={suppressBoxMouseDown}
|
|
541
519
|
onClick={handleBoxClick}
|
|
542
520
|
>
|
|
543
|
-
{
|
|
521
|
+
{cropOutlineInsetPx && (
|
|
522
|
+
<div
|
|
523
|
+
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
|
524
|
+
style={{
|
|
525
|
+
left: cropOutlineInsetPx.left,
|
|
526
|
+
top: cropOutlineInsetPx.top,
|
|
527
|
+
right: cropOutlineInsetPx.right,
|
|
528
|
+
bottom: cropOutlineInsetPx.bottom,
|
|
529
|
+
}}
|
|
530
|
+
/>
|
|
531
|
+
)}
|
|
532
|
+
{allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualSize && (
|
|
544
533
|
<div
|
|
545
534
|
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
|
|
546
|
-
style={{
|
|
535
|
+
style={{
|
|
536
|
+
cursor: "se-resize",
|
|
537
|
+
touchAction: "none",
|
|
538
|
+
...(cropOutlineInsetPx && {
|
|
539
|
+
right: cropOutlineInsetPx.right - 6,
|
|
540
|
+
bottom: cropOutlineInsetPx.bottom - 6,
|
|
541
|
+
}),
|
|
542
|
+
}}
|
|
547
543
|
onPointerDown={(e) => {
|
|
548
544
|
e.stopPropagation();
|
|
549
545
|
gestures.startGesture("resize", e);
|
|
@@ -551,6 +547,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
551
547
|
/>
|
|
552
548
|
)}
|
|
553
549
|
</div>
|
|
550
|
+
{cropMode && (
|
|
551
|
+
<DomEditCropHandles
|
|
552
|
+
selection={selection}
|
|
553
|
+
overlayRect={overlayRect}
|
|
554
|
+
onStyleCommit={onStyleCommitRef.current}
|
|
555
|
+
/>
|
|
556
|
+
)}
|
|
554
557
|
</>
|
|
555
558
|
)}
|
|
556
559
|
{childRects.length > 0 &&
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
2
|
+
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
3
|
+
|
|
4
|
+
/** Rotate grab-handle above the selection. Anchors to the crop outline when
|
|
5
|
+
* the element is cropped so it stays next to what's visible on screen. */
|
|
6
|
+
export function DomEditRotateHandle({
|
|
7
|
+
overlayRect,
|
|
8
|
+
cropOutlineInsetPx,
|
|
9
|
+
onStartRotate,
|
|
10
|
+
}: {
|
|
11
|
+
overlayRect: OverlayRect;
|
|
12
|
+
cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
|
|
13
|
+
onStartRotate: (e: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
14
|
+
}) {
|
|
15
|
+
const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 };
|
|
16
|
+
const visibleLeft = overlayRect.left + inset.left;
|
|
17
|
+
const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right);
|
|
18
|
+
const visibleTop = overlayRect.top + inset.top;
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className="pointer-events-none absolute"
|
|
22
|
+
style={{
|
|
23
|
+
left: visibleLeft + visibleWidth / 2,
|
|
24
|
+
top: visibleTop - 34,
|
|
25
|
+
width: 28,
|
|
26
|
+
height: 34,
|
|
27
|
+
transform: "translateX(-50%)",
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
|
|
34
|
+
style={{ cursor: "grab", touchAction: "none" }}
|
|
35
|
+
title="Rotate"
|
|
36
|
+
aria-label="Rotate selection"
|
|
37
|
+
onPointerDown={onStartRotate}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
buildInsetClipPathSides,
|
|
3
4
|
buildStrokeStyleUpdates,
|
|
4
5
|
buildStrokeWidthStyleUpdates,
|
|
5
6
|
getClipPathInsetPx,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
inferBoxShadowPreset,
|
|
8
9
|
inferClipPathPreset,
|
|
9
10
|
normalizePanelPxValue,
|
|
11
|
+
parseInsetClipPathSides,
|
|
10
12
|
setCssFilterFunctionPx,
|
|
11
13
|
} from "./PropertyPanel";
|
|
12
14
|
|
|
@@ -49,6 +51,52 @@ describe("PropertyPanel style helpers", () => {
|
|
|
49
51
|
expect(getClipPathInsetPx("circle(50% at 50% 50%)")).toBe(0);
|
|
50
52
|
});
|
|
51
53
|
|
|
54
|
+
it("builds and parses 4-side inset clip paths without losing radius", () => {
|
|
55
|
+
expect(buildInsetClipPathSides({ top: 10, right: 20, bottom: 30, left: 40 }, 6)).toBe(
|
|
56
|
+
"inset(10px 20px 30px 40px round 6px)",
|
|
57
|
+
);
|
|
58
|
+
expect(parseInsetClipPathSides("inset(10px 20px 30px 40px round 6px)")).toEqual({
|
|
59
|
+
top: 10,
|
|
60
|
+
right: 20,
|
|
61
|
+
bottom: 30,
|
|
62
|
+
left: 40,
|
|
63
|
+
radius: 6,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("emits the single-value inset form when all sides are equal", () => {
|
|
68
|
+
expect(buildInsetClipPathSides({ top: 12.5, right: 12.5, bottom: 12.5, left: 12.5 })).toBe(
|
|
69
|
+
"inset(12.5px)",
|
|
70
|
+
);
|
|
71
|
+
expect(parseInsetClipPathSides("inset(12.5px)")).toEqual({
|
|
72
|
+
top: 12.5,
|
|
73
|
+
right: 12.5,
|
|
74
|
+
bottom: 12.5,
|
|
75
|
+
left: 12.5,
|
|
76
|
+
radius: 0,
|
|
77
|
+
});
|
|
78
|
+
expect(getClipPathInsetPx("inset(12.5px 12.5px 12.5px 12.5px)")).toBe(12.5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("accepts CSS shorthand inset values and rejects unsupported clip paths", () => {
|
|
82
|
+
expect(parseInsetClipPathSides("inset(10px 20px)")).toEqual({
|
|
83
|
+
top: 10,
|
|
84
|
+
right: 20,
|
|
85
|
+
bottom: 10,
|
|
86
|
+
left: 20,
|
|
87
|
+
radius: 0,
|
|
88
|
+
});
|
|
89
|
+
expect(parseInsetClipPathSides("inset(10px 20px 30px)")).toEqual({
|
|
90
|
+
top: 10,
|
|
91
|
+
right: 20,
|
|
92
|
+
bottom: 30,
|
|
93
|
+
left: 20,
|
|
94
|
+
radius: 0,
|
|
95
|
+
});
|
|
96
|
+
expect(parseInsetClipPathSides("inset(10%)")).toBeNull();
|
|
97
|
+
expect(parseInsetClipPathSides("circle(50% at 50% 50%)")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
52
100
|
it("keeps stroke width and style edits visually effective", () => {
|
|
53
101
|
expect(buildStrokeWidthStyleUpdates("3px", "none")).toEqual([
|
|
54
102
|
["border-width", "3px"],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { Move } from "../../icons/SystemIcons";
|
|
3
3
|
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
|
4
4
|
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
5
5
|
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
@@ -27,9 +27,11 @@ import { usePlayerStore, liveTime } from "../../player";
|
|
|
27
27
|
import { TimingSection } from "./propertyPanelTimingSection";
|
|
28
28
|
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
|
29
29
|
import { GestureRecordPanelButton } from "./GestureRecordControl";
|
|
30
|
+
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
|
|
30
31
|
|
|
31
32
|
// Re-export helpers that external consumers import from this module
|
|
32
33
|
export {
|
|
34
|
+
buildInsetClipPathSides,
|
|
33
35
|
buildStrokeStyleUpdates,
|
|
34
36
|
buildStrokeWidthStyleUpdates,
|
|
35
37
|
getCssFilterFunctionPx,
|
|
@@ -37,6 +39,7 @@ export {
|
|
|
37
39
|
inferBoxShadowPreset,
|
|
38
40
|
inferClipPathPreset,
|
|
39
41
|
normalizePanelPxValue,
|
|
42
|
+
parseInsetClipPathSides,
|
|
40
43
|
setCssFilterFunctionPx,
|
|
41
44
|
} from "./propertyPanelHelpers";
|
|
42
45
|
|
|
@@ -94,6 +97,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
94
97
|
recordingState,
|
|
95
98
|
recordingDuration,
|
|
96
99
|
onToggleRecording,
|
|
100
|
+
cropMode,
|
|
101
|
+
onCropModeChange,
|
|
97
102
|
}: PropertyPanelProps) {
|
|
98
103
|
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
|
99
104
|
const { showToast } = useStudioShellContext();
|
|
@@ -160,35 +165,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
160
165
|
};
|
|
161
166
|
|
|
162
167
|
if (!element) {
|
|
163
|
-
return
|
|
164
|
-
<div className="flex h-full flex-col bg-neutral-900">
|
|
165
|
-
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
|
166
|
-
{multiSelectCount > 1 ? (
|
|
167
|
-
<>
|
|
168
|
-
<Layers size={18} className="mb-3 text-neutral-600" />
|
|
169
|
-
<p className="text-sm font-medium text-neutral-200">
|
|
170
|
-
{multiSelectCount} elements selected
|
|
171
|
-
</p>
|
|
172
|
-
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
173
|
-
Select a single element to edit its properties. Click an element in the preview or
|
|
174
|
-
use the timeline layer panel.
|
|
175
|
-
</p>
|
|
176
|
-
</>
|
|
177
|
-
) : (
|
|
178
|
-
<>
|
|
179
|
-
<Eye size={18} className="mb-3 text-neutral-600" />
|
|
180
|
-
<p className="text-sm font-medium text-neutral-200">
|
|
181
|
-
Select an element in the preview.
|
|
182
|
-
</p>
|
|
183
|
-
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
184
|
-
The inspector is tuned for element edits with safer geometry controls, color
|
|
185
|
-
picking, and cleaner grouped layer controls.
|
|
186
|
-
</p>
|
|
187
|
-
</>
|
|
188
|
-
)}
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
);
|
|
168
|
+
return <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
|
|
192
169
|
}
|
|
193
170
|
|
|
194
171
|
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
|
|
@@ -581,6 +558,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
581
558
|
onSetStyle={onSetStyle}
|
|
582
559
|
onImportAssets={onImportAssets}
|
|
583
560
|
gsapBorderRadius={gsapBorderRadius}
|
|
561
|
+
cropMode={cropMode}
|
|
562
|
+
onCropModeChange={onCropModeChange}
|
|
584
563
|
/>
|
|
585
564
|
)}
|
|
586
565
|
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Eye, Layers } from "../../icons/SystemIcons";
|
|
2
|
+
|
|
3
|
+
export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex h-full flex-col bg-neutral-900">
|
|
6
|
+
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
|
7
|
+
{multiSelectCount > 1 ? (
|
|
8
|
+
<>
|
|
9
|
+
<Layers size={18} className="mb-3 text-neutral-600" />
|
|
10
|
+
<p className="text-sm font-medium text-neutral-200">
|
|
11
|
+
{multiSelectCount} elements selected
|
|
12
|
+
</p>
|
|
13
|
+
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
14
|
+
Select a single element to edit its properties. Click an element in the preview or use
|
|
15
|
+
the timeline layer panel.
|
|
16
|
+
</p>
|
|
17
|
+
</>
|
|
18
|
+
) : (
|
|
19
|
+
<>
|
|
20
|
+
<Eye size={18} className="mb-3 text-neutral-600" />
|
|
21
|
+
<p className="text-sm font-medium text-neutral-200">
|
|
22
|
+
Select an element in the preview.
|
|
23
|
+
</p>
|
|
24
|
+
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
25
|
+
The inspector is tuned for element edits with safer geometry controls, color picking,
|
|
26
|
+
and cleaner grouped layer controls.
|
|
27
|
+
</p>
|
|
28
|
+
</>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
|
|
2
|
+
import { Crop, MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
|
|
3
3
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
4
4
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
5
5
|
|
|
@@ -39,6 +39,9 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
39
39
|
const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
|
|
40
40
|
const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
|
|
41
41
|
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
|
|
42
|
+
const cropAvailable = usePlayerStore((s) => s.cropAvailable);
|
|
43
|
+
const cropMode = usePlayerStore((s) => s.cropMode);
|
|
44
|
+
const setCropMode = usePlayerStore((s) => s.setCropMode);
|
|
42
45
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
43
46
|
const gridButtonRef = useRef<HTMLButtonElement>(null);
|
|
44
47
|
|
|
@@ -99,6 +102,22 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
99
102
|
className="absolute top-2 right-2 z-50 flex items-center gap-1"
|
|
100
103
|
onPointerDown={(e) => e.stopPropagation()}
|
|
101
104
|
>
|
|
105
|
+
{cropAvailable && (
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className={`rounded-md p-1.5 transition-colors ${
|
|
109
|
+
cropMode
|
|
110
|
+
? "bg-studio-accent/20 text-studio-accent"
|
|
111
|
+
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
112
|
+
}`}
|
|
113
|
+
onClick={() => setCropMode(!cropMode)}
|
|
114
|
+
title={cropMode ? "Exit crop (Esc)" : "Crop selection"}
|
|
115
|
+
aria-label="Crop selection"
|
|
116
|
+
aria-pressed={cropMode}
|
|
117
|
+
>
|
|
118
|
+
<Crop size={16} weight={cropMode ? "fill" : "regular"} />
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
102
121
|
{motionPathCreateAvailable && (
|
|
103
122
|
<button
|
|
104
123
|
type="button"
|