retake-dev 0.5.0 → 0.5.1
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/CHANGELOG.md +76 -1
- package/README.md +141 -7
- package/bin/retake.js +14 -1
- package/package.json +6 -1
- package/src/core.js +14 -1
- package/src/next-dev.js +183 -0
- package/src/next.js +81 -0
- package/src/note-text.js +844 -0
- package/src/plugin.js +6 -2
- package/src/runtime/10-animations.js +9 -0
- package/src/runtime/60-preview.js +3 -1
- package/src/runtime/62-motion.js +106 -0
- package/src/runtime/65-timeline.js +39 -0
- package/src/runtime/67-csssource.js +73 -1
- package/src/runtime/70-boot.js +9 -2
- package/src/server/api.js +73 -1
- package/src/server/front.js +3 -1
- package/src/server/mcp.js +213 -16
- package/src/server/moments.js +317 -0
- package/src/server/sourcemap.js +192 -0
- package/src/shell/10-dock.js +27 -5
- package/src/shell/15-session.js +11 -5
- package/src/shell/20-timeline.js +30 -5
- package/src/shell/25-input.js +25 -2
- package/src/shell/30-notes.js +704 -110
- package/src/shell/32-anims.js +1353 -0
- package/src/shell/shell.css +24 -0
- package/src/shell/shell.html +1 -0
- package/types/client.d.ts +7 -0
- package/types/index.d.ts +2 -0
- package/types/next.d.ts +50 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// A compiled bundle's line → the original file:line, through the bundle's
|
|
2
|
+
// source map. For notes whose source is a served chunk (Next/Turbopack and
|
|
3
|
+
// webpack chunks, Vite's optimized deps, a production-style bundle) rather
|
|
4
|
+
// than the app's own file. No dependencies: a VLQ decoder, and index maps
|
|
5
|
+
// (`sections`, as Turbopack writes them) read section by section.
|
|
6
|
+
// The dock has the same logic in src/shell/30-notes.js (it can't import).
|
|
7
|
+
|
|
8
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
9
|
+
|
|
10
|
+
/** "mappings" → per generated line, segments [genCol, sourceIndex, origLine, origCol] (0-based). */
|
|
11
|
+
export function decodeMappings(mappings) {
|
|
12
|
+
const lines = []
|
|
13
|
+
let src = 0, oLine = 0, oCol = 0
|
|
14
|
+
for (const text of String(mappings || "").split(";")) {
|
|
15
|
+
const segs = []
|
|
16
|
+
let gCol = 0
|
|
17
|
+
for (const seg of text.split(",")) {
|
|
18
|
+
if (!seg) continue
|
|
19
|
+
const v = []
|
|
20
|
+
for (let i = 0, shift = 0, acc = 0; i < seg.length; i++) {
|
|
21
|
+
const d = B64.indexOf(seg[i])
|
|
22
|
+
if (d < 0) break
|
|
23
|
+
acc += (d & 31) << shift
|
|
24
|
+
if (d & 32) shift += 5
|
|
25
|
+
else {
|
|
26
|
+
v.push(acc & 1 ? -(acc >> 1) : acc >> 1)
|
|
27
|
+
acc = shift = 0
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
gCol += v[0] || 0
|
|
31
|
+
if (v.length >= 4) {
|
|
32
|
+
src += v[1]
|
|
33
|
+
oLine += v[2]
|
|
34
|
+
oCol += v[3]
|
|
35
|
+
segs.push([gCol, src, oLine, oCol])
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
lines.push(segs)
|
|
39
|
+
}
|
|
40
|
+
return lines
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const decoded = new WeakMap()
|
|
44
|
+
/**
|
|
45
|
+
* Where generated line/column (1-based; col may be null) came from.
|
|
46
|
+
* @returns {{ source: string, line: number, sources: string[] } | null}
|
|
47
|
+
*/
|
|
48
|
+
export function originalPosition(map, line, col) {
|
|
49
|
+
if (!map || !(line > 0)) return null
|
|
50
|
+
if (Array.isArray(map.sections)) {
|
|
51
|
+
const L = line - 1
|
|
52
|
+
const C = col > 0 ? col - 1 : null
|
|
53
|
+
/** @type {any} */
|
|
54
|
+
let hit = null
|
|
55
|
+
for (const s of map.sections) {
|
|
56
|
+
const o = s.offset || { line: 0, column: 0 }
|
|
57
|
+
if (o.line < L || (o.line === L && (C == null || o.column <= C))) hit = s
|
|
58
|
+
else break
|
|
59
|
+
}
|
|
60
|
+
if (!hit || !hit.map) return null
|
|
61
|
+
const o = hit.offset || { line: 0, column: 0 }
|
|
62
|
+
const c = C == null ? null : (L === o.line ? C - o.column : C) + 1
|
|
63
|
+
const pos = originalPosition(hit.map, L - o.line + 1, c)
|
|
64
|
+
return pos && { ...pos, sources: map.sections.flatMap((x) => (x.map && x.map.sources) || []) }
|
|
65
|
+
}
|
|
66
|
+
let lines = decoded.get(map)
|
|
67
|
+
if (!lines) decoded.set(map, (lines = decodeMappings(map.mappings)))
|
|
68
|
+
const segs = lines[line - 1]
|
|
69
|
+
if (!segs || !segs.length) return null
|
|
70
|
+
let best = segs[0]
|
|
71
|
+
if (col > 0) for (const s of segs) if (s[0] <= col - 1) best = s
|
|
72
|
+
const sources = (map.sources || []).map((s) => (map.sourceRoot && s && !/^[a-z]+:/i.test(s) && !s.startsWith("/") ? map.sourceRoot.replace(/\/?$/, "/") + s : s))
|
|
73
|
+
const source = sources[best[1]]
|
|
74
|
+
return source ? { source, line: best[2] + 1, sources } : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Library code (not where the app's own element was written). */
|
|
78
|
+
export const isLibrary = (file) => /(^|\/)node_modules\/|\/\.vite\/deps\/|react-dom|jsx-dev-runtime|jsx-runtime|\[turbopack\]|turbopack\/|webpack\/(bootstrap|runtime)/.test(String(file || ""))
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A served bundle rather than a source file: Next's /_next/static chunks,
|
|
82
|
+
* webpack/Turbopack chunk names, Vite's deps, hashed build assets.
|
|
83
|
+
*/
|
|
84
|
+
export function isCompiled(file) {
|
|
85
|
+
const f = String(file || "").replace(/[?#].*$/, "")
|
|
86
|
+
return (
|
|
87
|
+
/\/_next\/static\/|\/\.vite\/deps\/|\/node_modules\/\.vite\//.test(f) ||
|
|
88
|
+
/\._\.js$|[-_.](?=[0-9a-z]*\d)[0-9a-z]{6,}\.(m?js|cjs)$/i.test(f.split("/").pop() || "") ||
|
|
89
|
+
/\/assets\/[^/]+-[\w-]{8}\.js$/.test(f)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A map's source name as a path: `webpack://app/./src/a.tsx` → `src/a.tsx`,
|
|
95
|
+
* `turbopack:///[project]/app/page.tsx` → `app/page.tsx`, `file:///x/y` → `/x/y`,
|
|
96
|
+
* then relative to `root` when it's inside it. Without a root, an absolute path
|
|
97
|
+
* is made relative to the project the map's own node_modules sources sit in.
|
|
98
|
+
* @param {string} name
|
|
99
|
+
* @param {{ root?: string | null, sources?: string[] }} [options]
|
|
100
|
+
*/
|
|
101
|
+
export function cleanSource(name, { root = null, sources = [] } = {}) {
|
|
102
|
+
let p = String(name || "").replace(/[?#].*$/, "")
|
|
103
|
+
p = p.replace(/^webpack:\/\/[^/]*\//, "").replace(/^turbopack:\/\/\/?(\[project\]\/)?/, "").replace(/^\[project\]\//, "")
|
|
104
|
+
if (p.startsWith("file://")) {
|
|
105
|
+
try {
|
|
106
|
+
p = decodeURIComponent(new URL(p).pathname)
|
|
107
|
+
} catch {
|
|
108
|
+
p = p.slice(7)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
p = p.replace(/^\.\//, "")
|
|
112
|
+
if (p.startsWith("/")) {
|
|
113
|
+
const roots = []
|
|
114
|
+
if (root) roots.push(String(root).replace(/\/$/, ""), "/private" + String(root).replace(/\/$/, ""))
|
|
115
|
+
for (const s of sources) {
|
|
116
|
+
const m = /^(?:file:\/\/)?(\/.*?)\/node_modules\//.exec(String(s || ""))
|
|
117
|
+
if (m) roots.push(m[1])
|
|
118
|
+
}
|
|
119
|
+
for (const r of roots) if (r && p.startsWith(r + "/")) return p.slice(r.length + 1)
|
|
120
|
+
}
|
|
121
|
+
return p
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The map's URL for a bundle: a SourceMap header, its last sourceMappingURL comment, else `<url>.map`. */
|
|
125
|
+
export function mapUrlOf(code, url, headers) {
|
|
126
|
+
const h = headers && (headers.get("sourcemap") || headers.get("x-sourcemap"))
|
|
127
|
+
if (h) return new URL(h, url).href
|
|
128
|
+
const all = [...String(code).matchAll(/\/\/[#@] sourceMappingURL=(\S+)/g)]
|
|
129
|
+
if (all.length) {
|
|
130
|
+
const ref = all[all.length - 1][1]
|
|
131
|
+
return ref.startsWith("data:") ? ref : new URL(ref, url).href
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseDataUrl(ref) {
|
|
137
|
+
const comma = ref.indexOf(",")
|
|
138
|
+
const body = ref.slice(comma + 1)
|
|
139
|
+
return /;base64/.test(ref.slice(0, comma)) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Loads `url`'s source map. Resolves to the parsed map, or null.
|
|
144
|
+
* @param {string} url
|
|
145
|
+
* @param {{ fetch?: typeof fetch, timeoutMs?: number }} [options]
|
|
146
|
+
*/
|
|
147
|
+
export async function loadSourceMap(url, { fetch: get = fetch, timeoutMs = 5000 } = {}) {
|
|
148
|
+
const ac = new AbortController()
|
|
149
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
150
|
+
try {
|
|
151
|
+
const res = await get(url, { signal: ac.signal })
|
|
152
|
+
if (!res.ok) return null
|
|
153
|
+
const code = await res.text()
|
|
154
|
+
let ref = mapUrlOf(code, url, res.headers)
|
|
155
|
+
const tries = ref ? [ref] : [url.replace(/[?#].*$/, "") + ".map"]
|
|
156
|
+
for (const r of tries) {
|
|
157
|
+
try {
|
|
158
|
+
if (r.startsWith("data:")) return JSON.parse(parseDataUrl(r))
|
|
159
|
+
const m = await get(r, { signal: ac.signal })
|
|
160
|
+
if (m.ok) return JSON.parse(await m.text())
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
} catch {
|
|
165
|
+
return null
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timer)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A compiled source { file, line, col? } → { file, line, from } in the app's
|
|
173
|
+
* own code, or { library } when the line is library code, or null.
|
|
174
|
+
* @param {{ file: string, line?: number | null, col?: number | null, url?: string }} src
|
|
175
|
+
* @param {{ base: string, root?: string | null, fetch?: typeof fetch }} options
|
|
176
|
+
*/
|
|
177
|
+
export async function resolveSource(src, { base, root = null, fetch: get = fetch }) {
|
|
178
|
+
if (!src || !src.file || !((src.line ?? 0) > 0)) return null
|
|
179
|
+
let url
|
|
180
|
+
try {
|
|
181
|
+
url = src.url && /^https?:/.test(src.url) ? new URL(new URL(src.url).pathname + new URL(src.url).search, base.replace(/\/?$/, "/")).href : new URL(src.file, base.replace(/\/?$/, "/")).href
|
|
182
|
+
} catch {
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
const map = await loadSourceMap(url, { fetch: get })
|
|
186
|
+
if (!map) return null
|
|
187
|
+
const pos = originalPosition(map, src.line, src.col || null)
|
|
188
|
+
if (!pos) return null
|
|
189
|
+
const file = cleanSource(pos.source, { root, sources: pos.sources })
|
|
190
|
+
if (isLibrary(file)) return { library: file, line: pos.line }
|
|
191
|
+
return { file, line: pos.line, from: `${src.file}:${src.line}` }
|
|
192
|
+
}
|
package/src/shell/10-dock.js
CHANGED
|
@@ -90,6 +90,10 @@ function swapIn(b) {
|
|
|
90
90
|
} catch {}
|
|
91
91
|
f.className = "live"
|
|
92
92
|
f.removeAttribute("aria-hidden")
|
|
93
|
+
// The new frame is the one on show before the old one goes: the old one
|
|
94
|
+
// blurs as it's removed, which must not read as ⌘ let go (F99).
|
|
95
|
+
D.frame = f
|
|
96
|
+
D.PT = pt
|
|
93
97
|
if (old) {
|
|
94
98
|
let canvas = false
|
|
95
99
|
try {
|
|
@@ -102,14 +106,14 @@ function swapIn(b) {
|
|
|
102
106
|
setTimeout(() => delete f.dataset.enter, 200)
|
|
103
107
|
} else old.remove()
|
|
104
108
|
}
|
|
105
|
-
D.frame = f
|
|
106
|
-
D.PT = pt
|
|
107
109
|
D.frameBranch = b.branchId != null ? b.branchId : D.activeId
|
|
108
110
|
D.building = null
|
|
109
111
|
window.__retakeShell.rebuilding = false
|
|
110
112
|
D.scopeEl = null // an element of the old frame
|
|
111
113
|
noteBuilt(b)
|
|
112
114
|
hookFrameKeys(f.contentWindow)
|
|
115
|
+
// A tool that's on stays on, in the new frame, over what's under the pointer.
|
|
116
|
+
syncPicking()
|
|
113
117
|
if (b.fork) forkNow()
|
|
114
118
|
else if (b.play) pt.play()
|
|
115
119
|
}
|
|
@@ -568,6 +572,7 @@ function render() {
|
|
|
568
572
|
renderShield(s)
|
|
569
573
|
if (s.started) renderTimeline(s, shownT)
|
|
570
574
|
renderExtras(s)
|
|
575
|
+
renderFocusOverlay(s)
|
|
571
576
|
if (D.collapsed && fab.dataset.phase !== readoutPhase.dataset.phase) fab.dataset.phase = readoutPhase.dataset.phase
|
|
572
577
|
}
|
|
573
578
|
|
|
@@ -688,8 +693,12 @@ const refocus = () => D.frame && D.frame.contentWindow && D.frame.contentWindow.
|
|
|
688
693
|
document.addEventListener("click", (e) => {
|
|
689
694
|
const target = /** @type {Element} */ (e.target)
|
|
690
695
|
const b = /** @type {HTMLButtonElement | null} */ (target.closest("button, [data-branch], [data-note]"))
|
|
696
|
+
// A press on the track while writing a note (the element row, a range, the
|
|
697
|
+
// playhead) keeps the note open.
|
|
698
|
+
const held = D.focusHold
|
|
699
|
+
D.focusHold = false
|
|
691
700
|
if (!b) {
|
|
692
|
-
if (!target.closest(".card")) closeCard()
|
|
701
|
+
if (!target.closest(".card") && !(held && target.closest(".track"))) closeCard()
|
|
693
702
|
return
|
|
694
703
|
}
|
|
695
704
|
const a = b.dataset.a
|
|
@@ -896,7 +905,14 @@ window.addEventListener("keydown", (e) => {
|
|
|
896
905
|
e.preventDefault()
|
|
897
906
|
return setCollapsed(!D.collapsed)
|
|
898
907
|
}
|
|
908
|
+
// Tab with the dock's window focused cycles what's under the pointer, as in the app.
|
|
909
|
+
if (e.key === "Tab" && !e.metaKey && mode() && !typing(e.target) && layers.length > 1) {
|
|
910
|
+
e.preventDefault()
|
|
911
|
+
return cycleLayer(e.shiftKey ? -1 : 1)
|
|
912
|
+
}
|
|
899
913
|
if (e.key === "Escape") {
|
|
914
|
+
// An open clip closes first, then a selected range, then the note.
|
|
915
|
+
if (closeFocusClip() || clearRange()) return
|
|
900
916
|
setPicking(null)
|
|
901
917
|
closeCard()
|
|
902
918
|
return
|
|
@@ -923,5 +939,11 @@ function hookFrameKeys(win) {
|
|
|
923
939
|
)
|
|
924
940
|
} catch {}
|
|
925
941
|
}
|
|
926
|
-
window.addEventListener("keyup", (e) => e.key === "Meta" &&
|
|
927
|
-
window
|
|
942
|
+
window.addEventListener("keyup", (e) => e.key === "Meta" && setMeta(false))
|
|
943
|
+
// The dock's window losing focus lets go of ⌘, unless the focus only went
|
|
944
|
+
// into the app's frame (whose own blur, if it goes, says so).
|
|
945
|
+
window.addEventListener("blur", () => {
|
|
946
|
+
const a = document.activeElement
|
|
947
|
+
if (a && a.tagName === "IFRAME") return
|
|
948
|
+
window.__retakeShell.meta(false)
|
|
949
|
+
})
|
package/src/shell/15-session.js
CHANGED
|
@@ -16,15 +16,21 @@ const net = {
|
|
|
16
16
|
activeSavedAt: 0,
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const GZIP_OVER = 1 << 20
|
|
20
|
+
|
|
19
21
|
async function api(method, path, body) {
|
|
20
22
|
const headers = {}
|
|
21
23
|
if (method !== "GET") headers["x-retake-token"] = TOKEN
|
|
22
24
|
if (body !== undefined) headers["content-type"] = "application/json"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/** @type {string | Blob | undefined} */
|
|
26
|
+
let data = body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body)
|
|
27
|
+
// A large upload goes gzipped (JSON shrinks ~10x): a Next.js middleware
|
|
28
|
+
// (retake-dev/next) gets 10 MB of a request body at most.
|
|
29
|
+
if (typeof data === "string" && data.length > GZIP_OVER && typeof CompressionStream === "function") {
|
|
30
|
+
data = await new Response(new Blob([data]).stream().pipeThrough(new CompressionStream("gzip"))).blob()
|
|
31
|
+
headers["content-encoding"] = "gzip"
|
|
32
|
+
}
|
|
33
|
+
const res = await fetch("/__retake/" + path, { method, headers, body: data })
|
|
28
34
|
if (res.status === 404) return { missing: true }
|
|
29
35
|
if (!res.ok) throw new Error(`${method} /__retake/${path}: ${res.status}`)
|
|
30
36
|
const text = await res.text()
|
package/src/shell/20-timeline.js
CHANGED
|
@@ -198,7 +198,9 @@ function treeOrder() {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
function layoutLanes(g) {
|
|
201
|
-
|
|
201
|
+
// The focused element's row (32-anims.js) takes its height first.
|
|
202
|
+
const fh = focusHeight(g)
|
|
203
|
+
const avail = g.h - RULER - 4 - fh
|
|
202
204
|
const order = treeOrder()
|
|
203
205
|
const n = order.length
|
|
204
206
|
const compact = n * LANE > avail
|
|
@@ -212,6 +214,7 @@ function layoutLanes(g) {
|
|
|
212
214
|
y += pitch
|
|
213
215
|
})
|
|
214
216
|
D.rowOrder = order.map((b) => b.id)
|
|
217
|
+
D.focusRow = fh ? { y0: Math.min(Math.round(y) + 2, g.h - fh), h: fh } : null
|
|
215
218
|
return lanes
|
|
216
219
|
}
|
|
217
220
|
|
|
@@ -479,6 +482,7 @@ function renderTimeline(s, shownT) {
|
|
|
479
482
|
D.branches.map((b) => `${b.id}.${b.end.toFixed(0)}.${b.forkAt}`).join(","),
|
|
480
483
|
tl.markers.length, act ? act.values.length : -1, D.notes.map((n) => `${n.id}.${n.status}.${n.branchId}`).join(","),
|
|
481
484
|
D.markers.length, lensClips ? D.lens.key : "", D.branchT, D.snapT, D.dragT, growing || bandFading ? performance.now() : 0, D.hoverRow,
|
|
485
|
+
focusKey(), D.range ? `${D.range.from}-${D.range.to}` : "",
|
|
482
486
|
].join("|")
|
|
483
487
|
if (key === sceneKey) return
|
|
484
488
|
sceneKey = key
|
|
@@ -532,6 +536,9 @@ function renderTimeline(s, shownT) {
|
|
|
532
536
|
scene.lanes.push({ id: active.id, y: p.y, x0: p.x0, x1: p.x1, color, active: true, empty: p.empty })
|
|
533
537
|
scene.actions = drawActions(g, tl.markers, A.y, lo, activeEnd, dim)
|
|
534
538
|
if (lensClips) scene.capsules = drawLens(g, lensClips, A.y)
|
|
539
|
+
// The focused element's own row, and a range of recording time (Shift+drag).
|
|
540
|
+
if (D.focus) scene.focus = drawFocusRow(g, s)
|
|
541
|
+
if (D.range) scene.range = drawRecordingRange(g)
|
|
535
542
|
|
|
536
543
|
// Bookmarks, on the ruler.
|
|
537
544
|
ctx.fillStyle = "#f2f2f5"
|
|
@@ -602,6 +609,12 @@ function renderTimeline(s, shownT) {
|
|
|
602
609
|
scene.notes.push({ id: n.id, x, y: L.y, r: 5 })
|
|
603
610
|
return
|
|
604
611
|
}
|
|
612
|
+
// A range note: a thin bracket from its start to its end.
|
|
613
|
+
if (n.range && n.range.to > n.range.from) {
|
|
614
|
+
const x1 = xOf(n.range.to, g)
|
|
615
|
+
ctx.fillRect(x, L.y - 12, Math.max(1, x1 - x), 1)
|
|
616
|
+
ctx.fillRect(x1 - 0.5, L.y - 14, 1, 5)
|
|
617
|
+
}
|
|
605
618
|
const label = String(i + 1)
|
|
606
619
|
const w = 8 + label.length * 5
|
|
607
620
|
roundRect(x - w / 2, L.y - 17, w, 12, 6)
|
|
@@ -627,6 +640,8 @@ function hitAt(clientX, clientY) {
|
|
|
627
640
|
const sc = D.scene
|
|
628
641
|
if (!sc) return null
|
|
629
642
|
for (const n of sc.notes) if (Math.abs(x - n.x) <= n.r && Math.abs(y - n.y) <= n.r) return { kind: "note", id: n.id }
|
|
643
|
+
const fh = focusHit(x, y)
|
|
644
|
+
if (fh) return fh
|
|
630
645
|
for (const c of sc.capsules) if (x >= c.x0 - 2 && x <= c.x1 + 2 && Math.abs(y - c.y) <= 8) return { kind: "capsule", clip: c.clip }
|
|
631
646
|
if (y < RULER) for (const b of sc.bookmarks) if (Math.abs(x - b.x) <= 5) return { kind: "bookmark", id: b.id }
|
|
632
647
|
let best = null
|
|
@@ -642,7 +657,8 @@ let gutterKey = ""
|
|
|
642
657
|
const invalidateGutter = () => (gutterKey = "")
|
|
643
658
|
function renderGutter(lanes) {
|
|
644
659
|
if (gutter.querySelector("input")) return // renaming
|
|
645
|
-
const
|
|
660
|
+
const fr = D.focus && D.focusRow ? `${D.focus.label}@${D.focusRow.y0}:${D.focusRow.h}` : ""
|
|
661
|
+
const key = D.compact + ":" + D.rowPitch + D.branches.map((b) => `${b.id}:${b.name}:${lanes.get(b.id).y}:${b.id === D.activeId}`).join("|") + fr
|
|
646
662
|
if (key === gutterKey) return
|
|
647
663
|
gutterKey = key
|
|
648
664
|
// Short dock: just the colour dots (the name shows on hover).
|
|
@@ -655,7 +671,7 @@ function renderGutter(lanes) {
|
|
|
655
671
|
const title = D.compact ? ` title="${esc(b.name)}"` : ""
|
|
656
672
|
return `<div class="${cls}" data-lane="${b.id}" style="top:${L.y}px;height:${D.rowPitch}px;margin-top:${-D.rowPitch / 2}px;--c:${colorOf(b)}"${title}><i></i><span>${esc(b.name)}</span></div>`
|
|
657
673
|
})
|
|
658
|
-
.join("")
|
|
674
|
+
.join("") + (fr ? `<div class="focus-name" style="top:${D.focusRow.y0}px;height:${Math.min(D.focusRow.h, 26)}px" title="${esc(D.focus.label)}">${esc(D.focus.label)}</div>` : "")
|
|
659
675
|
}
|
|
660
676
|
|
|
661
677
|
// ---- the header ---------------------------------------------------------------------
|
|
@@ -700,16 +716,25 @@ function renderHead(s, shownT) {
|
|
|
700
716
|
const playing = (!!s.playing || !!(D.building && D.building.play)) && D.dragT == null
|
|
701
717
|
playBtn.classList.toggle("playing", playing)
|
|
702
718
|
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play")
|
|
703
|
-
// The hint area is only for a short warning (see flash())
|
|
719
|
+
// The hint area is only for a short warning (see flash()), or, while a
|
|
720
|
+
// clip of the focused element is open, where the playhead is on its clock.
|
|
704
721
|
if (performance.now() < flashUntil) return
|
|
722
|
+
const ro = focusReadout(s, shownT)
|
|
723
|
+
if (ro) {
|
|
724
|
+
if (hintEl.textContent !== ro) hintEl.textContent = ro
|
|
725
|
+
hintEl.classList.add("show", "readout")
|
|
726
|
+
hintEl.classList.remove("warn")
|
|
727
|
+
return
|
|
728
|
+
}
|
|
705
729
|
if (hintEl.textContent) hintEl.textContent = ""
|
|
706
|
-
hintEl.classList.remove("show", "warn")
|
|
730
|
+
hintEl.classList.remove("show", "warn", "readout")
|
|
707
731
|
}
|
|
708
732
|
|
|
709
733
|
// A short message in the hint's place: a warning (a refused checkout, say),
|
|
710
734
|
// or with `warn: false` news ("Timeline 2 started").
|
|
711
735
|
function flash(msg, { warn = true } = {}) {
|
|
712
736
|
flashUntil = performance.now() + (warn ? 3500 : 2500)
|
|
737
|
+
hintEl.classList.remove("readout")
|
|
713
738
|
hintEl.textContent = msg
|
|
714
739
|
hintEl.classList.add("show")
|
|
715
740
|
hintEl.classList.toggle("warn", warn)
|
package/src/shell/25-input.js
CHANGED
|
@@ -289,6 +289,12 @@ track.addEventListener("pointerdown", (e) => {
|
|
|
289
289
|
}
|
|
290
290
|
const hit = hitAt(e.clientX, e.clientY)
|
|
291
291
|
if (hit && hit.kind === "note") return goToNoteId(hit.id)
|
|
292
|
+
// The focused element's row (32-anims.js): open a clip, pin a point, drag a range.
|
|
293
|
+
if (hit && (hit.kind.startsWith("focus") || hit.kind === "range-edge") && focusPointerDown(e, hit)) return
|
|
294
|
+
// A press on the track while writing a note moves the note's moment with it.
|
|
295
|
+
if (D.focus) (D.focusHold = true), (D.focus.touched = true)
|
|
296
|
+
// Shift+drag: a range of recording time, for the next element picked.
|
|
297
|
+
if (e.shiftKey) return rangePointerDown(e, s)
|
|
292
298
|
if (hit && hit.kind === "capsule") {
|
|
293
299
|
// ⌘ held on one of the element's animations: fit it, go to its start.
|
|
294
300
|
const c = hit.clip
|
|
@@ -334,6 +340,13 @@ track.addEventListener("pointermove", (e) => {
|
|
|
334
340
|
if (!s || !s.started) return
|
|
335
341
|
const g = geom()
|
|
336
342
|
overTrack = e
|
|
343
|
+
if (D.focusDrag && focusPointerMove(e)) return
|
|
344
|
+
if (D.rangeDrag && rangePointerMove(e)) return
|
|
345
|
+
if (D.focus) {
|
|
346
|
+
const fh = focusHit(e.clientX - g.left, e.clientY - g.top)
|
|
347
|
+
D.focus.hoverT = fh && fh.kind === "focus-row" && D.dragT == null ? fh.t : null
|
|
348
|
+
D.focus.hoverClip = fh && fh.kind === "focus-clip" ? fh.clip : null
|
|
349
|
+
}
|
|
337
350
|
if (D.dragT != null) {
|
|
338
351
|
if (!D.dragMoved && Math.abs(e.clientX - D.dragX0) < DEAD_PX) return
|
|
339
352
|
D.dragMoved = true
|
|
@@ -352,9 +365,11 @@ track.addEventListener("pointermove", (e) => {
|
|
|
352
365
|
if (e.ctrlKey) return
|
|
353
366
|
hover(e, s, g)
|
|
354
367
|
})
|
|
355
|
-
|
|
356
|
-
track.addEventListener("
|
|
368
|
+
const endPress = () => focusPointerUp() || rangePointerUp() || endScrub()
|
|
369
|
+
track.addEventListener("pointerup", endPress)
|
|
370
|
+
track.addEventListener("pointercancel", endPress)
|
|
357
371
|
track.addEventListener("pointerleave", () => {
|
|
372
|
+
if (D.focus) D.focus.hoverT = D.focus.hoverClip = null
|
|
358
373
|
overTrack = null
|
|
359
374
|
setBranchGuide(false)
|
|
360
375
|
D.hoverRow = null
|
|
@@ -512,11 +527,19 @@ function dockKey(e, fromApp) {
|
|
|
512
527
|
if (e.metaKey || e.ctrlKey || (!fromApp && typing(e.target))) return false
|
|
513
528
|
const s = D.last
|
|
514
529
|
if (!s || !s.started) return false
|
|
530
|
+
// Esc in a view-only app: an open clip closes, then a selected range goes, then the note.
|
|
531
|
+
if (e.key === "Escape" && (closeFocusClip() || clearRange())) return true
|
|
532
|
+
if (e.key === "Escape" && fromApp && !card.hidden) {
|
|
533
|
+
closeCard()
|
|
534
|
+
return true
|
|
535
|
+
}
|
|
515
536
|
if (e.code === "Space" || (e.altKey && e.code === "KeyP")) {
|
|
516
537
|
togglePlay()
|
|
517
538
|
return true
|
|
518
539
|
}
|
|
519
540
|
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
541
|
+
// An open clip of the focused element: its own frames and keyframes.
|
|
542
|
+
if (focusStep(e.key === "ArrowRight" ? 1 : -1, e.shiftKey)) return true
|
|
520
543
|
stepBy(e.key === "ArrowRight" ? 1 : -1, e.shiftKey)
|
|
521
544
|
return true
|
|
522
545
|
}
|