@hyperframes/studio 0.5.0-alpha.6 → 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
package/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-C9f5eif8.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-0Zt0t13W.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.8",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.5.0-alpha.
|
|
36
|
-
"@hyperframes/player": "0.5.0-alpha.
|
|
35
|
+
"@hyperframes/core": "0.5.0-alpha.8",
|
|
36
|
+
"@hyperframes/player": "0.5.0-alpha.8"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.5.0-alpha.
|
|
50
|
+
"@hyperframes/producer": "0.5.0-alpha.8"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -28,6 +28,7 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
28
28
|
import { useCaptionStore } from "./captions/store";
|
|
29
29
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
30
30
|
import { parseCaptionComposition } from "./captions/parser";
|
|
31
|
+
import { copyTextToClipboard } from "./utils/clipboard";
|
|
31
32
|
import {
|
|
32
33
|
applyPatchByTarget,
|
|
33
34
|
readAttributeByTarget,
|
|
@@ -166,6 +167,23 @@ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): stri
|
|
|
166
167
|
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
171
|
+
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
|
|
175
|
+
const trimmedSource = sourceFile.trim();
|
|
176
|
+
if (!trimmedSource) return undefined;
|
|
177
|
+
|
|
178
|
+
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
179
|
+
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
180
|
+
|
|
181
|
+
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
182
|
+
if (!normalizedRoot) return undefined;
|
|
183
|
+
|
|
184
|
+
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
function ensureImportedFontFace(
|
|
170
188
|
html: string,
|
|
171
189
|
asset: ImportedFontAsset,
|
|
@@ -574,6 +592,7 @@ export function StudioApp() {
|
|
|
574
592
|
});
|
|
575
593
|
|
|
576
594
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
595
|
+
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
577
596
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
578
597
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
579
598
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -589,6 +608,7 @@ export function StudioApp() {
|
|
|
589
608
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
590
609
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
591
610
|
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
611
|
+
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
592
612
|
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
593
613
|
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
594
614
|
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
@@ -1036,10 +1056,13 @@ export function StudioApp() {
|
|
|
1036
1056
|
let cancelled = false;
|
|
1037
1057
|
fetch(`/api/projects/${projectId}`)
|
|
1038
1058
|
.then((r) => r.json())
|
|
1039
|
-
.then((data: { files?: string[] }) => {
|
|
1059
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
1040
1060
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1061
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
1041
1062
|
})
|
|
1042
|
-
.catch(() => {
|
|
1063
|
+
.catch(() => {
|
|
1064
|
+
if (!cancelled) setProjectDir(null);
|
|
1065
|
+
});
|
|
1043
1066
|
return () => {
|
|
1044
1067
|
cancelled = true;
|
|
1045
1068
|
};
|
|
@@ -1428,6 +1451,7 @@ export function StudioApp() {
|
|
|
1428
1451
|
const applyDomSelection = useCallback(
|
|
1429
1452
|
(selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
|
|
1430
1453
|
setDomEditSelection(selection);
|
|
1454
|
+
setAgentPromptTagSnippet(undefined);
|
|
1431
1455
|
setCopiedAgentPrompt(false);
|
|
1432
1456
|
if (selection) {
|
|
1433
1457
|
if (options?.revealPanel !== false) {
|
|
@@ -1490,6 +1514,34 @@ export function StudioApp() {
|
|
|
1490
1514
|
[activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
|
|
1491
1515
|
);
|
|
1492
1516
|
|
|
1517
|
+
const preloadAgentPromptSnippet = useCallback(
|
|
1518
|
+
async (selection: DomEditSelection) => {
|
|
1519
|
+
const pid = projectIdRef.current;
|
|
1520
|
+
if (!pid) return;
|
|
1521
|
+
|
|
1522
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
1523
|
+
try {
|
|
1524
|
+
const response = await fetch(
|
|
1525
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1526
|
+
);
|
|
1527
|
+
if (!response.ok) return;
|
|
1528
|
+
|
|
1529
|
+
const data = (await response.json()) as { content?: string };
|
|
1530
|
+
const html = data.content;
|
|
1531
|
+
const tagSnippet =
|
|
1532
|
+
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
|
1533
|
+
|
|
1534
|
+
setAgentPromptTagSnippet((current) => {
|
|
1535
|
+
if (domEditSelectionRef.current !== selection) return current;
|
|
1536
|
+
return tagSnippet;
|
|
1537
|
+
});
|
|
1538
|
+
} catch {
|
|
1539
|
+
// Runtime outerHTML is still available as a synchronous copy fallback.
|
|
1540
|
+
}
|
|
1541
|
+
},
|
|
1542
|
+
[activeCompPath],
|
|
1543
|
+
);
|
|
1544
|
+
|
|
1493
1545
|
const resolveImportedFontAsset = useCallback(
|
|
1494
1546
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
1495
1547
|
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
@@ -1892,43 +1944,29 @@ export function StudioApp() {
|
|
|
1892
1944
|
|
|
1893
1945
|
const handleAskAgent = useCallback(() => {
|
|
1894
1946
|
if (!domEditSelection) return;
|
|
1947
|
+
setAgentPromptTagSnippet(undefined);
|
|
1948
|
+
void preloadAgentPromptSnippet(domEditSelection);
|
|
1895
1949
|
setAgentModalOpen(true);
|
|
1896
|
-
}, [domEditSelection]);
|
|
1950
|
+
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
1897
1951
|
|
|
1898
1952
|
const handleAgentModalSubmit = useCallback(
|
|
1899
1953
|
async (userInstruction: string) => {
|
|
1900
1954
|
if (!domEditSelection) return;
|
|
1901
1955
|
|
|
1902
|
-
const pid = projectIdRef.current;
|
|
1903
|
-
if (!pid) return;
|
|
1904
|
-
|
|
1905
1956
|
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
1906
|
-
const
|
|
1907
|
-
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
|
1908
|
-
|
|
1909
|
-
const data = (await response.json()) as { content?: string };
|
|
1910
|
-
const html = data.content;
|
|
1911
|
-
const tagSnippet =
|
|
1912
|
-
typeof html === "string" ? readTagSnippetByTarget(html, domEditSelection) : undefined;
|
|
1957
|
+
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
|
1913
1958
|
const prompt = buildElementAgentPrompt({
|
|
1914
1959
|
selection: domEditSelection,
|
|
1915
1960
|
currentTime,
|
|
1916
1961
|
tagSnippet,
|
|
1917
1962
|
userInstruction,
|
|
1963
|
+
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
1918
1964
|
});
|
|
1919
1965
|
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
textarea.value = prompt;
|
|
1925
|
-
textarea.setAttribute("readonly", "true");
|
|
1926
|
-
textarea.style.position = "fixed";
|
|
1927
|
-
textarea.style.opacity = "0";
|
|
1928
|
-
document.body.appendChild(textarea);
|
|
1929
|
-
textarea.select();
|
|
1930
|
-
document.execCommand("copy");
|
|
1931
|
-
document.body.removeChild(textarea);
|
|
1966
|
+
const copied = await copyTextToClipboard(prompt);
|
|
1967
|
+
if (!copied) {
|
|
1968
|
+
showToast("Could not copy prompt to clipboard.", "error");
|
|
1969
|
+
return;
|
|
1932
1970
|
}
|
|
1933
1971
|
|
|
1934
1972
|
setAgentModalOpen(false);
|
|
@@ -1936,7 +1974,7 @@ export function StudioApp() {
|
|
|
1936
1974
|
setCopiedAgentPrompt(true);
|
|
1937
1975
|
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
1938
1976
|
},
|
|
1939
|
-
[activeCompPath, currentTime, domEditSelection],
|
|
1977
|
+
[activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
|
|
1940
1978
|
);
|
|
1941
1979
|
|
|
1942
1980
|
const handlePreviewIframeRef = useCallback(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
|
|
3
|
+
import { copyTextToClipboard } from "../utils/clipboard";
|
|
3
4
|
|
|
4
5
|
export interface LintFinding {
|
|
5
6
|
severity: "error" | "warning";
|
|
@@ -30,12 +31,10 @@ export function LintModal({
|
|
|
30
31
|
return line;
|
|
31
32
|
});
|
|
32
33
|
const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const copiedText = await copyTextToClipboard(text);
|
|
35
|
+
if (copiedText) {
|
|
35
36
|
setCopied(true);
|
|
36
37
|
setTimeout(() => setCopied(false), 2000);
|
|
37
|
-
} catch {
|
|
38
|
-
// ignore
|
|
39
38
|
}
|
|
40
39
|
};
|
|
41
40
|
|
|
@@ -467,6 +467,43 @@ describe("patch builders and prompt builder", () => {
|
|
|
467
467
|
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
|
|
468
468
|
});
|
|
469
469
|
|
|
470
|
+
it("uses an absolute source path in copied agent prompts when provided", () => {
|
|
471
|
+
const selection = {
|
|
472
|
+
element: {} as HTMLElement,
|
|
473
|
+
id: "editable-card",
|
|
474
|
+
selector: "#editable-card",
|
|
475
|
+
selectorIndex: undefined,
|
|
476
|
+
sourceFile: "index.html",
|
|
477
|
+
compositionPath: "index.html",
|
|
478
|
+
compositionSrc: undefined,
|
|
479
|
+
isCompositionHost: false,
|
|
480
|
+
label: "Drag me first",
|
|
481
|
+
tagName: "div",
|
|
482
|
+
boundingBox: { x: 108, y: 112, width: 380, height: 196 },
|
|
483
|
+
textContent: "Drag me first",
|
|
484
|
+
dataAttributes: {},
|
|
485
|
+
inlineStyles: {},
|
|
486
|
+
computedStyles: {},
|
|
487
|
+
textFields: [],
|
|
488
|
+
capabilities: {
|
|
489
|
+
canSelect: true,
|
|
490
|
+
canEditStyles: true,
|
|
491
|
+
canMove: true,
|
|
492
|
+
canResize: true,
|
|
493
|
+
canDetachFromLayout: false,
|
|
494
|
+
},
|
|
495
|
+
} satisfies DomEditSelection;
|
|
496
|
+
|
|
497
|
+
const prompt = buildElementAgentPrompt({
|
|
498
|
+
selection,
|
|
499
|
+
currentTime: 1.25,
|
|
500
|
+
sourceFilePath: "/tmp/hf-studio-project/index.html",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
|
|
504
|
+
expect(prompt).not.toContain("Source file: index.html");
|
|
505
|
+
});
|
|
506
|
+
|
|
470
507
|
it("serializes child text fields back into HTML", () => {
|
|
471
508
|
expect(
|
|
472
509
|
serializeDomEditTextFields([
|
|
@@ -697,12 +697,15 @@ export function buildElementAgentPrompt({
|
|
|
697
697
|
currentTime,
|
|
698
698
|
tagSnippet,
|
|
699
699
|
userInstruction,
|
|
700
|
+
sourceFilePath,
|
|
700
701
|
}: {
|
|
701
702
|
selection: DomEditSelection;
|
|
702
703
|
currentTime: number;
|
|
703
704
|
tagSnippet?: string;
|
|
704
705
|
userInstruction?: string;
|
|
706
|
+
sourceFilePath?: string;
|
|
705
707
|
}): string {
|
|
708
|
+
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
|
706
709
|
const lines = [
|
|
707
710
|
"## HyperFrames element edit request v1",
|
|
708
711
|
"Schema version: 1",
|
|
@@ -711,7 +714,7 @@ export function buildElementAgentPrompt({
|
|
|
711
714
|
"",
|
|
712
715
|
`Composition: ${selection.compositionPath}`,
|
|
713
716
|
`Playback time: ${formatTime(currentTime)}`,
|
|
714
|
-
`Source file: ${
|
|
717
|
+
`Source file: ${displayedSourceFile}`,
|
|
715
718
|
`DOM id: ${selection.id ?? "(none)"}`,
|
|
716
719
|
`Selector: ${selection.selector ?? "(none)"}`,
|
|
717
720
|
`Selector index: ${selection.selectorIndex ?? 0}`,
|
|
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
5
6
|
|
|
6
7
|
interface AssetsTabProps {
|
|
7
8
|
projectId: string;
|
|
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
298
299
|
);
|
|
299
300
|
|
|
300
301
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
const copied = await copyTextToClipboard(path);
|
|
303
|
+
if (copied) {
|
|
303
304
|
setCopiedPath(path);
|
|
304
305
|
setTimeout(() => setCopiedPath(null), 1500);
|
|
305
|
-
} catch {
|
|
306
|
-
// ignore
|
|
307
306
|
}
|
|
308
307
|
}, []);
|
|
309
308
|
|
|
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
5
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
6
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
7
|
|
|
7
8
|
interface EditPopoverProps {
|
|
8
9
|
rangeStart: number;
|
|
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
62
63
|
}, [start, end, elementsInRange, prompt]);
|
|
63
64
|
|
|
64
65
|
const handleCopy = useCallback(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} catch {
|
|
68
|
-
const ta = document.createElement("textarea");
|
|
69
|
-
ta.value = buildClipboardText();
|
|
70
|
-
document.body.appendChild(ta);
|
|
71
|
-
ta.select();
|
|
72
|
-
document.execCommand("copy");
|
|
73
|
-
document.body.removeChild(ta);
|
|
74
|
-
}
|
|
66
|
+
const copied = await copyTextToClipboard(buildClipboardText());
|
|
67
|
+
if (!copied) return;
|
|
75
68
|
setCopiedAgentPrompt(true);
|
|
76
69
|
setTimeout(() => {
|
|
77
70
|
setCopiedAgentPrompt(false);
|
|
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
82
75
|
const handleCopyPrompt = useCallback(async () => {
|
|
83
76
|
const promptText = buildPromptCopyText(prompt);
|
|
84
77
|
if (!promptText) return;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
const ta = document.createElement("textarea");
|
|
89
|
-
ta.value = promptText;
|
|
90
|
-
document.body.appendChild(ta);
|
|
91
|
-
ta.select();
|
|
92
|
-
document.execCommand("copy");
|
|
93
|
-
document.body.removeChild(ta);
|
|
94
|
-
}
|
|
78
|
+
const copied = await copyTextToClipboard(promptText);
|
|
79
|
+
if (!copied) return;
|
|
95
80
|
setCopiedPromptOnly(true);
|
|
96
81
|
setTimeout(() => {
|
|
97
82
|
setCopiedPromptOnly(false);
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
formatTimelineTickLabel,
|
|
3
4
|
generateTicks,
|
|
4
5
|
getDefaultDroppedTrack,
|
|
5
6
|
getTimelineCanvasHeight,
|
|
6
7
|
resolveTimelineAssetDrop,
|
|
7
8
|
getTimelinePlayheadLeft,
|
|
9
|
+
getTimelineScrollLeftForZoomAnchor,
|
|
8
10
|
getTimelineScrollLeftForZoomTransition,
|
|
11
|
+
shouldShowTimelineShortcutHint,
|
|
9
12
|
shouldHandleTimelineDeleteKey,
|
|
10
13
|
shouldAutoScrollTimeline,
|
|
11
14
|
} from "./Timeline";
|
|
@@ -78,6 +81,20 @@ describe("generateTicks", () => {
|
|
|
78
81
|
expect(major[0]).toBe(0);
|
|
79
82
|
}
|
|
80
83
|
});
|
|
84
|
+
|
|
85
|
+
it("uses denser major labels as timeline zoom increases", () => {
|
|
86
|
+
const fitTicks = generateTicks(180, 10);
|
|
87
|
+
const zoomedTicks = generateTicks(180, 48);
|
|
88
|
+
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
|
|
89
|
+
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
|
|
90
|
+
expect(zoomedTicks.minor).toContain(1);
|
|
91
|
+
expect(zoomedTicks.minor).toContain(4);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps labels readable instead of placing one at every tiny tick", () => {
|
|
95
|
+
const { major } = generateTicks(180, 80);
|
|
96
|
+
expect(major[1] - major[0]).toBe(2);
|
|
97
|
+
});
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
describe("formatTime", () => {
|
|
@@ -118,6 +135,20 @@ describe("formatTime", () => {
|
|
|
118
135
|
});
|
|
119
136
|
});
|
|
120
137
|
|
|
138
|
+
describe("formatTimelineTickLabel", () => {
|
|
139
|
+
it("uses minute-second labels for normal timeline intervals", () => {
|
|
140
|
+
expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("uses hour labels for long timelines", () => {
|
|
144
|
+
expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("shows subsecond labels when the major ruler interval is below one second", () => {
|
|
148
|
+
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
121
152
|
describe("shouldAutoScrollTimeline", () => {
|
|
122
153
|
it("never auto-scrolls in fit mode", () => {
|
|
123
154
|
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
|
@@ -144,6 +175,48 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
|
144
175
|
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
|
145
176
|
});
|
|
146
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("getTimelineScrollLeftForZoomAnchor", () => {
|
|
180
|
+
it("preserves the time under the pointer when zooming in", () => {
|
|
181
|
+
expect(
|
|
182
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
183
|
+
pointerX: 300,
|
|
184
|
+
currentScrollLeft: 200,
|
|
185
|
+
gutter: 32,
|
|
186
|
+
currentPixelsPerSecond: 10,
|
|
187
|
+
nextPixelsPerSecond: 20,
|
|
188
|
+
duration: 120,
|
|
189
|
+
}),
|
|
190
|
+
).toBe(668);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("clamps negative scroll targets", () => {
|
|
194
|
+
expect(
|
|
195
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
196
|
+
pointerX: 300,
|
|
197
|
+
currentScrollLeft: 0,
|
|
198
|
+
gutter: 32,
|
|
199
|
+
currentPixelsPerSecond: 20,
|
|
200
|
+
nextPixelsPerSecond: 5,
|
|
201
|
+
duration: 120,
|
|
202
|
+
}),
|
|
203
|
+
).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("preserves current scroll when inputs are invalid", () => {
|
|
207
|
+
expect(
|
|
208
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
209
|
+
pointerX: 300,
|
|
210
|
+
currentScrollLeft: 120,
|
|
211
|
+
gutter: 32,
|
|
212
|
+
currentPixelsPerSecond: 0,
|
|
213
|
+
nextPixelsPerSecond: 20,
|
|
214
|
+
duration: 120,
|
|
215
|
+
}),
|
|
216
|
+
).toBe(120);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
147
220
|
describe("getTimelinePlayheadLeft", () => {
|
|
148
221
|
it("converts time to a pixel offset from the gutter", () => {
|
|
149
222
|
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
|
@@ -165,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
|
|
|
165
238
|
});
|
|
166
239
|
});
|
|
167
240
|
|
|
241
|
+
describe("shouldShowTimelineShortcutHint", () => {
|
|
242
|
+
it("shows the hint when the timeline does not vertically overflow", () => {
|
|
243
|
+
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
|
|
244
|
+
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("hides the hint when timeline tracks need vertical scrolling", () => {
|
|
248
|
+
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
168
252
|
describe("shouldHandleTimelineDeleteKey", () => {
|
|
169
253
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
170
254
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|