retake-dev 0.4.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.
@@ -8,9 +8,14 @@ const dock = $("#wb-dock")
8
8
  const track = $(".track")
9
9
  const cv = $(".lines") // the timeline canvas
10
10
 
11
+ // Timelines are "Timeline 1", "Timeline 2"... until the user renames one.
12
+ const defaultName = (id) => `Timeline ${id}`
13
+ // Older sessions named them "Main" and "Take N": those defaults
14
+ // (and only those) read as the new ones.
15
+ const migratedName = (b) => (b.name === (b.id === 1 ? "Main" : `Take ${b.id}`) ? defaultName(b.id) : b.name)
11
16
  const newBranch = (forkAt, parentId = null) => {
12
17
  const id = ++D.branchSeq
13
- const b = { id, name: id === 1 ? "Main" : `Take ${id}`, forkAt, parentId, json: null, end: forkAt, born: performance.now() }
18
+ const b = { id, name: defaultName(id), forkAt, parentId, json: null, end: forkAt, born: performance.now() }
14
19
  D.branches.push(b)
15
20
  return b
16
21
  }
@@ -85,6 +90,10 @@ function swapIn(b) {
85
90
  } catch {}
86
91
  f.className = "live"
87
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
88
97
  if (old) {
89
98
  let canvas = false
90
99
  try {
@@ -97,14 +106,14 @@ function swapIn(b) {
97
106
  setTimeout(() => delete f.dataset.enter, 200)
98
107
  } else old.remove()
99
108
  }
100
- D.frame = f
101
- D.PT = pt
102
109
  D.frameBranch = b.branchId != null ? b.branchId : D.activeId
103
110
  D.building = null
104
111
  window.__retakeShell.rebuilding = false
105
112
  D.scopeEl = null // an element of the old frame
106
113
  noteBuilt(b)
107
114
  hookFrameKeys(f.contentWindow)
115
+ // A tool that's on stays on, in the new frame, over what's under the pointer.
116
+ syncPicking()
108
117
  if (b.fork) forkNow()
109
118
  else if (b.play) pt.play()
110
119
  }
@@ -257,6 +266,7 @@ window.__retakeShell = {
257
266
  b.version = old.version // a new timeline starts on its parent's code
258
267
  D.activeId = b.id
259
268
  D.frameBranch = b.id // the visible frame carries on as the new timeline
269
+ flash(`${b.name} started`, { warn: false })
260
270
  },
261
271
  }
262
272
 
@@ -548,6 +558,7 @@ function render() {
548
558
  const dh = dockHeight()
549
559
  dock.style.height = dh + "px"
550
560
  document.body.style.setProperty("--dock-h", dh + "px")
561
+ tellDock(D.collapsed ? 0 : dh)
551
562
  if (!s || !D.frame) return
552
563
  autoStart(s)
553
564
  if (D.perfNoDraw) return // (perf measurements: the runtime alone)
@@ -561,6 +572,21 @@ function render() {
561
572
  renderShield(s)
562
573
  if (s.started) renderTimeline(s, shownT)
563
574
  renderExtras(s)
575
+ renderFocusOverlay(s)
576
+ if (D.collapsed && fab.dataset.phase !== readoutPhase.dataset.phase) fab.dataset.phase = readoutPhase.dataset.phase
577
+ }
578
+
579
+ // The app is full-window under the dock. So that it can leave room, the frame
580
+ // on show gets `window.__retakeDockHeight` (the px the dock covers at the
581
+ // bottom, 0 when folded) and a `retake:dock` event ({ detail: { height } })
582
+ // whenever that changes.
583
+ function tellDock(height) {
584
+ try {
585
+ const win = /** @type {any} */ (D.frame && D.frame.contentWindow)
586
+ if (!win || win.__retakeDockHeight === height) return
587
+ win.__retakeDockHeight = height
588
+ win.dispatchEvent(new win.CustomEvent("retake:dock", { detail: { height } }))
589
+ } catch {}
564
590
  }
565
591
 
566
592
  // A frame built behind can take the window's focus (its replay focused a
@@ -667,11 +693,16 @@ const refocus = () => D.frame && D.frame.contentWindow && D.frame.contentWindow.
667
693
  document.addEventListener("click", (e) => {
668
694
  const target = /** @type {Element} */ (e.target)
669
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
670
700
  if (!b) {
671
- if (!target.closest(".card")) closeCard()
701
+ if (!target.closest(".card") && !(held && target.closest(".track"))) closeCard()
672
702
  return
673
703
  }
674
704
  const a = b.dataset.a
705
+ if (a === "expand") return setCollapsed(false)
675
706
  if (a === "play") togglePlay()
676
707
  if (a === "fresh") return startFresh()
677
708
  if (a === "undo") return undoFresh()
@@ -695,35 +726,193 @@ document.addEventListener("click", (e) => {
695
726
  // The dock never grows by itself (that would resize the app mid-recording);
696
727
  // only the divider changes it.
697
728
  const MIN_H = 96
729
+ const DEFAULT_H = 200
698
730
  // Never so short that the lanes (at their closest) don't fit under the ruler.
731
+ const minDockHeight = () => Math.max(MIN_H, 56 + 24 + D.branches.length * 10 + 16)
699
732
  const dockHeight = () => {
700
- const min = Math.max(MIN_H, 56 + 24 + D.branches.length * 10 + 16)
733
+ const min = minDockHeight()
701
734
  return Math.round(clamp(D.height, min, Math.max(min, innerHeight * 0.7)))
702
735
  }
703
736
 
704
- // Resize by dragging the top edge, like docked DevTools.
737
+ // Resize by dragging the top edge, like docked DevTools. Below the minimum
738
+ // height the dock keeps following the pointer: it slides down (a transform, so
739
+ // neither its layout nor the app's changes mid-drag) all the way off the edge.
740
+ // Let go far enough down and it settles into the corner icon; anywhere higher
741
+ // it springs back up to the minimum.
705
742
  const divider = $(".divider")
706
743
  divider.addEventListener("pointerdown", (e) => {
707
744
  divider.setPointerCapture(e.pointerId)
708
745
  document.body.classList.add("dragging")
709
746
  const startY = e.clientY
710
747
  const startH = dock.offsetHeight
748
+ const min = minDockHeight()
749
+ let fold = false
750
+ let lastY = startY
751
+ let raf = 0
752
+ // One write per frame, whatever the pointer's rate.
753
+ const paint = () => {
754
+ raf = 0
755
+ const want = startH + startY - lastY
756
+ D.height = Math.round(clamp(want, min, innerHeight * 0.7))
757
+ const drop = clamp(min - want, 0, min + 24)
758
+ fold = drop > min / 2 || lastY >= innerHeight - FOLD_EDGE
759
+ dock.style.transform = drop ? `translateY(${drop}px)` : ""
760
+ dock.classList.toggle("will-collapse", fold)
761
+ }
711
762
  const move = (ev) => {
712
- D.height = Math.round(clamp(startH + startY - ev.clientY, MIN_H, innerHeight * 0.7))
763
+ lastY = ev.clientY
764
+ if (!raf) raf = requestAnimationFrame(paint)
713
765
  }
714
- const up = () => {
766
+ // A touch the browser takes back (pointercancel) ends the drag too, or the
767
+ // app would stay unclickable under body.dragging.
768
+ const up = (ev) => {
769
+ for (const type of ["pointermove", "pointerup", "pointercancel", "lostpointercapture"]) divider.removeEventListener(type, type === "pointermove" ? move : up)
770
+ if (raf) cancelAnimationFrame(raf)
771
+ if (ev.type !== "pointercancel" && ev.type !== "lostpointercapture") {
772
+ lastY = ev.clientY
773
+ paint()
774
+ }
775
+ // From where the finger left it: on into the icon, or back up.
715
776
  document.body.classList.remove("dragging")
716
- divider.removeEventListener("pointermove", move)
717
- divider.removeEventListener("pointerup", up)
777
+ dock.classList.remove("will-collapse")
778
+ dock.style.transform = ""
718
779
  store.set("height", D.height)
780
+ if (fold && ev.type !== "pointercancel") setCollapsed(true)
719
781
  }
720
782
  divider.addEventListener("pointermove", move)
721
- divider.addEventListener("pointerup", up)
783
+ for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) divider.addEventListener(type, up)
784
+ })
785
+
786
+ // Folded: the timeline is a round button (bottom-right at first) and the app
787
+ // has the whole window. It keeps recording; the button (or ⌥T) brings the dock
788
+ // back at its default height. Remembered across reloads.
789
+ const FOLD_EDGE = 48 // px from the bottom edge: let go there and it folds
790
+ const fab = $("#wb-fab")
791
+ function applyCollapsed() {
792
+ document.body.classList.toggle("collapsed", D.collapsed)
793
+ fab.hidden = !D.collapsed
794
+ dock.inert = D.collapsed
795
+ if (D.collapsed) placeFab()
796
+ }
797
+
798
+ // The button can be dragged anywhere (mouse or finger). Past a few px it's a
799
+ // drag, not a click; let go and it eases to the nearer side edge. Where it is
800
+ // (`D.fab`: that side, and how far down) is stored, so a reload or a resized
801
+ // window puts it back in the same place, always fully on screen.
802
+ const FAB_SIZE = 44
803
+ const FAB_MARGIN = 16
804
+ const FAB_SLOP = 5 // px a press may move and still be a click
805
+ let fabDragged = false // the press that just ended was a drag: not a click
806
+ // The notch and the home bar (viewport-fit=cover), measured once per layout.
807
+ const safeProbe = document.createElement("div")
808
+ safeProbe.style.cssText =
809
+ "position:fixed;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)"
810
+ document.body.appendChild(safeProbe)
811
+ function fabBounds() {
812
+ const cs = getComputedStyle(safeProbe)
813
+ const left = FAB_MARGIN + (parseFloat(cs.paddingLeft) || 0)
814
+ const top = FAB_MARGIN + (parseFloat(cs.paddingTop) || 0)
815
+ const right = Math.max(left, innerWidth - FAB_SIZE - FAB_MARGIN - (parseFloat(cs.paddingRight) || 0))
816
+ const bottom = Math.max(top, innerHeight - FAB_SIZE - FAB_MARGIN - (parseFloat(cs.paddingBottom) || 0))
817
+ return { left, top, right, bottom }
818
+ }
819
+ function moveFab(x, y) {
820
+ fab.style.left = Math.round(x) + "px"
821
+ fab.style.top = Math.round(y) + "px"
822
+ }
823
+ function placeFab() {
824
+ const b = fabBounds()
825
+ moveFab(D.fab.side === "left" ? b.left : b.right, b.top + D.fab.y * (b.bottom - b.top))
826
+ }
827
+ applyCollapsed()
828
+ addEventListener("resize", () => D.collapsed && !fab.classList.contains("moving") && placeFab())
829
+
830
+ fab.addEventListener("pointerdown", (e) => {
831
+ fabDragged = false
832
+ if (e.button !== 0) return
833
+ const r = fab.getBoundingClientRect()
834
+ const grabX = e.clientX - r.left
835
+ const grabY = e.clientY - r.top
836
+ const x0 = e.clientX
837
+ const y0 = e.clientY
838
+ let moving = false
839
+ fab.setPointerCapture(e.pointerId)
840
+ const move = (ev) => {
841
+ if (!moving && Math.hypot(ev.clientX - x0, ev.clientY - y0) < FAB_SLOP) return
842
+ if (!moving) {
843
+ moving = true
844
+ fab.classList.remove("snapping")
845
+ fab.classList.add("moving")
846
+ }
847
+ const b = fabBounds()
848
+ moveFab(clamp(ev.clientX - grabX, b.left, b.right), clamp(ev.clientY - grabY, b.top, b.bottom))
849
+ }
850
+ const up = () => {
851
+ for (const type of ["pointermove", "pointerup", "pointercancel"]) fab.removeEventListener(type, type === "pointermove" ? move : up)
852
+ if (!moving) return
853
+ fabDragged = true
854
+ fab.classList.remove("moving")
855
+ // Where it was let go (its left/top: the rect would include the lift's scale).
856
+ const b = fabBounds()
857
+ const x = parseFloat(fab.style.left) || 0
858
+ const y = parseFloat(fab.style.top) || 0
859
+ D.fab = {
860
+ side: x + FAB_SIZE / 2 < innerWidth / 2 ? "left" : "right",
861
+ y: b.bottom > b.top ? clamp((y - b.top) / (b.bottom - b.top), 0, 1) : 1,
862
+ }
863
+ store.set("fab", D.fab)
864
+ fab.classList.add("snapping")
865
+ placeFab()
866
+ setTimeout(() => fab.classList.remove("snapping"), 400)
867
+ }
868
+ fab.addEventListener("pointermove", move)
869
+ fab.addEventListener("pointerup", up)
870
+ fab.addEventListener("pointercancel", up)
722
871
  })
872
+ // The click that ends a drag doesn't open the dock.
873
+ fab.addEventListener(
874
+ "click",
875
+ (e) => {
876
+ if (!fabDragged) return
877
+ fabDragged = false
878
+ e.stopPropagation()
879
+ e.preventDefault()
880
+ },
881
+ true,
882
+ )
883
+ function setCollapsed(on) {
884
+ if (on === D.collapsed) return
885
+ const hadFocus = on ? dock.contains(document.activeElement) : document.activeElement === fab
886
+ if (on) {
887
+ setPicking(null)
888
+ closeCard()
889
+ } else {
890
+ D.height = DEFAULT_H
891
+ store.set("height", D.height)
892
+ }
893
+ D.collapsed = on
894
+ store.set("collapsed", on)
895
+ applyCollapsed()
896
+ if (hadFocus) (on ? fab : playBtn).focus({ preventScroll: true })
897
+ }
723
898
 
724
899
  window.addEventListener("keydown", (e) => {
725
900
  if (e.key === "Meta") return window.__retakeShell.meta(true)
901
+ // The corner button is a real button: Enter and Space press it.
902
+ if (e.target === fab && (e.key === "Enter" || e.code === "Space")) return
903
+ // Not while typing in the dock (a note, a timeline's name): ⌥T types there († on a Mac).
904
+ if (e.altKey && e.code === "KeyT" && !typing(e.target)) {
905
+ e.preventDefault()
906
+ return setCollapsed(!D.collapsed)
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
+ }
726
913
  if (e.key === "Escape") {
914
+ // An open clip closes first, then a selected range, then the note.
915
+ if (closeFocusClip() || clearRange()) return
727
916
  setPicking(null)
728
917
  closeCard()
729
918
  return
@@ -750,5 +939,11 @@ function hookFrameKeys(win) {
750
939
  )
751
940
  } catch {}
752
941
  }
753
- window.addEventListener("keyup", (e) => e.key === "Meta" && window.__retakeShell.meta(false))
754
- window.addEventListener("blur", () => window.__retakeShell.meta(false))
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
+ })
@@ -2,11 +2,13 @@
2
2
  // (/__retake/session, kept on disk under <project>/.retake/) so a reload or a
3
3
  // restart doesn't lose them; each timeline's recording goes to
4
4
  // /__retake/recording/:id. Agents reply to notes over /__retake/events.
5
- // When the server has no session API (it answers 404), everything stays in
6
- // memory for this page.
5
+ // When the server has no session API (it answers 404), or the page says
6
+ // there's no server at all (`__retakeConfig.server === false`, a static
7
+ // deploy), everything stays in memory for this page.
7
8
  const TOKEN = window.__RETAKE_TOKEN || ""
9
+ const SERVER = !(window.__retakeConfig && window.__retakeConfig.server === false)
8
10
  const net = {
9
- on: true, // false: in-memory only
11
+ on: SERVER, // false: in-memory only
10
12
  restoring: true, // nothing is saved until what's on disk has been read
11
13
  savedSession: null, // the last session body the server has
12
14
  savedRec: new Map(), // branch id → the recording string the server has
@@ -14,15 +16,21 @@ const net = {
14
16
  activeSavedAt: 0,
15
17
  }
16
18
 
19
+ const GZIP_OVER = 1 << 20
20
+
17
21
  async function api(method, path, body) {
18
22
  const headers = {}
19
23
  if (method !== "GET") headers["x-retake-token"] = TOKEN
20
24
  if (body !== undefined) headers["content-type"] = "application/json"
21
- const res = await fetch("/__retake/" + path, {
22
- method,
23
- headers,
24
- body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
25
- })
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 })
26
34
  if (res.status === 404) return { missing: true }
27
35
  if (!res.ok) throw new Error(`${method} /__retake/${path}: ${res.status}`)
28
36
  const text = await res.text()
@@ -40,7 +48,7 @@ function sessionBody() {
40
48
  }
41
49
 
42
50
  function applySession(s) {
43
- D.branches = s.branches.map((b) => ({ ...b, version: b.codeVersion || undefined, json: null, end: b.end ?? b.forkAt, born: -1e9 }))
51
+ D.branches = s.branches.map((b) => ({ ...b, name: migratedName(b), version: b.codeVersion || undefined, json: null, end: b.end ?? b.forkAt, born: -1e9 }))
44
52
  D.branchSeq = Math.max(0, ...D.branches.map((b) => b.id))
45
53
  D.activeId = D.branches.some((b) => b.id === s.activeId) ? s.activeId : D.branches[0].id
46
54
  D.markers = Array.isArray(s.markers) ? s.markers : []
@@ -83,6 +91,7 @@ async function restore() {
83
91
  return b.id
84
92
  }
85
93
  try {
94
+ if (!SERVER) return
86
95
  const r = await api("GET", "session")
87
96
  if (r.missing) {
88
97
  net.on = false
@@ -93,7 +102,7 @@ async function restore() {
93
102
  applySession(s)
94
103
  net.savedSession = JSON.stringify(sessionBody())
95
104
  known = true
96
- const json = await recordingOf(activeBranch())
105
+ const json = newerThanSaved(await recordingOf(activeBranch()))
97
106
  if (D.frame) {
98
107
  // Opened fresh already (the session was slow): that visit is its own.
99
108
  D.frameBranch = ownTimeline()
@@ -119,6 +128,53 @@ async function restore() {
119
128
  }
120
129
  }
121
130
 
131
+ // Leaving the page (a new address typed in, a reload of the dock) while
132
+ // recording: the server has the recording as it was at the last save, up to
133
+ // five seconds ago. What came after is kept in this tab's sessionStorage on
134
+ // the way out, and restore() carries on from it (F78).
135
+ const LIVE_KEY = KEY + "live"
136
+ function takeLiveStash() {
137
+ try {
138
+ const v = sessionStorage.getItem(LIVE_KEY)
139
+ sessionStorage.removeItem(LIVE_KEY) // the app's frame shares this storage
140
+ return v ? JSON.parse(v) : null
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+ const liveStash = SERVER ? takeLiveStash() : null
146
+ // The saved recording, or what this tab kept of the same recording (same
147
+ // timeline, same epoch) when it's further along.
148
+ function newerThanSaved(json) {
149
+ const kept = liveStash
150
+ if (!json || !kept || kept.branchId !== D.activeId || typeof kept.rec !== "string") return json
151
+ try {
152
+ const saved = JSON.parse(json)
153
+ const rec = JSON.parse(kept.rec)
154
+ if (rec.epoch !== saved.epoch || !(rec.end > saved.end)) return json
155
+ } catch {
156
+ return json
157
+ }
158
+ activeBranch().json = kept.rec
159
+ return kept.rec
160
+ }
161
+ window.addEventListener("pagehide", () => {
162
+ if (!net.on || net.restoring || net.hold || !D.PT || D.building || D.frameBranch !== D.activeId) return
163
+ try {
164
+ const s = D.PT.state()
165
+ if (!s.started || s.seeking) return
166
+ const rec = JSON.stringify(D.PT.history())
167
+ if (rec === net.savedRec.get(D.activeId)) return
168
+ sessionStorage.setItem(LIVE_KEY, JSON.stringify({ branchId: D.activeId, rec }))
169
+ } catch {}
170
+ })
171
+ // Back from the back/forward cache: this page carries on, nothing to keep.
172
+ window.addEventListener("pageshow", (e) => {
173
+ try {
174
+ if (e.persisted) sessionStorage.removeItem(LIVE_KEY)
175
+ } catch {}
176
+ })
177
+
122
178
  // The active timeline's recording is saved when it's still (paused), and every
123
179
  // few seconds while it's live.
124
180
  function activeSig(s) {
@@ -41,7 +41,6 @@ const MONO = '10px "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace'
41
41
 
42
42
  const tip = $("#wb-tip")
43
43
  const gutter = $(".gutter")
44
- const liveBtn = $(".live-btn")
45
44
  const readoutT = $(".readout .t")
46
45
  // Minutes and seconds, then the hundredths dimmer.
47
46
  readoutT.innerHTML = '<span class="ms"></span><span class="cs"></span>'
@@ -100,7 +99,7 @@ function setView(from, to, { animate = true } = {}) {
100
99
  const r = pinStart({ from: mid - span / 2, to: mid + span / 2 }, D.last)
101
100
  D.view.fit = false
102
101
  D.view.followSpan = r.to - r.from
103
- if (D.last && D.last.playing) D.view.detached = true
102
+ if (D.last && D.last.playing) (D.view.detached = true), (D.view.detachedAt = performance.now())
104
103
  if (animate) D.view.target = r
105
104
  else {
106
105
  D.view.from = r.from
@@ -117,6 +116,8 @@ function fitRange(a, b) {
117
116
  const pad = Math.max((b - a) * 0.15, 10)
118
117
  setView(a - pad, b + pad)
119
118
  }
119
+ // How long a manual scroll of a live timeline holds the view before it follows again.
120
+ const FOLLOW_AGAIN_MS = 3000
120
121
  function followLive() {
121
122
  D.view.detached = false
122
123
  D.view.fit = false
@@ -131,6 +132,8 @@ function stepView(s) {
131
132
  const v = D.view
132
133
  // Started playing: follow again. Paused: stay exactly where it is.
133
134
  if (s.playing && !v.wasPlaying) v.detached = false
135
+ // Scrolled away while it records: it follows again once you leave it alone.
136
+ if (s.playing && !s.future && v.detached && now - (v.detachedAt || 0) > FOLLOW_AGAIN_MS) v.detached = false
134
137
  if (!s.playing && v.wasPlaying) {
135
138
  v.fit = false
136
139
  v.target = null
@@ -195,7 +198,9 @@ function treeOrder() {
195
198
  }
196
199
 
197
200
  function layoutLanes(g) {
198
- const avail = g.h - RULER - 4
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
199
204
  const order = treeOrder()
200
205
  const n = order.length
201
206
  const compact = n * LANE > avail
@@ -209,6 +214,7 @@ function layoutLanes(g) {
209
214
  y += pitch
210
215
  })
211
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
212
218
  return lanes
213
219
  }
214
220
 
@@ -310,11 +316,13 @@ function drawRuler(g, s, xNow) {
310
316
  const isMajor = rel % major === 0
311
317
  ctx.fillStyle = isMajor ? INK.dim : INK.faint
312
318
  ctx.fillRect(x - 0.5, RULER - (isMajor ? 6 : 3), 1, isMajor ? 5 : 2)
313
- // A label the playhead's cap would sit on is left out.
314
- if (isMajor && !(x + 3 < xNow + 8 && x + 36 > xNow - 8)) {
319
+ // A label that would run past the right edge is left out.
320
+ if (isMajor) {
321
+ const text = label(t)
322
+ if (x + 3 + ctx.measureText(text).width > g.w) continue
315
323
  ctx.fillStyle = INK.dim
316
- ctx.fillText(label(t), x + 3, RULER - 8)
317
- labels.push(label(t))
324
+ ctx.fillText(text, x + 3, RULER - 8)
325
+ labels.push(text)
318
326
  }
319
327
  }
320
328
  return labels
@@ -457,7 +465,6 @@ function renderTimeline(s, shownT) {
457
465
  D.lanes = lanes
458
466
  renderGutter(lanes)
459
467
  const following = s.playing && !D.view.detached
460
- liveBtn.hidden = !(s.playing && !s.future && D.view.detached)
461
468
  checkPendingFork(s)
462
469
 
463
470
  if (band.id !== D.activeId) {
@@ -475,6 +482,7 @@ function renderTimeline(s, shownT) {
475
482
  D.branches.map((b) => `${b.id}.${b.end.toFixed(0)}.${b.forkAt}`).join(","),
476
483
  tl.markers.length, act ? act.values.length : -1, D.notes.map((n) => `${n.id}.${n.status}.${n.branchId}`).join(","),
477
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}` : "",
478
486
  ].join("|")
479
487
  if (key === sceneKey) return
480
488
  sceneKey = key
@@ -528,6 +536,9 @@ function renderTimeline(s, shownT) {
528
536
  scene.lanes.push({ id: active.id, y: p.y, x0: p.x0, x1: p.x1, color, active: true, empty: p.empty })
529
537
  scene.actions = drawActions(g, tl.markers, A.y, lo, activeEnd, dim)
530
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)
531
542
 
532
543
  // Bookmarks, on the ruler.
533
544
  ctx.fillStyle = "#f2f2f5"
@@ -598,6 +609,12 @@ function renderTimeline(s, shownT) {
598
609
  scene.notes.push({ id: n.id, x, y: L.y, r: 5 })
599
610
  return
600
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
+ }
601
618
  const label = String(i + 1)
602
619
  const w = 8 + label.length * 5
603
620
  roundRect(x - w / 2, L.y - 17, w, 12, 6)
@@ -623,6 +640,8 @@ function hitAt(clientX, clientY) {
623
640
  const sc = D.scene
624
641
  if (!sc) return null
625
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
626
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 }
627
646
  if (y < RULER) for (const b of sc.bookmarks) if (Math.abs(x - b.x) <= 5) return { kind: "bookmark", id: b.id }
628
647
  let best = null
@@ -638,7 +657,8 @@ let gutterKey = ""
638
657
  const invalidateGutter = () => (gutterKey = "")
639
658
  function renderGutter(lanes) {
640
659
  if (gutter.querySelector("input")) return // renaming
641
- const key = D.compact + ":" + D.rowPitch + D.branches.map((b) => `${b.id}:${b.name}:${lanes.get(b.id).y}:${b.id === D.activeId}`).join("|")
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
642
662
  if (key === gutterKey) return
643
663
  gutterKey = key
644
664
  // Short dock: just the colour dots (the name shows on hover).
@@ -651,7 +671,7 @@ function renderGutter(lanes) {
651
671
  const title = D.compact ? ` title="${esc(b.name)}"` : ""
652
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>`
653
673
  })
654
- .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>` : "")
655
675
  }
656
676
 
657
677
  // ---- the header ---------------------------------------------------------------------
@@ -696,17 +716,28 @@ function renderHead(s, shownT) {
696
716
  const playing = (!!s.playing || !!(D.building && D.building.play)) && D.dragT == null
697
717
  playBtn.classList.toggle("playing", playing)
698
718
  playBtn.setAttribute("aria-label", playing ? "Pause" : "Play")
699
- // 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.
700
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
+ }
701
729
  if (hintEl.textContent) hintEl.textContent = ""
702
- hintEl.classList.remove("show", "warn")
730
+ hintEl.classList.remove("show", "warn", "readout")
703
731
  }
704
732
 
705
- // A short message in the hint's place (a refused checkout, say).
706
- function flash(msg) {
707
- flashUntil = performance.now() + 3500
733
+ // A short message in the hint's place: a warning (a refused checkout, say),
734
+ // or with `warn: false` news ("Timeline 2 started").
735
+ function flash(msg, { warn = true } = {}) {
736
+ flashUntil = performance.now() + (warn ? 3500 : 2500)
737
+ hintEl.classList.remove("readout")
708
738
  hintEl.textContent = msg
709
- hintEl.classList.add("show", "warn")
739
+ hintEl.classList.add("show")
740
+ hintEl.classList.toggle("warn", warn)
710
741
  }
711
742
 
712
743
  // ---- a new timeline from a chosen moment -------------------------------------------
@@ -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
- track.addEventListener("pointerup", endScrub)
356
- track.addEventListener("pointercancel", endScrub)
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,14 +527,26 @@ 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
  }
546
+ if (e.altKey && e.code === "KeyT") {
547
+ setCollapsed(!D.collapsed)
548
+ return true
549
+ }
523
550
  if (e.altKey) return false
524
551
  if (e.code === "KeyF") {
525
552
  fitAll()