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.
- package/CHANGELOG.md +125 -0
- package/README.md +173 -10
- package/bin/retake.js +14 -1
- package/package.json +7 -1
- package/src/core.js +23 -1
- package/src/mark.svg +1 -0
- package/src/next-dev.js +183 -0
- package/src/next.js +81 -0
- package/src/note-text.js +844 -0
- package/src/plugin.js +7 -3
- 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 +28 -2
- package/src/server/api.js +73 -1
- package/src/server/front.js +3 -1
- package/src/server/mcp.js +225 -19
- package/src/server/moments.js +317 -0
- package/src/server/sourcemap.js +192 -0
- package/src/shell/00-state.js +7 -0
- package/src/shell/10-dock.js +208 -13
- package/src/shell/15-session.js +66 -10
- package/src/shell/20-timeline.js +47 -16
- package/src/shell/25-input.js +29 -2
- package/src/shell/30-notes.js +709 -113
- package/src/shell/32-anims.js +1353 -0
- package/src/shell/shell.css +79 -8
- package/src/shell/shell.html +10 -3
- package/types/client.d.ts +16 -0
- package/types/index.d.ts +16 -0
- package/types/next.d.ts +50 -0
package/src/shell/10-dock.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
763
|
+
lastY = ev.clientY
|
|
764
|
+
if (!raf) raf = requestAnimationFrame(paint)
|
|
713
765
|
}
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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" &&
|
|
754
|
-
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
|
@@ -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),
|
|
6
|
-
//
|
|
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:
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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) {
|
package/src/shell/20-timeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
314
|
-
if (isMajor
|
|
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(
|
|
317
|
-
labels.push(
|
|
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
|
|
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
|
-
|
|
707
|
-
|
|
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"
|
|
739
|
+
hintEl.classList.add("show")
|
|
740
|
+
hintEl.classList.toggle("warn", warn)
|
|
710
741
|
}
|
|
711
742
|
|
|
712
743
|
// ---- a new timeline from a chosen moment -------------------------------------------
|
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,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()
|