@hyperframes/studio 0.5.0-alpha.8 → 0.5.0
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-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1436
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2462
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +9 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +219 -1
- package/src/player/hooks/useTimelinePlayer.ts +487 -106
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +6 -1
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-C9f5eif8.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -442
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
|
@@ -1,442 +0,0 @@
|
|
|
1
|
-
import { memo, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
|
-
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
import { type DomEditSelection, findElementForSelection } from "./domEditing";
|
|
4
|
-
|
|
5
|
-
interface OverlayRect {
|
|
6
|
-
left: number;
|
|
7
|
-
top: number;
|
|
8
|
-
width: number;
|
|
9
|
-
height: number;
|
|
10
|
-
scaleX: number;
|
|
11
|
-
scaleY: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface DomEditOverlayProps {
|
|
15
|
-
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
16
|
-
selection: DomEditSelection | null;
|
|
17
|
-
onCanvasMouseDown: (
|
|
18
|
-
event: React.MouseEvent<HTMLDivElement>,
|
|
19
|
-
options?: { preferClipAncestor?: boolean },
|
|
20
|
-
) => void;
|
|
21
|
-
onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
22
|
-
onSelectedDoubleClick: () => void;
|
|
23
|
-
onBlockedMove: (selection: DomEditSelection) => void;
|
|
24
|
-
onMoveCommit: (
|
|
25
|
-
selection: DomEditSelection,
|
|
26
|
-
next: { left: number; top: number },
|
|
27
|
-
) => Promise<void> | void;
|
|
28
|
-
onResizeCommit: (
|
|
29
|
-
selection: DomEditSelection,
|
|
30
|
-
next: { width: number; height: number },
|
|
31
|
-
) => Promise<void> | void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function toOverlayRect(
|
|
35
|
-
overlayEl: HTMLDivElement,
|
|
36
|
-
iframe: HTMLIFrameElement,
|
|
37
|
-
element: HTMLElement,
|
|
38
|
-
): OverlayRect | null {
|
|
39
|
-
const iframeRect = iframe.getBoundingClientRect();
|
|
40
|
-
const overlayRect = overlayEl.getBoundingClientRect();
|
|
41
|
-
const doc = iframe.contentDocument;
|
|
42
|
-
const root =
|
|
43
|
-
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
|
|
44
|
-
const rootRect = root?.getBoundingClientRect();
|
|
45
|
-
const rootWidth = rootRect?.width;
|
|
46
|
-
const rootHeight = rootRect?.height;
|
|
47
|
-
if (!rootWidth || !rootHeight) return null;
|
|
48
|
-
|
|
49
|
-
const elementRect = element.getBoundingClientRect();
|
|
50
|
-
const scaleX = iframeRect.width / rootWidth;
|
|
51
|
-
const scaleY = iframeRect.height / rootHeight;
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
left: iframeRect.left - overlayRect.left + elementRect.left * scaleX,
|
|
55
|
-
top: iframeRect.top - overlayRect.top + elementRect.top * scaleY,
|
|
56
|
-
width: elementRect.width * scaleX,
|
|
57
|
-
height: elementRect.height * scaleY,
|
|
58
|
-
scaleX,
|
|
59
|
-
scaleY,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
type GestureKind = "drag" | "resize";
|
|
64
|
-
const BLOCKED_MOVE_THRESHOLD_PX = 4;
|
|
65
|
-
const OVERLAY_RECT_EPSILON_PX = 0.5;
|
|
66
|
-
|
|
67
|
-
function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
|
|
68
|
-
if (a === b) return true;
|
|
69
|
-
if (!a || !b) return false;
|
|
70
|
-
return (
|
|
71
|
-
Math.abs(a.left - b.left) < OVERLAY_RECT_EPSILON_PX &&
|
|
72
|
-
Math.abs(a.top - b.top) < OVERLAY_RECT_EPSILON_PX &&
|
|
73
|
-
Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
|
|
74
|
-
Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
|
|
75
|
-
Math.abs(a.scaleX - b.scaleX) < 0.001 &&
|
|
76
|
-
Math.abs(a.scaleY - b.scaleY) < 0.001
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function selectionCacheKey(
|
|
81
|
-
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
|
|
82
|
-
): string {
|
|
83
|
-
return [
|
|
84
|
-
selection.sourceFile ?? "",
|
|
85
|
-
selection.id ?? "",
|
|
86
|
-
selection.selector ?? "",
|
|
87
|
-
selection.selectorIndex ?? "",
|
|
88
|
-
].join("|");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function restoreInlineStyle(
|
|
92
|
-
element: HTMLElement,
|
|
93
|
-
property: "left" | "top" | "width" | "height",
|
|
94
|
-
value: string,
|
|
95
|
-
) {
|
|
96
|
-
if (value) element.style.setProperty(property, value);
|
|
97
|
-
else element.style.removeProperty(property);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
interface GestureState {
|
|
101
|
-
kind: GestureKind;
|
|
102
|
-
startX: number;
|
|
103
|
-
startY: number;
|
|
104
|
-
initialStyleLeft: string;
|
|
105
|
-
initialStyleTop: string;
|
|
106
|
-
originLeft: number;
|
|
107
|
-
originTop: number;
|
|
108
|
-
originWidth: number;
|
|
109
|
-
originHeight: number;
|
|
110
|
-
actualLeft: number;
|
|
111
|
-
actualTop: number;
|
|
112
|
-
actualWidth: number;
|
|
113
|
-
actualHeight: number;
|
|
114
|
-
scaleX: number;
|
|
115
|
-
scaleY: number;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
interface BlockedMoveState {
|
|
119
|
-
pointerId: number;
|
|
120
|
-
startX: number;
|
|
121
|
-
startY: number;
|
|
122
|
-
notified: boolean;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export const DomEditOverlay = memo(function DomEditOverlay({
|
|
126
|
-
iframeRef,
|
|
127
|
-
selection,
|
|
128
|
-
onCanvasMouseDown,
|
|
129
|
-
onCanvasDoubleClick,
|
|
130
|
-
onSelectedDoubleClick,
|
|
131
|
-
onBlockedMove,
|
|
132
|
-
onMoveCommit,
|
|
133
|
-
onResizeCommit,
|
|
134
|
-
}: DomEditOverlayProps) {
|
|
135
|
-
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
136
|
-
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
137
|
-
const [overlayRect, setOverlayRect] = useState<OverlayRect | null>(null);
|
|
138
|
-
const gestureRef = useRef<GestureState | null>(null);
|
|
139
|
-
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
|
140
|
-
const suppressNextBoxClickRef = useRef(false);
|
|
141
|
-
const rafPausedRef = useRef(false);
|
|
142
|
-
const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
|
|
143
|
-
|
|
144
|
-
const selectionRef = useRef(selection);
|
|
145
|
-
selectionRef.current = selection;
|
|
146
|
-
const overlayRectRef = useRef(overlayRect);
|
|
147
|
-
overlayRectRef.current = overlayRect;
|
|
148
|
-
const onMoveCommitRef = useRef(onMoveCommit);
|
|
149
|
-
onMoveCommitRef.current = onMoveCommit;
|
|
150
|
-
const onResizeCommitRef = useRef(onResizeCommit);
|
|
151
|
-
onResizeCommitRef.current = onResizeCommit;
|
|
152
|
-
const onBlockedMoveRef = useRef(onBlockedMove);
|
|
153
|
-
onBlockedMoveRef.current = onBlockedMove;
|
|
154
|
-
|
|
155
|
-
useMountEffect(() => {
|
|
156
|
-
let frame = 0;
|
|
157
|
-
const clearOverlayRect = () => {
|
|
158
|
-
if (!overlayRectRef.current) return;
|
|
159
|
-
overlayRectRef.current = null;
|
|
160
|
-
setOverlayRect(null);
|
|
161
|
-
};
|
|
162
|
-
const setNextOverlayRect = (next: OverlayRect | null) => {
|
|
163
|
-
if (rectsEqual(overlayRectRef.current, next)) return;
|
|
164
|
-
overlayRectRef.current = next;
|
|
165
|
-
setOverlayRect(next);
|
|
166
|
-
};
|
|
167
|
-
const resolveElement = (doc: Document, sel: DomEditSelection) => {
|
|
168
|
-
const key = selectionCacheKey(sel);
|
|
169
|
-
const cached = resolvedElementRef.current;
|
|
170
|
-
if (
|
|
171
|
-
cached?.key === key &&
|
|
172
|
-
cached.element.isConnected &&
|
|
173
|
-
cached.element.ownerDocument === doc
|
|
174
|
-
) {
|
|
175
|
-
return cached.element;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const next = findElementForSelection(doc, sel, sel.sourceFile);
|
|
179
|
-
resolvedElementRef.current = next ? { key, element: next } : null;
|
|
180
|
-
return next;
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const update = () => {
|
|
184
|
-
frame = requestAnimationFrame(update);
|
|
185
|
-
if (rafPausedRef.current) return;
|
|
186
|
-
|
|
187
|
-
const sel = selectionRef.current;
|
|
188
|
-
const iframe = iframeRef.current;
|
|
189
|
-
const overlayEl = overlayRef.current;
|
|
190
|
-
if (!sel || !iframe || !overlayEl) {
|
|
191
|
-
resolvedElementRef.current = null;
|
|
192
|
-
clearOverlayRect();
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const doc = iframe.contentDocument;
|
|
197
|
-
if (!doc) return;
|
|
198
|
-
|
|
199
|
-
const el = resolveElement(doc, sel);
|
|
200
|
-
if (!el) {
|
|
201
|
-
clearOverlayRect();
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const next = toOverlayRect(overlayEl, iframe, el);
|
|
206
|
-
setNextOverlayRect(next);
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
frame = requestAnimationFrame(update);
|
|
210
|
-
return () => cancelAnimationFrame(frame);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
const selectionKey = useMemo(() => {
|
|
214
|
-
if (!selection) return "none";
|
|
215
|
-
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${
|
|
216
|
-
selection.selectorIndex ?? 0
|
|
217
|
-
}`;
|
|
218
|
-
}, [selection]);
|
|
219
|
-
|
|
220
|
-
const startGesture = (kind: GestureKind, e: React.PointerEvent) => {
|
|
221
|
-
const sel = selectionRef.current;
|
|
222
|
-
const rect = overlayRectRef.current;
|
|
223
|
-
const box = boxRef.current;
|
|
224
|
-
if (!sel || !rect || !box) return;
|
|
225
|
-
|
|
226
|
-
const left = Number.parseFloat(sel.computedStyles.left ?? "");
|
|
227
|
-
const top = Number.parseFloat(sel.computedStyles.top ?? "");
|
|
228
|
-
const width = Number.parseFloat(sel.computedStyles.width ?? "");
|
|
229
|
-
const height = Number.parseFloat(sel.computedStyles.height ?? "");
|
|
230
|
-
if (!Number.isFinite(left) || !Number.isFinite(top)) return;
|
|
231
|
-
if (kind === "resize" && !Number.isFinite(width) && !Number.isFinite(height)) return;
|
|
232
|
-
|
|
233
|
-
e.preventDefault();
|
|
234
|
-
e.stopPropagation();
|
|
235
|
-
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
236
|
-
|
|
237
|
-
rafPausedRef.current = true;
|
|
238
|
-
|
|
239
|
-
gestureRef.current = {
|
|
240
|
-
kind,
|
|
241
|
-
startX: e.clientX,
|
|
242
|
-
startY: e.clientY,
|
|
243
|
-
initialStyleLeft: sel.element.style.left,
|
|
244
|
-
initialStyleTop: sel.element.style.top,
|
|
245
|
-
originLeft: rect.left,
|
|
246
|
-
originTop: rect.top,
|
|
247
|
-
originWidth: rect.width,
|
|
248
|
-
originHeight: rect.height,
|
|
249
|
-
actualLeft: left,
|
|
250
|
-
actualTop: top,
|
|
251
|
-
actualWidth: Number.isFinite(width) ? width : 0,
|
|
252
|
-
actualHeight: Number.isFinite(height) ? height : 0,
|
|
253
|
-
scaleX: rect.scaleX,
|
|
254
|
-
scaleY: rect.scaleY,
|
|
255
|
-
};
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
const onPointerMove = (e: React.PointerEvent) => {
|
|
259
|
-
const g = gestureRef.current;
|
|
260
|
-
const sel = selectionRef.current;
|
|
261
|
-
const box = boxRef.current;
|
|
262
|
-
const blockedMove = blockedMoveRef.current;
|
|
263
|
-
if (blockedMove && sel) {
|
|
264
|
-
const dx = e.clientX - blockedMove.startX;
|
|
265
|
-
const dy = e.clientY - blockedMove.startY;
|
|
266
|
-
if (!blockedMove.notified && Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
|
|
267
|
-
blockedMove.notified = true;
|
|
268
|
-
suppressNextBoxClickRef.current = true;
|
|
269
|
-
onBlockedMoveRef.current(sel);
|
|
270
|
-
}
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (!g || !sel || !box) return;
|
|
275
|
-
|
|
276
|
-
const dx = e.clientX - g.startX;
|
|
277
|
-
const dy = e.clientY - g.startY;
|
|
278
|
-
|
|
279
|
-
if (g.kind === "drag") {
|
|
280
|
-
const nextBoxLeft = g.originLeft + dx;
|
|
281
|
-
const nextBoxTop = g.originTop + dy;
|
|
282
|
-
box.style.left = `${nextBoxLeft}px`;
|
|
283
|
-
box.style.top = `${nextBoxTop}px`;
|
|
284
|
-
sel.element.style.left = `${Math.round(g.actualLeft + dx / g.scaleX)}px`;
|
|
285
|
-
sel.element.style.top = `${Math.round(g.actualTop + dy / g.scaleY)}px`;
|
|
286
|
-
} else {
|
|
287
|
-
const newW = Math.max(20, g.originWidth + dx);
|
|
288
|
-
const newH = Math.max(20, g.originHeight + dy);
|
|
289
|
-
box.style.width = `${newW}px`;
|
|
290
|
-
box.style.height = `${newH}px`;
|
|
291
|
-
sel.element.style.width = `${Math.round(g.actualWidth + dx / g.scaleX)}px`;
|
|
292
|
-
sel.element.style.height = `${Math.round(g.actualHeight + dy / g.scaleY)}px`;
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
297
|
-
const g = gestureRef.current;
|
|
298
|
-
const sel = selectionRef.current;
|
|
299
|
-
const box = boxRef.current;
|
|
300
|
-
blockedMoveRef.current = null;
|
|
301
|
-
if (!g || !sel) {
|
|
302
|
-
gestureRef.current = null;
|
|
303
|
-
rafPausedRef.current = false;
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
gestureRef.current = null;
|
|
308
|
-
rafPausedRef.current = false;
|
|
309
|
-
|
|
310
|
-
const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
|
|
311
|
-
if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
|
|
312
|
-
restoreInlineStyle(sel.element, "left", g.initialStyleLeft);
|
|
313
|
-
restoreInlineStyle(sel.element, "top", g.initialStyleTop);
|
|
314
|
-
if (box) {
|
|
315
|
-
box.style.left = `${g.originLeft}px`;
|
|
316
|
-
box.style.top = `${g.originTop}px`;
|
|
317
|
-
}
|
|
318
|
-
suppressNextBoxClickRef.current = true;
|
|
319
|
-
onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
|
|
320
|
-
preferClipAncestor: false,
|
|
321
|
-
});
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if (g.kind === "drag") {
|
|
326
|
-
const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
|
|
327
|
-
const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
|
|
328
|
-
void Promise.resolve(onMoveCommitRef.current(sel, { left: finalLeft, top: finalTop })).catch(
|
|
329
|
-
() => {
|
|
330
|
-
sel.element.style.left = `${Math.round(g.actualLeft)}px`;
|
|
331
|
-
sel.element.style.top = `${Math.round(g.actualTop)}px`;
|
|
332
|
-
},
|
|
333
|
-
);
|
|
334
|
-
} else {
|
|
335
|
-
const finalW = Number.parseFloat(sel.element.style.width) || g.actualWidth;
|
|
336
|
-
const finalH = Number.parseFloat(sel.element.style.height) || g.actualHeight;
|
|
337
|
-
void Promise.resolve(onResizeCommitRef.current(sel, { width: finalW, height: finalH })).catch(
|
|
338
|
-
() => {
|
|
339
|
-
if (g.actualWidth > 0) sel.element.style.width = `${Math.round(g.actualWidth)}px`;
|
|
340
|
-
else sel.element.style.removeProperty("width");
|
|
341
|
-
if (g.actualHeight > 0) sel.element.style.height = `${Math.round(g.actualHeight)}px`;
|
|
342
|
-
else sel.element.style.removeProperty("height");
|
|
343
|
-
},
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
// Click on overlay background → select whatever is under the pointer in the iframe.
|
|
349
|
-
// This handles clicking children inside an already-selected parent: the selection
|
|
350
|
-
// box stops propagation for drag gestures, but clicks on the transparent overlay
|
|
351
|
-
// area outside the box pass through to the iframe pick logic.
|
|
352
|
-
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
353
|
-
const target = event.target as HTMLElement | null;
|
|
354
|
-
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
355
|
-
onCanvasMouseDown(event, { preferClipAncestor: false });
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
359
|
-
const target = event.target as HTMLElement | null;
|
|
360
|
-
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
361
|
-
onCanvasDoubleClick(event);
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
// Click on the selection box itself → re-pick the element under the pointer.
|
|
365
|
-
// This lets you click a child element even when a parent is selected, because
|
|
366
|
-
// the click coordinates are forwarded to the iframe's element picker.
|
|
367
|
-
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
368
|
-
if (gestureRef.current) return;
|
|
369
|
-
if (suppressNextBoxClickRef.current) {
|
|
370
|
-
suppressNextBoxClickRef.current = false;
|
|
371
|
-
event.stopPropagation();
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
onCanvasMouseDown(event, { preferClipAncestor: false });
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
const clearPointerState = () => {
|
|
378
|
-
blockedMoveRef.current = null;
|
|
379
|
-
gestureRef.current = null;
|
|
380
|
-
rafPausedRef.current = false;
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
return (
|
|
384
|
-
<div
|
|
385
|
-
key={selectionKey}
|
|
386
|
-
ref={overlayRef}
|
|
387
|
-
className="absolute inset-0 z-10 pointer-events-auto"
|
|
388
|
-
onMouseDown={handleOverlayMouseDown}
|
|
389
|
-
onDoubleClick={handleOverlayDoubleClick}
|
|
390
|
-
onPointerMove={onPointerMove}
|
|
391
|
-
onPointerUp={onPointerUp}
|
|
392
|
-
onPointerCancel={clearPointerState}
|
|
393
|
-
>
|
|
394
|
-
{selection && overlayRect && (
|
|
395
|
-
<>
|
|
396
|
-
<div
|
|
397
|
-
key={selectionKey}
|
|
398
|
-
ref={boxRef}
|
|
399
|
-
data-dom-edit-selection-box="true"
|
|
400
|
-
className="pointer-events-auto absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
|
401
|
-
style={{
|
|
402
|
-
left: overlayRect.left,
|
|
403
|
-
top: overlayRect.top,
|
|
404
|
-
width: overlayRect.width,
|
|
405
|
-
height: overlayRect.height,
|
|
406
|
-
cursor: selection.capabilities.canMove ? "move" : "default",
|
|
407
|
-
}}
|
|
408
|
-
onPointerDown={(e) => {
|
|
409
|
-
if (selection.capabilities.canMove) {
|
|
410
|
-
startGesture("drag", e);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
e.preventDefault();
|
|
414
|
-
e.stopPropagation();
|
|
415
|
-
e.currentTarget.setPointerCapture(e.pointerId);
|
|
416
|
-
blockedMoveRef.current = {
|
|
417
|
-
pointerId: e.pointerId,
|
|
418
|
-
startX: e.clientX,
|
|
419
|
-
startY: e.clientY,
|
|
420
|
-
notified: false,
|
|
421
|
-
};
|
|
422
|
-
}}
|
|
423
|
-
onClick={handleBoxClick}
|
|
424
|
-
onDoubleClick={onSelectedDoubleClick}
|
|
425
|
-
>
|
|
426
|
-
{/* Resize handle — bottom-right corner */}
|
|
427
|
-
{selection.capabilities.canResize && (
|
|
428
|
-
<div
|
|
429
|
-
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
|
|
430
|
-
style={{ cursor: "se-resize", touchAction: "none" }}
|
|
431
|
-
onPointerDown={(e) => {
|
|
432
|
-
e.stopPropagation();
|
|
433
|
-
startGesture("resize", e);
|
|
434
|
-
}}
|
|
435
|
-
/>
|
|
436
|
-
)}
|
|
437
|
-
</div>
|
|
438
|
-
</>
|
|
439
|
-
)}
|
|
440
|
-
</div>
|
|
441
|
-
);
|
|
442
|
-
});
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
formatCssColor,
|
|
4
|
-
hsvToRgb,
|
|
5
|
-
mergeColorWithExistingAlpha,
|
|
6
|
-
parseCssColor,
|
|
7
|
-
rgbToHsv,
|
|
8
|
-
toColorPickerValue,
|
|
9
|
-
toHexColor,
|
|
10
|
-
} from "./colorValue";
|
|
11
|
-
|
|
12
|
-
describe("parseCssColor", () => {
|
|
13
|
-
it("parses rgb values", () => {
|
|
14
|
-
expect(parseCssColor("rgb(12, 34, 56)")).toEqual({
|
|
15
|
-
red: 12,
|
|
16
|
-
green: 34,
|
|
17
|
-
blue: 56,
|
|
18
|
-
alpha: 1,
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("parses rgba values", () => {
|
|
23
|
-
expect(parseCssColor("rgba(15, 23, 42, 0.64)")).toEqual({
|
|
24
|
-
red: 15,
|
|
25
|
-
green: 23,
|
|
26
|
-
blue: 42,
|
|
27
|
-
alpha: 0.64,
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("parses transparent", () => {
|
|
32
|
-
expect(parseCssColor("transparent")).toEqual({
|
|
33
|
-
red: 0,
|
|
34
|
-
green: 0,
|
|
35
|
-
blue: 0,
|
|
36
|
-
alpha: 0,
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("toColorPickerValue", () => {
|
|
42
|
-
it("converts css color to hex", () => {
|
|
43
|
-
expect(toColorPickerValue("rgba(15, 23, 42, 0.64)")).toBe("#0f172a");
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("toHexColor", () => {
|
|
48
|
-
it("formats rgb channels as hex", () => {
|
|
49
|
-
expect(toHexColor({ red: 15, green: 23, blue: 42 })).toBe("#0f172a");
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("formatCssColor", () => {
|
|
54
|
-
it("formats opaque colors as rgb", () => {
|
|
55
|
-
expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 1 })).toBe("rgb(18, 52, 86)");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("formats translucent colors as rgba", () => {
|
|
59
|
-
expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 0.64 })).toBe(
|
|
60
|
-
"rgba(18, 52, 86, 0.64)",
|
|
61
|
-
);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe("rgb hsv conversion", () => {
|
|
66
|
-
it("round-trips primary color values", () => {
|
|
67
|
-
const hsv = rgbToHsv({ red: 47, green: 198, blue: 127 });
|
|
68
|
-
expect(hsvToRgb(hsv)).toEqual({ red: 47, green: 198, blue: 127 });
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe("mergeColorWithExistingAlpha", () => {
|
|
73
|
-
it("preserves alpha when the previous color was translucent", () => {
|
|
74
|
-
expect(mergeColorWithExistingAlpha("#123456", "rgba(15, 23, 42, 0.64)")).toBe(
|
|
75
|
-
"rgba(18, 52, 86, 0.64)",
|
|
76
|
-
);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("returns rgb when the previous color was opaque", () => {
|
|
80
|
-
expect(mergeColorWithExistingAlpha("#123456", "rgb(15, 23, 42)")).toBe("rgb(18, 52, 86)");
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
export interface ParsedColor {
|
|
2
|
-
red: number;
|
|
3
|
-
green: number;
|
|
4
|
-
blue: number;
|
|
5
|
-
alpha: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface HsvColor {
|
|
9
|
-
hue: number;
|
|
10
|
-
saturation: number;
|
|
11
|
-
value: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function clampChannel(value: number): number {
|
|
15
|
-
return Math.max(0, Math.min(255, Math.round(value)));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function clampAlpha(value: number): number {
|
|
19
|
-
return Math.max(0, Math.min(1, value));
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function toHex(value: number): string {
|
|
23
|
-
return clampChannel(value).toString(16).padStart(2, "0");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function formatAlpha(value: number): string {
|
|
27
|
-
return `${Math.round(clampAlpha(value) * 100) / 100}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function parseCssColor(value: string): ParsedColor | null {
|
|
31
|
-
const trimmed = value.trim().toLowerCase();
|
|
32
|
-
if (!trimmed) return null;
|
|
33
|
-
if (trimmed === "transparent") {
|
|
34
|
-
return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const shortHex = trimmed.match(/^#([0-9a-f]{3})$/i);
|
|
38
|
-
if (shortHex) {
|
|
39
|
-
const [r, g, b] = shortHex[1].split("");
|
|
40
|
-
return {
|
|
41
|
-
red: Number.parseInt(r + r, 16),
|
|
42
|
-
green: Number.parseInt(g + g, 16),
|
|
43
|
-
blue: Number.parseInt(b + b, 16),
|
|
44
|
-
alpha: 1,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
|
|
49
|
-
if (hex) {
|
|
50
|
-
return {
|
|
51
|
-
red: Number.parseInt(hex[1].slice(0, 2), 16),
|
|
52
|
-
green: Number.parseInt(hex[1].slice(2, 4), 16),
|
|
53
|
-
blue: Number.parseInt(hex[1].slice(4, 6), 16),
|
|
54
|
-
alpha: 1,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const rgba = trimmed.match(
|
|
59
|
-
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
|
60
|
-
);
|
|
61
|
-
if (rgba) {
|
|
62
|
-
return {
|
|
63
|
-
red: clampChannel(Number.parseFloat(rgba[1])),
|
|
64
|
-
green: clampChannel(Number.parseFloat(rgba[2])),
|
|
65
|
-
blue: clampChannel(Number.parseFloat(rgba[3])),
|
|
66
|
-
alpha: clampAlpha(rgba[4] != null ? Number.parseFloat(rgba[4]) : 1),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function toColorPickerValue(value: string): string {
|
|
74
|
-
const parsed = parseCssColor(value);
|
|
75
|
-
if (!parsed) return "#000000";
|
|
76
|
-
return toHexColor(parsed);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function toHexColor(color: Pick<ParsedColor, "red" | "green" | "blue">): string {
|
|
80
|
-
return `#${toHex(color.red)}${toHex(color.green)}${toHex(color.blue)}`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function formatCssColor(color: ParsedColor): string {
|
|
84
|
-
const red = clampChannel(color.red);
|
|
85
|
-
const green = clampChannel(color.green);
|
|
86
|
-
const blue = clampChannel(color.blue);
|
|
87
|
-
const alpha = clampAlpha(color.alpha);
|
|
88
|
-
|
|
89
|
-
if (alpha >= 1) {
|
|
90
|
-
return `rgb(${red}, ${green}, ${blue})`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return `rgba(${red}, ${green}, ${blue}, ${formatAlpha(alpha)})`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function rgbToHsv(color: Pick<ParsedColor, "red" | "green" | "blue">): HsvColor {
|
|
97
|
-
const red = clampChannel(color.red) / 255;
|
|
98
|
-
const green = clampChannel(color.green) / 255;
|
|
99
|
-
const blue = clampChannel(color.blue) / 255;
|
|
100
|
-
const max = Math.max(red, green, blue);
|
|
101
|
-
const min = Math.min(red, green, blue);
|
|
102
|
-
const delta = max - min;
|
|
103
|
-
|
|
104
|
-
let hue = 0;
|
|
105
|
-
if (delta !== 0) {
|
|
106
|
-
if (max === red) {
|
|
107
|
-
hue = 60 * (((green - blue) / delta) % 6);
|
|
108
|
-
} else if (max === green) {
|
|
109
|
-
hue = 60 * ((blue - red) / delta + 2);
|
|
110
|
-
} else {
|
|
111
|
-
hue = 60 * ((red - green) / delta + 4);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (hue < 0) hue += 360;
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
hue,
|
|
119
|
-
saturation: max === 0 ? 0 : delta / max,
|
|
120
|
-
value: max,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function hsvToRgb(color: HsvColor): Pick<ParsedColor, "red" | "green" | "blue"> {
|
|
125
|
-
const hue = (((color.hue % 360) + 360) % 360) / 60;
|
|
126
|
-
const saturation = Math.max(0, Math.min(1, color.saturation));
|
|
127
|
-
const value = Math.max(0, Math.min(1, color.value));
|
|
128
|
-
const chroma = value * saturation;
|
|
129
|
-
const x = chroma * (1 - Math.abs((hue % 2) - 1));
|
|
130
|
-
const m = value - chroma;
|
|
131
|
-
|
|
132
|
-
let red = 0;
|
|
133
|
-
let green = 0;
|
|
134
|
-
let blue = 0;
|
|
135
|
-
|
|
136
|
-
if (hue >= 0 && hue < 1) {
|
|
137
|
-
red = chroma;
|
|
138
|
-
green = x;
|
|
139
|
-
} else if (hue >= 1 && hue < 2) {
|
|
140
|
-
red = x;
|
|
141
|
-
green = chroma;
|
|
142
|
-
} else if (hue >= 2 && hue < 3) {
|
|
143
|
-
green = chroma;
|
|
144
|
-
blue = x;
|
|
145
|
-
} else if (hue >= 3 && hue < 4) {
|
|
146
|
-
green = x;
|
|
147
|
-
blue = chroma;
|
|
148
|
-
} else if (hue >= 4 && hue < 5) {
|
|
149
|
-
red = x;
|
|
150
|
-
blue = chroma;
|
|
151
|
-
} else {
|
|
152
|
-
red = chroma;
|
|
153
|
-
blue = x;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
red: clampChannel((red + m) * 255),
|
|
158
|
-
green: clampChannel((green + m) * 255),
|
|
159
|
-
blue: clampChannel((blue + m) * 255),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function mergeColorWithExistingAlpha(nextHex: string, previousValue: string): string {
|
|
164
|
-
const hex = nextHex.trim();
|
|
165
|
-
const match = hex.match(/^#([0-9a-f]{6})$/i);
|
|
166
|
-
if (!match) return previousValue;
|
|
167
|
-
|
|
168
|
-
const previous = parseCssColor(previousValue);
|
|
169
|
-
const red = Number.parseInt(match[1].slice(0, 2), 16);
|
|
170
|
-
const green = Number.parseInt(match[1].slice(2, 4), 16);
|
|
171
|
-
const blue = Number.parseInt(match[1].slice(4, 6), 16);
|
|
172
|
-
const alpha = previous?.alpha ?? 1;
|
|
173
|
-
|
|
174
|
-
return formatCssColor({ red, green, blue, alpha });
|
|
175
|
-
}
|