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:
parent
f67baf4b99
commit
30cfbf3a1f
6 changed files with 241 additions and 60 deletions
|
|
@ -94,6 +94,13 @@ body { overflow: hidden; }
|
|||
|
||||
.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) ------------------------- */
|
||||
.form {
|
||||
flex: 1; min-width: 0; overflow-y: auto;
|
||||
|
|
@ -172,6 +179,21 @@ body { overflow: hidden; }
|
|||
/* --- timeline region ---------------------------------------------------- */
|
||||
.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;
|
||||
padding: 6px 8px; background: #0c0c0c; border-bottom: 1px solid #222;
|
||||
flex-shrink: 0; }
|
||||
|
|
@ -194,9 +216,16 @@ body { overflow: hidden; }
|
|||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.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 {
|
||||
position: absolute; top: 1px; box-sizing: border-box; overflow: hidden;
|
||||
background: #3a6ea5; border: 1px solid #16324f; border-radius: 2px;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@
|
|||
(rdom/unmount-component-at-node 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 []
|
||||
(reset-marks-once!)
|
||||
(re-frame/dispatch-sync [::events/initialize-db])
|
||||
(re-frame/dispatch [::events/load-otio])
|
||||
(dev-setup)
|
||||
|
|
|
|||
|
|
@ -10,12 +10,36 @@
|
|||
|
||||
;; Mark-groups: the annotation layer (see tl.marks for the grammar).
|
||||
;; Clips come from :timeline; these are the user-authored groups + the
|
||||
;; timeline descriptors. A timeline's :mark-groups says which annotations
|
||||
;; IT surfaces ("*" | [ids] | {:except #{ids}}); the top of :timeline-stack
|
||||
;; decides what's currently visible. For now everything is hardcoded.
|
||||
;; root timeline descriptor.
|
||||
;;
|
||||
;; 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
|
||||
{: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)
|
||||
:view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps)
|
||||
|
|
|
|||
|
|
@ -87,13 +87,35 @@
|
|||
(update-in db [:draft :rows]
|
||||
#(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) -----------------
|
||||
|
||||
(rf/reg-event-fx
|
||||
::add-annotation
|
||||
(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)))
|
||||
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))
|
||||
db' (assoc-in db [:marks :groups id] group)]
|
||||
{:db db' :tl/save (:marks db')})))
|
||||
|
|
@ -101,13 +123,25 @@
|
|||
(rf/reg-event-fx
|
||||
::update-annotation
|
||||
(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))
|
||||
db' (assoc-in db [:marks :groups id] group)]
|
||||
db' (update-in db [:marks :groups id] merge group)]
|
||||
{:db db' :tl/save (:marks db')})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::delete-annotation
|
||||
(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')})))
|
||||
|
|
|
|||
|
|
@ -36,37 +36,74 @@
|
|||
(sort-by :start)
|
||||
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
|
||||
::current-timeline
|
||||
:<- [::groups] :<- [::timeline-stack]
|
||||
(fn [[groups stack] _] (get groups (last stack))))
|
||||
|
||||
(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))
|
||||
::context-id
|
||||
:<- [::timeline-stack]
|
||||
(fn [stack _] (or (last stack) :root)))
|
||||
|
||||
;; 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
|
||||
::annotations
|
||||
:<- [::groups] :<- [::clip-index] :<- [::current-timeline]
|
||||
(fn [[groups idx cur] _]
|
||||
:<- [::groups] :<- [::clip-index] :<- [::context-id]
|
||||
(fn [[groups idx ctx] _]
|
||||
(when idx
|
||||
(let [rule (:mark-groups cur "*")]
|
||||
(->> groups
|
||||
(filter (fn [[gid g]] (and (= :annotation (:type g)) (admits? rule gid))))
|
||||
(let [anns (filter (fn [[_ g]] (= :annotation (:type g))) groups)
|
||||
child-count (frequencies (keep (fn [[_ g]] (:parent g :root)) anns))]
|
||||
(->> anns
|
||||
(filter (fn [[_ g]] (= ctx (:parent g :root))))
|
||||
(map (fn [[gid g]]
|
||||
(let [spans (marks/resolve-group idx groups gid)]
|
||||
{: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)
|
||||
:instant? (every? :instant? spans)})))
|
||||
(sort-by :anchor)
|
||||
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
|
||||
;; we've reached. Drives the commentary scroll + highlight.
|
||||
(rf/reg-sub
|
||||
|
|
|
|||
|
|
@ -29,9 +29,12 @@
|
|||
sec (js/Math.floor (mod s 60))]
|
||||
(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]
|
||||
(let [px (* (secs playhead fps) zoom)
|
||||
(let [px (* (secs (- playhead off) fps) zoom)
|
||||
max-sl (max 0 (- (.-scrollWidth el) (.-clientWidth el)))
|
||||
target (-> (- px (/ (.-clientWidth el) 2)) (max 0) (min max-sl))]
|
||||
(set! (.-scrollLeft el) target))))
|
||||
|
|
@ -48,7 +51,8 @@
|
|||
"Jump (seek video + move playhead) AND scroll the timeline to center the frame."
|
||||
[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) -----------------
|
||||
;; A point is {:text "..."} (a typed frame) or {:clip <id> :setup <s> :occ <n>
|
||||
|
|
@ -151,6 +155,7 @@
|
|||
(fn [_fps]
|
||||
[:video {:src media-src
|
||||
:controls true
|
||||
:preload "auto"
|
||||
;; Keep playback inline on iOS instead of jumping to fullscreen.
|
||||
:plays-inline true
|
||||
:webkit-playsinline "true"
|
||||
|
|
@ -208,7 +213,7 @@
|
|||
"+ Add annotation"]]
|
||||
(if (seq anns)
|
||||
(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))
|
||||
one? (= 1 (count targets))]
|
||||
^{:key id}
|
||||
|
|
@ -225,6 +230,13 @@
|
|||
;; the one bit of JS (anchor() misbehaves in a scroll box).
|
||||
[:button.jump-btn {:popovertarget (str "jp-" (name id))}
|
||||
(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
|
||||
{:title "Edit annotation"
|
||||
:on-click #(rf/dispatch [::events/open-draft
|
||||
|
|
@ -250,8 +262,9 @@
|
|||
|
||||
(defn annotation-lane
|
||||
"One labeled row per annotation, above the tracks. Bars are positioned in
|
||||
display frames (same coordinate as clips), so they align with the timeline."
|
||||
[fps zoom annotations active]
|
||||
display frames (same coordinate as clips), so they align with the timeline.
|
||||
`off` is the crop offset (frames): positions are drawn relative to it."
|
||||
[fps zoom off annotations active]
|
||||
[:div
|
||||
(doall
|
||||
(for [{:keys [id name spans preview?] c :color} annotations]
|
||||
|
|
@ -261,7 +274,7 @@
|
|||
[:div.ann-lane-row
|
||||
(doall
|
||||
(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)]
|
||||
^{:key j}
|
||||
[:div.ann-bar
|
||||
|
|
@ -270,10 +283,10 @@
|
|||
:background (cond preview? (str color "44") on? color :else (str color "80"))
|
||||
:border (str (if preview? "1px dashed " "1px solid ") color)}}])))
|
||||
(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}]
|
||||
(let [left (* (secs media-in fps) zoom)
|
||||
(defn clip-block [fps zoom off row-h authoring? {:keys [id name media-in duration] :as _clip}]
|
||||
(let [left (* (secs (- media-in off) fps) zoom)
|
||||
width (max 1 (* (secs duration fps) zoom))]
|
||||
[:div.clip {:style {:left left :width width :height (- row-h 2)
|
||||
:line-height (str (- row-h 2) "px")}
|
||||
|
|
@ -285,26 +298,34 @@
|
|||
(insert-clip! (keyword id))))}
|
||||
name]))
|
||||
|
||||
(defn track-row [fps zoom row-h authoring? track]
|
||||
[:div.track-row {:style {:height row-h}}
|
||||
(for [c (:clips track)]
|
||||
^{:key (:id c)} [clip-block fps zoom row-h authoring? c])])
|
||||
(defn track-row
|
||||
"A track lane. When cropped out of the current view it collapses to height 0
|
||||
(CSS transitions the height) rather than vanishing, so tracks animate away."
|
||||
[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
|
||||
"Pointer clientX -> clamped timeline frame, relative to the content div
|
||||
(whose bounding rect already accounts for horizontal scroll)."
|
||||
[content clientX zoom fps duration]
|
||||
(whose bounding rect already accounts for horizontal scroll). `off`/`end`
|
||||
are the visible window bounds in frames."
|
||||
[content clientX zoom fps off end]
|
||||
(let [px (- clientX (.-left (.getBoundingClientRect content)))
|
||||
frames (* (/ px zoom) fps)]
|
||||
(-> frames (max 0) (min duration))))
|
||||
frames (+ off (* (/ px zoom) fps))]
|
||||
(-> frames (max off) (min end))))
|
||||
|
||||
(defn- begin-scrub!
|
||||
"mousedown on the timeline: seek to the click, then follow the mouse (even
|
||||
outside the element) until mouseup."
|
||||
[content zoom fps duration ev]
|
||||
[content zoom fps off end ev]
|
||||
(.preventDefault ev)
|
||||
(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
|
||||
(seek-to-frame! fps f))) ; and seek the video
|
||||
move (fn [e] (to (.-clientX e)))
|
||||
|
|
@ -321,12 +342,15 @@
|
|||
(let [fps @(rf/subscribe [::subs/fps])
|
||||
zoom @(rf/subscribe [::subs/zoom])
|
||||
row-h @(rf/subscribe [::subs/row-h])
|
||||
duration @(rf/subscribe [::subs/duration])
|
||||
tracks @(rf/subscribe [::subs/tracks])
|
||||
off @(rf/subscribe [::subs/view-offset])
|
||||
vframes @(rf/subscribe [::subs/view-frames])
|
||||
playhead @(rf/subscribe [::subs/playhead])
|
||||
playing? @(rf/subscribe [::subs/playing?])
|
||||
anns @(rf/subscribe [::subs/annotations])
|
||||
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
|
||||
d @(rf/subscribe [::subs/draft])
|
||||
preview (when d
|
||||
|
|
@ -342,31 +366,34 @@
|
|||
:spans spans})))
|
||||
lane-anns (let [base (if (:id d) (remove #(= (:id %) (:id d)) anns) anns)]
|
||||
(cond-> (vec base) preview (conj preview)))
|
||||
vtracks (filter #(= :video (:kind %)) tracks) ; audio hidden
|
||||
lane-h (* 18 (count lane-anns))
|
||||
width (* (secs duration fps) zoom)]
|
||||
end (+ off vframes)
|
||||
width (* (secs vframes fps) zoom)]
|
||||
(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
|
||||
;; 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 {:style {:height lane-h}}] ; spacer aligning labels with rows
|
||||
(for [t vtracks]
|
||||
^{: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)])]
|
||||
;; horizontally-scrolling timeline
|
||||
[:div.hscroll {:ref (fn [n] (reset! scroll-el n))
|
||||
:on-wheel (fn [_] (reset! following? false))}
|
||||
[: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}}
|
||||
(let [x (* (secs playhead fps) zoom)]
|
||||
(let [x (* (secs (- playhead off) fps) zoom)]
|
||||
[:div.playhead {:style {:left x}}
|
||||
[:div.playhead-handle]])
|
||||
[annotation-lane fps zoom lane-anns active]
|
||||
[annotation-lane fps zoom off lane-anns active]
|
||||
(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 --------------------------------------------------
|
||||
|
||||
|
|
@ -382,12 +409,33 @@
|
|||
(let [el @scroll-el
|
||||
fps @(rf/subscribe [::subs/fps])
|
||||
ph @(rf/subscribe [::subs/playhead])
|
||||
off (or @(rf/subscribe [::subs/view-offset]) 0)
|
||||
z0 @(rf/subscribe [::subs/zoom])]
|
||||
(rf/dispatch [::events/set-zoom z1])
|
||||
(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))))))
|
||||
|
||||
(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 []
|
||||
(let [zoom @(rf/subscribe [::subs/zoom])
|
||||
row-h @(rf/subscribe [::subs/row-h])
|
||||
|
|
@ -548,6 +596,7 @@
|
|||
{:on-pointer-down #(begin-resize! :y ::events/set-top-h 12 88 %)}]
|
||||
;; timeline region
|
||||
[:div.timeline-pane
|
||||
[breadcrumbs]
|
||||
[zoom-controls]
|
||||
[:div.timeline-scroll
|
||||
(case status
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue