feat: expandable annotations with nested timelines

Each annotation can be expanded into its own nested timeline. Reuses the
dormant :timeline-stack: annotations carry a :parent (:root = top level),
expanding pushes the annotation id, and only annotations whose :parent
matches the stack top are surfaced — so nested annotations stay invisible
on the main timeline.

- crop: the expanded annotation's own spans define a time window + the set
  of involved tracks; non-involved tracks collapse to height 0 (CSS-
  transitioned, so tracks animate away and survivors slide up), and the
  view offsets/crops to the window
- breadcrumb bar + back button (shown only when nested) fully restore the
  main timeline
- new annotations attach to the current context; the expand button shows a
  child count and delete removes the whole subtree
- seed data (Opening rally + 2 children, Net-cam beat) for testing, plus a
  one-time localStorage reset so it shows

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-27 16:01:11 -04:00
parent f67baf4b99
commit 30cfbf3a1f
6 changed files with 241 additions and 60 deletions

View file

@ -94,6 +94,13 @@ body { overflow: hidden; }
.clip.clickable { cursor: copy; outline: 1px solid #5ab07a; } .clip.clickable { cursor: copy; outline: 1px solid #5ab07a; }
.expand-btn {
background: none; color: #7aa6d6; border: 1px solid transparent;
border-radius: 4px; padding: 1px 6px; cursor: pointer; font-size: 11px;
}
.expand-btn:hover { color: #cfe3f5; border-color: #3a6ea5; }
.expand-btn.has-children { color: #cfe3f5; border-color: #3a6ea5; background: #16263a; }
/* --- authoring form (fills the annotation pane) ------------------------- */ /* --- authoring form (fills the annotation pane) ------------------------- */
.form { .form {
flex: 1; min-width: 0; overflow-y: auto; flex: 1; min-width: 0; overflow-y: auto;
@ -172,6 +179,21 @@ body { overflow: hidden; }
/* --- timeline region ---------------------------------------------------- */ /* --- timeline region ---------------------------------------------------- */
.timeline-pane { flex: 1; display: flex; flex-direction: column; min-height: 150px; } .timeline-pane { flex: 1; display: flex; flex-direction: column; min-height: 150px; }
/* --- breadcrumb bar (only present when expanded into a nested timeline) -- */
.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
padding: 5px 8px; background: #0c0c0c; border-bottom: 1px solid #222;
flex-shrink: 0; font-size: 12px; }
.back-btn { background: #1c2a38; color: #cfe3f5; border: 1px solid #3a6ea5;
border-radius: 4px; padding: 2px 10px; cursor: pointer; font-size: 12px;
margin-right: 4px; }
.back-btn:hover { background: #24384e; }
.crumb-item { display: inline-flex; align-items: center; gap: 6px; }
.crumb-sep { color: #555; }
.crumb { background: none; border: none; color: #7aa6d6; cursor: pointer;
font-size: 12px; padding: 2px 2px; }
.crumb:hover { color: #cfe3f5; text-decoration: underline; }
.crumb.current { color: #cfe3f5; cursor: default; text-decoration: none; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; .toolbar { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;
padding: 6px 8px; background: #0c0c0c; border-bottom: 1px solid #222; padding: 6px 8px; background: #0c0c0c; border-bottom: 1px solid #222;
flex-shrink: 0; } flex-shrink: 0; }
@ -194,9 +216,16 @@ body { overflow: hidden; }
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.hscroll { overflow-x: auto; flex: 1; } .hscroll { overflow-x: auto; flex: 1; }
.content { position: relative; cursor: ew-resize; } /* overflow:hidden crops clips that fall outside the (possibly windowed) view */
.content { position: relative; cursor: ew-resize; overflow: hidden; }
.track-row { position: relative; background: #1c1c1c; border-bottom: 1px solid #2a2a2a; } /* height transitions so cropped-out tracks animate away and the survivors
slide up to fill the gap ("tracks leave / get rearranged") */
.track-row { position: relative; background: #1c1c1c; border-bottom: 1px solid #2a2a2a;
overflow: hidden; transition: height .3s ease, opacity .3s ease; }
.track-row.collapsed { opacity: 0; border-bottom: none; }
.gutter-label { transition: height .3s ease, opacity .3s ease; }
.gutter-label.collapsed { opacity: 0; border-bottom: none; padding: 0; }
.clip { .clip {
position: absolute; top: 1px; box-sizing: border-box; overflow: hidden; position: absolute; top: 1px; box-sizing: border-box; overflow: hidden;
background: #3a6ea5; border: 1px solid #16324f; border-radius: 2px; background: #3a6ea5; border: 1px solid #16324f; border-radius: 2px;

View file

@ -19,7 +19,15 @@
(rdom/unmount-component-at-node root-el) (rdom/unmount-component-at-node root-el)
(rdom/render [views/main-panel] root-el))) (rdom/render [views/main-panel] root-el)))
;; one-time dev reset: wipe saved marks once so the nested-timeline seed data
;; shows, then flag it so persistence resumes normally. Safe to delete later.
(defn- reset-marks-once! []
(when-not (.getItem js/localStorage "tl/seed-reset-1")
(.removeItem js/localStorage "tl/marks")
(.setItem js/localStorage "tl/seed-reset-1" "1")))
(defn init [] (defn init []
(reset-marks-once!)
(re-frame/dispatch-sync [::events/initialize-db]) (re-frame/dispatch-sync [::events/initialize-db])
(re-frame/dispatch [::events/load-otio]) (re-frame/dispatch [::events/load-otio])
(dev-setup) (dev-setup)

View file

@ -10,12 +10,36 @@
;; Mark-groups: the annotation layer (see tl.marks for the grammar). ;; Mark-groups: the annotation layer (see tl.marks for the grammar).
;; Clips come from :timeline; these are the user-authored groups + the ;; Clips come from :timeline; these are the user-authored groups + the
;; timeline descriptors. A timeline's :mark-groups says which annotations ;; root timeline descriptor.
;; IT surfaces ("*" | [ids] | {:except #{ids}}); the top of :timeline-stack ;;
;; decides what's currently visible. For now everything is hardcoded. ;; Nesting: every annotation has a :parent (the context it lives in; :root =
;; top level). "Expanding" an annotation pushes its id onto :timeline-stack;
;; the top of the stack is the current context, and only annotations whose
;; :parent matches it are surfaced — so a nested annotation is invisible on
;; the main timeline. The context annotation's own spans crop the timeline
;; (time window + involved tracks) it's expanded into.
;;
;; The annotations below are seed data (shown on a fresh localStorage) so the
;; expand/nest flow is testable: "Opening rally" has two children.
:marks :marks
{:timeline-stack [:root] {:timeline-stack [:root]
:groups {:root {:type :timeline :name "Sequence" :mark-groups "*"}}} :groups {:root {:type :timeline :name "Sequence"}
:ann-rally
{:type :annotation :parent :root :name "Opening rally" :color "#4e8fc2"
:content "The first volley — CU coverage across the three principals."
:marks [:t2-c0 :t3-c0 :t4-c0]}
:ann-tashi-read
{:type :annotation :parent :ann-rally :name "Tashi's read" :color "#c2624e"
:content "She clocks the spin early."
:marks [[[:at :t2-c0 0] [:at :t2-c0 -1]]]}
:ann-art-react
{:type :annotation :parent :ann-rally :name "Art reacts" :color "#5ab07a"
:marks [:t3-c0]}
:ann-net
{:type :annotation :parent :root :name "Net-cam beat" :color "#9a7ac2"
:marks [:t6-c0]}}}
;; transient view state (mutates while scrubbing/zooming) ;; transient view state (mutates while scrubbing/zooming)
:view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps) :view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps)

View file

@ -87,13 +87,35 @@
(update-in db [:draft :rows] (update-in db [:draft :rows]
#(into (subvec % 0 i) (subvec % (inc i)))))) #(into (subvec % 0 i) (subvec % (inc i))))))
;; --- nested timelines: expand an annotation / navigate back -------------
;; The timeline-stack is the breadcrumb trail. Expanding pushes an annotation
;; id; the top of the stack is the current context (see ::context-id).
(rf/reg-event-db
::expand-annotation
(fn [db [_ id]] (update-in db [:marks :timeline-stack] conj id)))
(rf/reg-event-db
::collapse ; the back button: pop one level
(fn [db _] (update-in db [:marks :timeline-stack]
(fn [s] (if (> (count s) 1) (pop s) s)))))
(rf/reg-event-db
::pop-to ; a breadcrumb: truncate to that id
(fn [db [_ id]]
(update-in db [:marks :timeline-stack]
(fn [s] (let [i (first (keep-indexed #(when (= id %2) %1) s))]
(if i (subvec s 0 (inc i)) s))))))
;; --- authoring: create / delete annotations (persisted) ----------------- ;; --- authoring: create / delete annotations (persisted) -----------------
(rf/reg-event-fx (rf/reg-event-fx
::add-annotation ::add-annotation
(fn [{:keys [db]} [_ {:keys [name content color marks]}]] (fn [{:keys [db]} [_ {:keys [name content color marks]}]]
;; new annotations are children of whatever context we're currently in
(let [id (keyword (str "ann-" (random-uuid))) (let [id (keyword (str "ann-" (random-uuid)))
group (cond-> {:type :annotation :name name :color color :marks marks} parent (last (get-in db [:marks :timeline-stack]))
group (cond-> {:type :annotation :parent parent :name name :color color :marks marks}
(seq content) (assoc :content content)) (seq content) (assoc :content content))
db' (assoc-in db [:marks :groups id] group)] db' (assoc-in db [:marks :groups id] group)]
{:db db' :tl/save (:marks db')}))) {:db db' :tl/save (:marks db')})))
@ -101,13 +123,25 @@
(rf/reg-event-fx (rf/reg-event-fx
::update-annotation ::update-annotation
(fn [{:keys [db]} [_ id {:keys [name content color marks]}]] (fn [{:keys [db]} [_ id {:keys [name content color marks]}]]
(let [group (cond-> {:type :annotation :name name :color color :marks marks} ;; merge so :parent (and any other group keys) survive an edit; drop a
;; cleared :content explicitly rather than leaving the old text behind.
(let [group (cond-> {:name name :color color :marks marks :content nil}
(seq content) (assoc :content content)) (seq content) (assoc :content content))
db' (assoc-in db [:marks :groups id] group)] db' (update-in db [:marks :groups id] merge group)]
{:db db' :tl/save (:marks db')}))) {:db db' :tl/save (:marks db')})))
(rf/reg-event-fx (rf/reg-event-fx
::delete-annotation ::delete-annotation
(fn [{:keys [db]} [_ id]] (fn [{:keys [db]} [_ id]]
(let [db' (update-in db [:marks :groups] dissoc id)] ;; remove the annotation and its whole nested subtree (so children aren't
;; orphaned), and pop the stack past anything we just deleted.
(let [groups (get-in db [:marks :groups])
doomed (loop [acc #{id}]
(let [more (into acc (keep (fn [[gid g]] (when (acc (:parent g)) gid)) groups))]
(if (= more acc) acc (recur more))))
db' (-> db
(update-in [:marks :groups] #(apply dissoc % doomed))
(update-in [:marks :timeline-stack]
(fn [s] (let [s' (vec (take-while #(not (doomed %)) s))]
(if (seq s') s' [:root])))))]
{:db db' :tl/save (:marks db')}))) {:db db' :tl/save (:marks db')})))

View file

@ -36,37 +36,74 @@
(sort-by :start) (sort-by :start)
vec)))) vec))))
;; The current context = top of the timeline-stack. :root is the main timeline;
;; otherwise it's the annotation whose nested timeline we're expanded into.
(rf/reg-sub (rf/reg-sub
::current-timeline ::context-id
:<- [::groups] :<- [::timeline-stack] :<- [::timeline-stack]
(fn [[groups stack] _] (get groups (last stack)))) (fn [stack _] (or (last stack) :root)))
(defn- admits?
"Does a timeline's :mark-groups rule surface annotation `gid`?"
[rule gid]
(cond
(= "*" rule) true
(vector? rule) (boolean (some #{gid} rule))
(map? rule) (not (contains? (set (:except rule)) gid))
:else false))
;; Annotations surfaced right now: those whose :parent is the current context.
;; (A missing :parent means top-level, i.e. :root — keeps old saved data valid.)
;; Each is enriched with how many children it has, so the UI can flag/expand it.
(rf/reg-sub (rf/reg-sub
::annotations ::annotations
:<- [::groups] :<- [::clip-index] :<- [::current-timeline] :<- [::groups] :<- [::clip-index] :<- [::context-id]
(fn [[groups idx cur] _] (fn [[groups idx ctx] _]
(when idx (when idx
(let [rule (:mark-groups cur "*")] (let [anns (filter (fn [[_ g]] (= :annotation (:type g))) groups)
(->> groups child-count (frequencies (keep (fn [[_ g]] (:parent g :root)) anns))]
(filter (fn [[gid g]] (and (= :annotation (:type g)) (admits? rule gid)))) (->> anns
(filter (fn [[_ g]] (= ctx (:parent g :root))))
(map (fn [[gid g]] (map (fn [[gid g]]
(let [spans (marks/resolve-group idx groups gid)] (let [spans (marks/resolve-group idx groups gid)]
{:id gid :name (:name g) :content (:content g) :color (:color g) {:id gid :name (:name g) :content (:content g) :color (:color g)
:marks (:marks g) :marks (:marks g) :children (get child-count gid 0)
:spans spans :anchor (marks/anchor spans) :spans spans :anchor (marks/anchor spans)
:instant? (every? :instant? spans)}))) :instant? (every? :instant? spans)})))
(sort-by :anchor) (sort-by :anchor)
vec))))) vec)))))
;; Breadcrumb trail for the stack: root + each expanded annotation's name.
(rf/reg-sub
::breadcrumbs
:<- [::groups] :<- [::timeline-stack]
(fn [[groups stack] _]
(mapv (fn [gid] {:id gid :name (:name (groups gid) (name gid))}) stack)))
;; Crop: when expanded into an annotation, its own spans define the visible
;; window (time range) and the set of involved tracks. nil at the root.
(rf/reg-sub
::crop
:<- [::groups] :<- [::clip-index] :<- [::context-id]
(fn [[groups idx ctx] _]
(when (and idx (not= :root ctx) (groups ctx))
(let [spans (marks/resolve-group idx groups ctx)]
(when (seq spans)
{:window [(reduce min (map :start spans)) (reduce max (map :end spans))]
:tracks (set (keep :track spans))})))))
;; Video tracks to render, filtered to the crop's involved tracks when expanded.
;; (Audio is never shown.) Returns {:track ... :visible? bool} so the timeline
;; can collapse — rather than drop — hidden rows and animate them out.
(rf/reg-sub
::visible-tracks
:<- [::tracks] :<- [::crop]
(fn [[tracks crop] _]
(let [tset (when (seq (:tracks crop)) (:tracks crop))]
(->> tracks
(filter #(= :video (:kind %)))
(mapv (fn [t] (assoc t :visible? (or (nil? tset) (contains? tset (:name t))))))))))
;; View transform applied to the timeline: a frame offset (so the crop window's
;; start sits at the left edge) and the visible span length in frames.
(rf/reg-sub
::view-offset :<- [::crop]
(fn [crop _] (if crop (first (:window crop)) 0)))
(rf/reg-sub
::view-frames :<- [::crop] :<- [::duration]
(fn [[crop dur] _] (if crop (- (second (:window crop)) (first (:window crop))) dur)))
;; The annotation the playhead is currently "in" — the latest one whose anchor ;; The annotation the playhead is currently "in" — the latest one whose anchor
;; we've reached. Drives the commentary scroll + highlight. ;; we've reached. Drives the commentary scroll + highlight.
(rf/reg-sub (rf/reg-sub

View file

@ -29,9 +29,12 @@
sec (js/Math.floor (mod s 60))] sec (js/Math.floor (mod s 60))]
(str m ":" (when (< sec 10) "0") sec))) (str m ":" (when (< sec 10) "0") sec)))
(defn- center-on-playhead! [fps zoom playhead] (defn- center-on-playhead!
"Scroll the timeline so `playhead` sits at the horizontal centre. `off` is the
crop offset in frames (0 at the root; the window start when expanded)."
[fps zoom off playhead]
(when-let [el @scroll-el] (when-let [el @scroll-el]
(let [px (* (secs playhead fps) zoom) (let [px (* (secs (- playhead off) fps) zoom)
max-sl (max 0 (- (.-scrollWidth el) (.-clientWidth el))) max-sl (max 0 (- (.-scrollWidth el) (.-clientWidth el)))
target (-> (- px (/ (.-clientWidth el) 2)) (max 0) (min max-sl))] target (-> (- px (/ (.-clientWidth el) 2)) (max 0) (min max-sl))]
(set! (.-scrollLeft el) target)))) (set! (.-scrollLeft el) target))))
@ -48,7 +51,8 @@
"Jump (seek video + move playhead) AND scroll the timeline to center the frame." "Jump (seek video + move playhead) AND scroll the timeline to center the frame."
[fps frame] [fps frame]
(jump! fps frame) (jump! fps frame)
(center-on-playhead! fps (or @(rf/subscribe [::subs/zoom]) 50) frame)) (center-on-playhead! fps (or @(rf/subscribe [::subs/zoom]) 50)
(or @(rf/subscribe [::subs/view-offset]) 0) frame))
;; --- authoring draft (in app-db at :draft; nil = closed) ----------------- ;; --- authoring draft (in app-db at :draft; nil = closed) -----------------
;; A point is {:text "..."} (a typed frame) or {:clip <id> :setup <s> :occ <n> ;; A point is {:text "..."} (a typed frame) or {:clip <id> :setup <s> :occ <n>
@ -151,6 +155,7 @@
(fn [_fps] (fn [_fps]
[:video {:src media-src [:video {:src media-src
:controls true :controls true
:preload "auto"
;; Keep playback inline on iOS instead of jumping to fullscreen. ;; Keep playback inline on iOS instead of jumping to fullscreen.
:plays-inline true :plays-inline true
:webkit-playsinline "true" :webkit-playsinline "true"
@ -208,7 +213,7 @@
"+ Add annotation"]] "+ Add annotation"]]
(if (seq anns) (if (seq anns)
(doall (doall
(for [{:keys [id content spans] nm :name :as ann} anns] (for [{:keys [id content spans children] nm :name :as ann} anns]
(let [targets (vec (jump-targets fps nm spans)) (let [targets (vec (jump-targets fps nm spans))
one? (= 1 (count targets))] one? (= 1 (count targets))]
^{:key id} ^{:key id}
@ -225,6 +230,13 @@
;; the one bit of JS (anchor() misbehaves in a scroll box). ;; the one bit of JS (anchor() misbehaves in a scroll box).
[:button.jump-btn {:popovertarget (str "jp-" (name id))} [:button.jump-btn {:popovertarget (str "jp-" (name id))}
(str "↪ jump (" (count targets) ")")]) (str "↪ jump (" (count targets) ")")])
[:button.expand-btn
{:title (if (pos? children)
(str "Expand — " children " nested")
"Expand into a nested timeline")
:class (when (pos? children) "has-children")
:on-click #(rf/dispatch [::events/expand-annotation id])}
(if (pos? children) (str "⤢ " children) "⤢")]
[:button.edit-btn [:button.edit-btn
{:title "Edit annotation" {:title "Edit annotation"
:on-click #(rf/dispatch [::events/open-draft :on-click #(rf/dispatch [::events/open-draft
@ -250,8 +262,9 @@
(defn annotation-lane (defn annotation-lane
"One labeled row per annotation, above the tracks. Bars are positioned in "One labeled row per annotation, above the tracks. Bars are positioned in
display frames (same coordinate as clips), so they align with the timeline." display frames (same coordinate as clips), so they align with the timeline.
[fps zoom annotations active] `off` is the crop offset (frames): positions are drawn relative to it."
[fps zoom off annotations active]
[:div [:div
(doall (doall
(for [{:keys [id name spans preview?] c :color} annotations] (for [{:keys [id name spans preview?] c :color} annotations]
@ -261,7 +274,7 @@
[:div.ann-lane-row [:div.ann-lane-row
(doall (doall
(for [[j s] (map-indexed vector spans)] (for [[j s] (map-indexed vector spans)]
(let [left (* (secs (:start s) fps) zoom) (let [left (* (secs (- (:start s) off) fps) zoom)
w (* (secs (- (:end s) (:start s)) fps) zoom)] w (* (secs (- (:end s) (:start s)) fps) zoom)]
^{:key j} ^{:key j}
[:div.ann-bar [:div.ann-bar
@ -270,10 +283,10 @@
:background (cond preview? (str color "44") on? color :else (str color "80")) :background (cond preview? (str color "44") on? color :else (str color "80"))
:border (str (if preview? "1px dashed " "1px solid ") color)}}]))) :border (str (if preview? "1px dashed " "1px solid ") color)}}])))
(when-let [a (some-> spans first :start)] (when-let [a (some-> spans first :start)]
[:div.ann-bar-label {:style {:left (+ 4 (* (secs a fps) zoom))}} name])])))]) [:div.ann-bar-label {:style {:left (+ 4 (* (secs (- a off) fps) zoom))}} name])])))])
(defn clip-block [fps zoom row-h authoring? {:keys [id name media-in duration] :as _clip}] (defn clip-block [fps zoom off row-h authoring? {:keys [id name media-in duration] :as _clip}]
(let [left (* (secs media-in fps) zoom) (let [left (* (secs (- media-in off) fps) zoom)
width (max 1 (* (secs duration fps) zoom))] width (max 1 (* (secs duration fps) zoom))]
[:div.clip {:style {:left left :width width :height (- row-h 2) [:div.clip {:style {:left left :width width :height (- row-h 2)
:line-height (str (- row-h 2) "px")} :line-height (str (- row-h 2) "px")}
@ -285,26 +298,34 @@
(insert-clip! (keyword id))))} (insert-clip! (keyword id))))}
name])) name]))
(defn track-row [fps zoom row-h authoring? track] (defn track-row
[:div.track-row {:style {:height row-h}} "A track lane. When cropped out of the current view it collapses to height 0
(for [c (:clips track)] (CSS transitions the height) rather than vanishing, so tracks animate away."
^{:key (:id c)} [clip-block fps zoom row-h authoring? c])]) [fps zoom off row-h authoring? track]
(let [vis? (:visible? track true)]
[:div.track-row {:class (when-not vis? "collapsed")
:style {:height (if vis? row-h 0)}}
;; clips render even while collapsing (overflow:hidden clips them) so the
;; row's contents shrink away with it instead of blanking instantly
(for [c (:clips track)]
^{:key (:id c)} [clip-block fps zoom off row-h authoring? c])]))
(defn- ev->frames (defn- ev->frames
"Pointer clientX -> clamped timeline frame, relative to the content div "Pointer clientX -> clamped timeline frame, relative to the content div
(whose bounding rect already accounts for horizontal scroll)." (whose bounding rect already accounts for horizontal scroll). `off`/`end`
[content clientX zoom fps duration] are the visible window bounds in frames."
[content clientX zoom fps off end]
(let [px (- clientX (.-left (.getBoundingClientRect content))) (let [px (- clientX (.-left (.getBoundingClientRect content)))
frames (* (/ px zoom) fps)] frames (+ off (* (/ px zoom) fps))]
(-> frames (max 0) (min duration)))) (-> frames (max off) (min end))))
(defn- begin-scrub! (defn- begin-scrub!
"mousedown on the timeline: seek to the click, then follow the mouse (even "mousedown on the timeline: seek to the click, then follow the mouse (even
outside the element) until mouseup." outside the element) until mouseup."
[content zoom fps duration ev] [content zoom fps off end ev]
(.preventDefault ev) (.preventDefault ev)
(let [to (fn [clientX] (let [to (fn [clientX]
(let [f (ev->frames content clientX zoom fps duration)] (let [f (ev->frames content clientX zoom fps off end)]
(rf/dispatch [::events/set-playhead f]) ; move the red line now (rf/dispatch [::events/set-playhead f]) ; move the red line now
(seek-to-frame! fps f))) ; and seek the video (seek-to-frame! fps f))) ; and seek the video
move (fn [e] (to (.-clientX e))) move (fn [e] (to (.-clientX e)))
@ -321,12 +342,15 @@
(let [fps @(rf/subscribe [::subs/fps]) (let [fps @(rf/subscribe [::subs/fps])
zoom @(rf/subscribe [::subs/zoom]) zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h]) row-h @(rf/subscribe [::subs/row-h])
duration @(rf/subscribe [::subs/duration]) off @(rf/subscribe [::subs/view-offset])
tracks @(rf/subscribe [::subs/tracks]) vframes @(rf/subscribe [::subs/view-frames])
playhead @(rf/subscribe [::subs/playhead]) playhead @(rf/subscribe [::subs/playhead])
playing? @(rf/subscribe [::subs/playing?]) playing? @(rf/subscribe [::subs/playing?])
anns @(rf/subscribe [::subs/annotations]) anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation]) active @(rf/subscribe [::subs/active-annotation])
;; all video tracks, each flagged :visible? by the crop (hidden ones
;; collapse rather than disappear, so they animate out)
vtracks @(rf/subscribe [::subs/visible-tracks])
;; live, un-saved preview of the annotation currently being authored ;; live, un-saved preview of the annotation currently being authored
d @(rf/subscribe [::subs/draft]) d @(rf/subscribe [::subs/draft])
preview (when d preview (when d
@ -342,31 +366,34 @@
:spans spans}))) :spans spans})))
lane-anns (let [base (if (:id d) (remove #(= (:id %) (:id d)) anns) anns)] lane-anns (let [base (if (:id d) (remove #(= (:id %) (:id d)) anns) anns)]
(cond-> (vec base) preview (conj preview))) (cond-> (vec base) preview (conj preview)))
vtracks (filter #(= :video (:kind %)) tracks) ; audio hidden
lane-h (* 18 (count lane-anns)) lane-h (* 18 (count lane-anns))
width (* (secs duration fps) zoom)] end (+ off vframes)
width (* (secs vframes fps) zoom)]
(when (and playing? @following?) (when (and playing? @following?)
(r/after-render #(center-on-playhead! fps zoom playhead))) (r/after-render #(center-on-playhead! fps zoom off playhead)))
[:div.timeline [:div.timeline
;; fixed left gutter: setup (track) names, always visible ;; fixed left gutter: setup (track) names, in lockstep with the rows
;; (collapsed tracks shrink here too so labels stay aligned)
[:div.gutter [:div.gutter
[:div {:style {:height lane-h}}] ; spacer aligning labels with rows [:div {:style {:height lane-h}}] ; spacer aligning labels with rows
(for [t vtracks] (for [t vtracks]
^{:key (:id t)} ^{:key (:id t)}
[:div.gutter-label {:style {:height row-h :line-height (str (dec row-h) "px")}} [:div.gutter-label {:class (when-not (:visible? t) "collapsed")
:style {:height (if (:visible? t) row-h 0)
:line-height (str (dec row-h) "px")}}
(:name t)])] (:name t)])]
;; horizontally-scrolling timeline ;; horizontally-scrolling timeline
[:div.hscroll {:ref (fn [n] (reset! scroll-el n)) [:div.hscroll {:ref (fn [n] (reset! scroll-el n))
:on-wheel (fn [_] (reset! following? false))} :on-wheel (fn [_] (reset! following? false))}
[:div.content {:ref (fn [n] (reset! content n)) [:div.content {:ref (fn [n] (reset! content n))
:on-mouse-down (fn [e] (begin-scrub! @content zoom fps duration e)) :on-mouse-down (fn [e] (begin-scrub! @content zoom fps off end e))
:style {:width width}} :style {:width width}}
(let [x (* (secs playhead fps) zoom)] (let [x (* (secs (- playhead off) fps) zoom)]
[:div.playhead {:style {:left x}} [:div.playhead {:style {:left x}}
[:div.playhead-handle]]) [:div.playhead-handle]])
[annotation-lane fps zoom lane-anns active] [annotation-lane fps zoom off lane-anns active]
(for [t vtracks] (for [t vtracks]
^{:key (:id t)} [track-row fps zoom row-h (some? d) t])]]])))) ^{:key (:id t)} [track-row fps zoom off row-h (some? d) t])]]]))))
;; --- zoom + layout shell -------------------------------------------------- ;; --- zoom + layout shell --------------------------------------------------
@ -382,12 +409,33 @@
(let [el @scroll-el (let [el @scroll-el
fps @(rf/subscribe [::subs/fps]) fps @(rf/subscribe [::subs/fps])
ph @(rf/subscribe [::subs/playhead]) ph @(rf/subscribe [::subs/playhead])
off (or @(rf/subscribe [::subs/view-offset]) 0)
z0 @(rf/subscribe [::subs/zoom])] z0 @(rf/subscribe [::subs/zoom])]
(rf/dispatch [::events/set-zoom z1]) (rf/dispatch [::events/set-zoom z1])
(when el (when el
(let [target (max 0 (+ (.-scrollLeft el) (* (secs ph fps) (- z1 z0))))] (let [target (max 0 (+ (.-scrollLeft el) (* (secs (- ph off) fps) (- z1 z0))))]
(r/after-render #(set! (.-scrollLeft el) target)))))) (r/after-render #(set! (.-scrollLeft el) target))))))
(defn breadcrumbs
"Trail of the expanded-timeline stack. Only shown when nested. The back button
pops one level; clicking a crumb jumps straight back to it. Either way the
main timeline is fully restored."
[]
(let [crumbs @(rf/subscribe [::subs/breadcrumbs])]
(when (> (count crumbs) 1)
[:div.crumbs
[:button.back-btn {:title "Back one level" :on-click #(rf/dispatch [::events/collapse])}
"← back"]
(doall
(for [[i {:keys [id name]}] (map-indexed vector crumbs)]
(let [last? (= i (dec (count crumbs)))]
^{:key id}
[:span.crumb-item
(when (pos? i) [:span.crumb-sep "›"])
[:button.crumb {:class (when last? "current") :disabled last?
:on-click #(rf/dispatch [::events/pop-to id])}
name]])))])))
(defn zoom-controls [] (defn zoom-controls []
(let [zoom @(rf/subscribe [::subs/zoom]) (let [zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h]) row-h @(rf/subscribe [::subs/row-h])
@ -548,6 +596,7 @@
{:on-pointer-down #(begin-resize! :y ::events/set-top-h 12 88 %)}] {:on-pointer-down #(begin-resize! :y ::events/set-top-h 12 88 %)}]
;; timeline region ;; timeline region
[:div.timeline-pane [:div.timeline-pane
[breadcrumbs]
[zoom-controls] [zoom-controls]
[:div.timeline-scroll [:div.timeline-scroll
(case status (case status