@hyperframes/studio 0.5.0-alpha.7 → 0.5.0-alpha.8
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-C9f5eif8.js +105 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +64 -26
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/domEditing.test.ts +37 -0
- package/src/components/editor/domEditing.ts +4 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +197 -27
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/dist/assets/index-CDSQavT7.js +0 -105
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type TimelineTrackStyle,
|
|
27
27
|
type TimelineTheme,
|
|
28
28
|
} from "./timelineTheme";
|
|
29
|
-
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
29
|
+
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
30
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
31
31
|
|
|
32
32
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
@@ -35,7 +35,7 @@ const TRACK_H = 72;
|
|
|
35
35
|
const RULER_H = 24;
|
|
36
36
|
const CLIP_Y = 3; // vertical inset inside track
|
|
37
37
|
const CLIP_HANDLE_W = 18;
|
|
38
|
-
const TIMELINE_SCROLL_BUFFER =
|
|
38
|
+
const TIMELINE_SCROLL_BUFFER = 20;
|
|
39
39
|
|
|
40
40
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
41
41
|
icon: ReactNode;
|
|
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/* ── Tick Generation ────────────────────────────────────────────── */
|
|
91
|
-
|
|
91
|
+
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
92
|
+
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
93
|
+
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
94
|
+
const targetMajorPx = 128;
|
|
95
|
+
return (
|
|
96
|
+
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
100
|
+
const target = duration / 6;
|
|
101
|
+
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
|
|
105
|
+
let interval = majorInterval / 2;
|
|
106
|
+
if (majorInterval >= 30) interval = majorInterval / 6;
|
|
107
|
+
else if (majorInterval >= 15) interval = majorInterval / 3;
|
|
108
|
+
else if (majorInterval >= 5) interval = majorInterval / 5;
|
|
109
|
+
else if (majorInterval >= 1) interval = majorInterval / 4;
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
Number.isFinite(pixelsPerSecond) &&
|
|
113
|
+
(pixelsPerSecond ?? 0) > 0 &&
|
|
114
|
+
interval * (pixelsPerSecond ?? 0) < 20
|
|
115
|
+
) {
|
|
116
|
+
return Math.max(0.25, majorInterval / 2);
|
|
117
|
+
}
|
|
118
|
+
return Math.max(0.25, interval);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function generateTicks(
|
|
122
|
+
duration: number,
|
|
123
|
+
pixelsPerSecond?: number,
|
|
124
|
+
): { major: number[]; minor: number[] } {
|
|
92
125
|
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
|
93
126
|
return { major: [], minor: [] };
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const majorInterval = intervals.find((i) => i >= target) ?? 60;
|
|
97
|
-
const minorInterval = Math.max(0.25, majorInterval / 2);
|
|
127
|
+
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
128
|
+
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
|
|
98
129
|
const major: number[] = [];
|
|
99
130
|
const minor: number[] = [];
|
|
100
|
-
const maxTicks =
|
|
131
|
+
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
101
132
|
for (
|
|
102
133
|
let t = 0;
|
|
103
134
|
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
|
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
|
|
113
144
|
return { major, minor };
|
|
114
145
|
}
|
|
115
146
|
|
|
147
|
+
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
148
|
+
if (!Number.isFinite(time)) return "0:00";
|
|
149
|
+
const safeTime = Math.max(0, time);
|
|
150
|
+
if (majorInterval < 1) {
|
|
151
|
+
const totalTenths = Math.round(safeTime * 10);
|
|
152
|
+
const wholeSeconds = Math.floor(totalTenths / 10);
|
|
153
|
+
const tenth = totalTenths % 10;
|
|
154
|
+
return `${formatTime(wholeSeconds)}.${tenth}`;
|
|
155
|
+
}
|
|
156
|
+
if (duration >= 3600 || safeTime >= 3600) {
|
|
157
|
+
const totalSeconds = Math.floor(safeTime);
|
|
158
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
159
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
160
|
+
const seconds = totalSeconds % 60;
|
|
161
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
162
|
+
}
|
|
163
|
+
return formatTime(safeTime);
|
|
164
|
+
}
|
|
165
|
+
|
|
116
166
|
export function shouldAutoScrollTimeline(
|
|
117
167
|
zoomMode: ZoomMode,
|
|
118
168
|
scrollWidth: number,
|
|
@@ -131,6 +181,32 @@ export function getTimelineScrollLeftForZoomTransition(
|
|
|
131
181
|
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
|
|
132
182
|
return currentScrollLeft;
|
|
133
183
|
}
|
|
184
|
+
|
|
185
|
+
export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
186
|
+
pointerX: number;
|
|
187
|
+
currentScrollLeft: number;
|
|
188
|
+
gutter: number;
|
|
189
|
+
currentPixelsPerSecond: number;
|
|
190
|
+
nextPixelsPerSecond: number;
|
|
191
|
+
duration: number;
|
|
192
|
+
}): number {
|
|
193
|
+
const currentPps = Math.max(0, input.currentPixelsPerSecond);
|
|
194
|
+
const nextPps = Math.max(0, input.nextPixelsPerSecond);
|
|
195
|
+
if (
|
|
196
|
+
!Number.isFinite(input.pointerX) ||
|
|
197
|
+
!Number.isFinite(input.currentScrollLeft) ||
|
|
198
|
+
!Number.isFinite(input.duration) ||
|
|
199
|
+
input.duration <= 0 ||
|
|
200
|
+
currentPps <= 0 ||
|
|
201
|
+
nextPps <= 0
|
|
202
|
+
) {
|
|
203
|
+
return Math.max(0, input.currentScrollLeft);
|
|
204
|
+
}
|
|
205
|
+
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
|
|
206
|
+
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
|
207
|
+
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
|
|
208
|
+
}
|
|
209
|
+
|
|
134
210
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
135
211
|
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
|
136
212
|
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
|
@@ -140,6 +216,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|
|
140
216
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
141
217
|
}
|
|
142
218
|
|
|
219
|
+
export function shouldShowTimelineShortcutHint(
|
|
220
|
+
scrollHeight: number,
|
|
221
|
+
clientHeight: number,
|
|
222
|
+
): boolean {
|
|
223
|
+
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
224
|
+
return scrollHeight - clientHeight <= 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
143
227
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
144
228
|
key: string;
|
|
145
229
|
metaKey?: boolean;
|
|
@@ -304,6 +388,8 @@ export const Timeline = memo(function Timeline({
|
|
|
304
388
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
305
389
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
306
390
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
391
|
+
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
392
|
+
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
307
393
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
308
394
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
309
395
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -348,30 +434,51 @@ export const Timeline = memo(function Timeline({
|
|
|
348
434
|
onDeleteElementRef.current = onDeleteElement;
|
|
349
435
|
const suppressClickRef = useRef(false);
|
|
350
436
|
const [showPopover, setShowPopover] = useState(false);
|
|
437
|
+
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
351
438
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
352
439
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
440
|
+
const shortcutHintRafRef = useRef(0);
|
|
441
|
+
const syncShortcutHintVisibility = useCallback(() => {
|
|
442
|
+
const scroll = scrollRef.current;
|
|
443
|
+
setShowShortcutHint(
|
|
444
|
+
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
445
|
+
);
|
|
446
|
+
}, []);
|
|
447
|
+
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
448
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
449
|
+
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
450
|
+
shortcutHintRafRef.current = 0;
|
|
451
|
+
syncShortcutHintVisibility();
|
|
452
|
+
});
|
|
453
|
+
}, [syncShortcutHintVisibility]);
|
|
353
454
|
|
|
354
455
|
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
355
456
|
// useMountEffect can't work here because the component returns null on first
|
|
356
457
|
// render (timelineReady=false), so containerRef.current is null when the
|
|
357
458
|
// effect fires and the ResizeObserver is never created.
|
|
358
|
-
const setContainerRef = useCallback(
|
|
359
|
-
|
|
360
|
-
roRef.current
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
459
|
+
const setContainerRef = useCallback(
|
|
460
|
+
(el: HTMLDivElement | null) => {
|
|
461
|
+
if (roRef.current) {
|
|
462
|
+
roRef.current.disconnect();
|
|
463
|
+
roRef.current = null;
|
|
464
|
+
}
|
|
465
|
+
containerRef.current = el;
|
|
466
|
+
if (!el) return;
|
|
467
|
+
setViewportWidth(el.clientWidth);
|
|
468
|
+
scheduleShortcutHintVisibilitySync();
|
|
469
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
470
|
+
setViewportWidth(entry.contentRect.width);
|
|
471
|
+
scheduleShortcutHintVisibilitySync();
|
|
472
|
+
});
|
|
473
|
+
roRef.current.observe(el);
|
|
474
|
+
},
|
|
475
|
+
[scheduleShortcutHintVisibilitySync],
|
|
476
|
+
);
|
|
371
477
|
|
|
372
478
|
// Clean up ResizeObserver on unmount
|
|
373
479
|
useMountEffect(() => () => {
|
|
374
480
|
roRef.current?.disconnect();
|
|
481
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
375
482
|
});
|
|
376
483
|
|
|
377
484
|
// Effective duration: max of store duration and the furthest element end.
|
|
@@ -416,6 +523,7 @@ export const Timeline = memo(function Timeline({
|
|
|
416
523
|
}
|
|
417
524
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
418
525
|
}, [draggedClip, trackOrder]);
|
|
526
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
419
527
|
const selectedElement = useMemo(
|
|
420
528
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
421
529
|
[elements, selectedElementId],
|
|
@@ -433,7 +541,11 @@ export const Timeline = memo(function Timeline({
|
|
|
433
541
|
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
434
542
|
const zoomModeRef = useRef(zoomMode);
|
|
435
543
|
zoomModeRef.current = zoomMode;
|
|
544
|
+
const manualZoomPercentRef = useRef(manualZoomPercent);
|
|
545
|
+
manualZoomPercentRef.current = manualZoomPercent;
|
|
436
546
|
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
547
|
+
const fitPpsRef = useRef(fitPps);
|
|
548
|
+
fitPpsRef.current = fitPps;
|
|
437
549
|
|
|
438
550
|
const durationRef = useRef(effectiveDuration);
|
|
439
551
|
durationRef.current = effectiveDuration;
|
|
@@ -922,7 +1034,15 @@ export const Timeline = memo(function Timeline({
|
|
|
922
1034
|
cancelAnimationFrame(dragScrollRaf.current);
|
|
923
1035
|
}, []);
|
|
924
1036
|
|
|
925
|
-
const { major, minor } = useMemo(
|
|
1037
|
+
const { major, minor } = useMemo(
|
|
1038
|
+
() => generateTicks(effectiveDuration, pps),
|
|
1039
|
+
[effectiveDuration, pps],
|
|
1040
|
+
);
|
|
1041
|
+
const majorTickInterval =
|
|
1042
|
+
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
1043
|
+
useEffect(() => {
|
|
1044
|
+
syncShortcutHintVisibility();
|
|
1045
|
+
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
926
1046
|
const getPreviewElement = useCallback(
|
|
927
1047
|
(element: TimelineElement): TimelineElement => {
|
|
928
1048
|
if (resizingClip?.element.id === element.id) {
|
|
@@ -1008,6 +1128,57 @@ export const Timeline = memo(function Timeline({
|
|
|
1008
1128
|
[onAssetDrop, onFileDrop],
|
|
1009
1129
|
);
|
|
1010
1130
|
|
|
1131
|
+
const handlePinchWheel = useCallback(
|
|
1132
|
+
(e: WheelEvent) => {
|
|
1133
|
+
if (!e.ctrlKey) return;
|
|
1134
|
+
const scroll = scrollRef.current;
|
|
1135
|
+
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
e.preventDefault();
|
|
1140
|
+
e.stopPropagation();
|
|
1141
|
+
|
|
1142
|
+
const rect = scroll.getBoundingClientRect();
|
|
1143
|
+
const pointerX = e.clientX - rect.left;
|
|
1144
|
+
const nextZoomPercent = getPinchTimelineZoomPercent(
|
|
1145
|
+
e.deltaY,
|
|
1146
|
+
zoomModeRef.current,
|
|
1147
|
+
manualZoomPercentRef.current,
|
|
1148
|
+
);
|
|
1149
|
+
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
|
|
1154
|
+
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
1155
|
+
pointerX,
|
|
1156
|
+
currentScrollLeft: scroll.scrollLeft,
|
|
1157
|
+
gutter: GUTTER,
|
|
1158
|
+
currentPixelsPerSecond: ppsRef.current,
|
|
1159
|
+
nextPixelsPerSecond: nextPps,
|
|
1160
|
+
duration: durationRef.current,
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
setZoomMode("manual");
|
|
1164
|
+
setManualZoomPercent(nextZoomPercent);
|
|
1165
|
+
requestAnimationFrame(() => {
|
|
1166
|
+
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
1167
|
+
scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
|
|
1168
|
+
});
|
|
1169
|
+
},
|
|
1170
|
+
[setManualZoomPercent, setZoomMode],
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
useEffect(() => {
|
|
1174
|
+
const scroll = scrollRef.current;
|
|
1175
|
+
if (!scroll) return;
|
|
1176
|
+
scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
|
|
1177
|
+
return () => {
|
|
1178
|
+
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
|
|
1179
|
+
};
|
|
1180
|
+
}, [handlePinchWheel, timelineReady, elements.length]);
|
|
1181
|
+
|
|
1011
1182
|
if (!timelineReady || elements.length === 0) {
|
|
1012
1183
|
return (
|
|
1013
1184
|
<div
|
|
@@ -1096,7 +1267,6 @@ export const Timeline = memo(function Timeline({
|
|
|
1096
1267
|
);
|
|
1097
1268
|
}
|
|
1098
1269
|
|
|
1099
|
-
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
1100
1270
|
const draggedElement = draggedClip?.element ?? null;
|
|
1101
1271
|
const activeDraggedElement =
|
|
1102
1272
|
draggedClip?.started === true && draggedElement
|
|
@@ -1170,7 +1340,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1170
1340
|
<div
|
|
1171
1341
|
ref={setContainerRef}
|
|
1172
1342
|
aria-label="Timeline"
|
|
1173
|
-
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1343
|
+
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1174
1344
|
style={{
|
|
1175
1345
|
touchAction: "pan-x pan-y",
|
|
1176
1346
|
background: theme.shellBackground,
|
|
@@ -1239,7 +1409,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1239
1409
|
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
|
1240
1410
|
style={{ color: theme.tickText }}
|
|
1241
1411
|
>
|
|
1242
|
-
{
|
|
1412
|
+
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
|
1243
1413
|
</span>
|
|
1244
1414
|
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
|
1245
1415
|
</div>
|
|
@@ -1509,8 +1679,8 @@ export const Timeline = memo(function Timeline({
|
|
|
1509
1679
|
</div>
|
|
1510
1680
|
</div>
|
|
1511
1681
|
|
|
1512
|
-
{/* Keyboard shortcut hint
|
|
1513
|
-
{!showPopover && !rangeSelection && (
|
|
1682
|
+
{/* Keyboard shortcut hint */}
|
|
1683
|
+
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
1514
1684
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1515
1685
|
<div
|
|
1516
1686
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
clampTimelineZoomPercent,
|
|
4
4
|
getNextTimelineZoomPercent,
|
|
5
|
+
getPinchTimelineZoomPercent,
|
|
5
6
|
getTimelinePixelsPerSecond,
|
|
6
7
|
getTimelineZoomPercent,
|
|
7
8
|
MAX_TIMELINE_ZOOM_PERCENT,
|
|
@@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => {
|
|
|
60
61
|
);
|
|
61
62
|
});
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
describe("getPinchTimelineZoomPercent", () => {
|
|
66
|
+
it("zooms in for upward pinch wheel deltas", () => {
|
|
67
|
+
expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("zooms out for downward pinch wheel deltas", () => {
|
|
71
|
+
expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the current zoom for zero or invalid deltas", () => {
|
|
75
|
+
expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
|
|
76
|
+
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clamps pinch zoom to the supported range", () => {
|
|
80
|
+
expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
81
|
+
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
|
|
4
4
|
export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
|
|
5
5
|
const ZOOM_OUT_FACTOR = 0.8;
|
|
6
6
|
const ZOOM_IN_FACTOR = 1.25;
|
|
7
|
+
const PINCH_ZOOM_SENSITIVITY = 0.0035;
|
|
7
8
|
|
|
8
9
|
export function clampTimelineZoomPercent(percent: number): number {
|
|
9
10
|
if (!Number.isFinite(percent)) return 100;
|
|
@@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent(
|
|
|
36
37
|
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
|
37
38
|
return clampTimelineZoomPercent(next);
|
|
38
39
|
}
|
|
40
|
+
|
|
41
|
+
export function getPinchTimelineZoomPercent(
|
|
42
|
+
deltaY: number,
|
|
43
|
+
zoomMode: ZoomMode,
|
|
44
|
+
manualZoomPercent: number,
|
|
45
|
+
): number {
|
|
46
|
+
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
47
|
+
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
|
48
|
+
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
|
49
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
3
|
+
import { copyTextToClipboard } from "./clipboard";
|
|
4
|
+
|
|
5
|
+
function installDocument(execCommand: (command: string) => boolean): void {
|
|
6
|
+
const window = new Window();
|
|
7
|
+
Object.defineProperty(window.document, "execCommand", {
|
|
8
|
+
configurable: true,
|
|
9
|
+
value: execCommand,
|
|
10
|
+
});
|
|
11
|
+
vi.stubGlobal("document", window.document);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function installNavigator(
|
|
15
|
+
writeText: (text: string) => Promise<void>,
|
|
16
|
+
userAgent = "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
|
|
17
|
+
): void {
|
|
18
|
+
vi.stubGlobal("navigator", {
|
|
19
|
+
clipboard: { writeText },
|
|
20
|
+
userAgent,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("copyTextToClipboard", () => {
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("uses the synchronous selection copy path first in Safari", async () => {
|
|
30
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
31
|
+
const writeText = vi.fn((_text: string) => Promise.resolve());
|
|
32
|
+
|
|
33
|
+
installDocument(execCommand);
|
|
34
|
+
installNavigator(
|
|
35
|
+
writeText,
|
|
36
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
40
|
+
|
|
41
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
42
|
+
expect(writeText).not.toHaveBeenCalled();
|
|
43
|
+
expect(document.querySelector("textarea")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("uses navigator.clipboard first outside Safari", async () => {
|
|
47
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
48
|
+
const writeText = vi.fn((_text: string) => Promise.resolve());
|
|
49
|
+
|
|
50
|
+
installDocument(execCommand);
|
|
51
|
+
installNavigator(writeText);
|
|
52
|
+
|
|
53
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
54
|
+
|
|
55
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
56
|
+
expect(execCommand).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("falls back to selection copy outside Safari when navigator.clipboard fails", async () => {
|
|
60
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
61
|
+
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
|
62
|
+
|
|
63
|
+
installDocument(execCommand);
|
|
64
|
+
installNavigator(writeText);
|
|
65
|
+
|
|
66
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
67
|
+
|
|
68
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
69
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reports failure when both copy paths fail", async () => {
|
|
73
|
+
const execCommand = vi.fn(() => false);
|
|
74
|
+
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
|
75
|
+
|
|
76
|
+
installDocument(execCommand);
|
|
77
|
+
installNavigator(
|
|
78
|
+
writeText,
|
|
79
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(false);
|
|
83
|
+
|
|
84
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
85
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
86
|
+
expect(document.querySelector("textarea")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function copyWithSelection(text: string): boolean {
|
|
2
|
+
if (typeof document === "undefined" || !document.body || !document.execCommand) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const textarea = document.createElement("textarea");
|
|
7
|
+
textarea.value = text;
|
|
8
|
+
textarea.setAttribute("readonly", "true");
|
|
9
|
+
textarea.style.position = "fixed";
|
|
10
|
+
textarea.style.top = "0";
|
|
11
|
+
textarea.style.left = "0";
|
|
12
|
+
textarea.style.width = "1px";
|
|
13
|
+
textarea.style.height = "1px";
|
|
14
|
+
textarea.style.padding = "0";
|
|
15
|
+
textarea.style.border = "0";
|
|
16
|
+
textarea.style.opacity = "0";
|
|
17
|
+
textarea.style.pointerEvents = "none";
|
|
18
|
+
|
|
19
|
+
document.body.appendChild(textarea);
|
|
20
|
+
textarea.focus({ preventScroll: true });
|
|
21
|
+
textarea.select();
|
|
22
|
+
textarea.setSelectionRange(0, text.length);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return document.execCommand("copy");
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
} finally {
|
|
29
|
+
document.body.removeChild(textarea);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shouldCopyWithSelectionFirst(): boolean {
|
|
34
|
+
if (typeof navigator === "undefined") return false;
|
|
35
|
+
|
|
36
|
+
const userAgent = navigator.userAgent;
|
|
37
|
+
return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR/i.test(userAgent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
|
41
|
+
const useSelectionFirst = shouldCopyWithSelectionFirst();
|
|
42
|
+
if (useSelectionFirst && copyWithSelection(text)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
|
47
|
+
if (clipboard?.writeText) {
|
|
48
|
+
try {
|
|
49
|
+
await clipboard.writeText(text);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
// Fall back below when the browser still allows synchronous copy.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return !useSelectionFirst && copyWithSelection(text);
|
|
57
|
+
}
|