feat: reveal child annotations in-place + lane packing + range warnings

- Greedy annotation lane collapse: non-overlapping annotations share a lane
  (packed by earliest start, then longest span); each annotation keeps all its
  segments in one lane.
- Clicking a lane bar jumps the playhead to that mark and scrolls its card into
  view instead of drilling into the annotation timeline.
- "Show N annotations" toggle nests an annotation's children (recursively) in the
  commentary pane and surfaces them in the lane; children reparent-drag correctly
  (stopPropagation so the child, not the parent, is moved).
- Out-of-range warning (⚠) on cards and the content display when an annotation
  references frames its parent timeline trims; the lane shows the best-effort
  in-range portion (scene/nested-src + scene/clip-loss?).
- Annotation-card markdown: 12px body, larger heading ramp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-02 20:17:03 -04:00
parent 2508066381
commit 65acfbc886
5 changed files with 151 additions and 28 deletions

View file

@ -316,6 +316,19 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
.ann-tags .tag { font-size: 9px; padding: 0 6px 0 12px; line-height: 1.6; border-radius: 2px 8px 8px 2px; } .ann-tags .tag { font-size: 9px; padding: 0 6px 0 12px; line-height: 1.6; border-radius: 2px 8px 8px 2px; }
.ann-tags .tag::before { width: 3px; height: 3px; left: 5px; } .ann-tags .tag::before { width: 3px; height: 3px; left: 5px; }
/* reveal-children toggle at the card bottom + the nested child cards */
.show-children { display: block; width: 100%; margin-top: 8px; padding: 3px 6px;
font-family: var(--chicago); font-size: 11px; text-align: left;
color: var(--ink); background: var(--shade); cursor: pointer;
border: 1px solid var(--ink); border-radius: 0; }
.show-children:hover { background: var(--ink); color: var(--paper); }
.ann-children { margin: 8px 0 0 10px; padding-left: 8px;
border-left: 2px solid var(--ann-color, var(--ink)); }
.ann-children .ann { margin-bottom: 6px; }
.ann-children .ann:last-child { margin-bottom: 0; }
/* out-of-range warning: annotation references frames its parent timeline trims */
.ann-warn { cursor: help; font-style: normal; }
/* --- timeline tag filter ------------------------------------------------ */ /* --- timeline tag filter ------------------------------------------------ */
.tag-filter { position: relative; display: inline-flex; } .tag-filter { position: relative; display: inline-flex; }
.filter-btn { .filter-btn {
@ -348,11 +361,12 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
.save:active { box-shadow: 0 0 0 1px var(--paper), 0 0 0 3px var(--ink); } .save:active { box-shadow: 0 0 0 1px var(--paper), 0 0 0 3px var(--ink); }
/* --- markdown (tl.md output is wrapped in .md) -------------------------- */ /* --- markdown (tl.md output is wrapped in .md) -------------------------- */
.md { font-size: 12px; line-height: 1.45; }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-family: var(--chicago); } .md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-family: var(--chicago); }
.md h1 { font-size: 16px; margin: 0 0 6px; line-height: 1.25; } .md h1 { font-size: 22px; margin: 0 0 6px; line-height: 1.2; }
.md h2 { font-size: 14px; margin: 0 0 6px; line-height: 1.25; } .md h2 { font-size: 17px; margin: 0 0 6px; line-height: 1.22; }
.md h3 { font-size: 13px; margin: 0 0 6px; line-height: 1.25; } .md h3 { font-size: 15px; margin: 0 0 6px; line-height: 1.25; }
.md h4, .md h5, .md h6 { font-size: 12px; margin: 0 0 6px; } .md h4, .md h5, .md h6 { font-size: 13px; margin: 0 0 6px; }
.md p { margin: 0 0 8px; } .md p { margin: 0 0 8px; }
.md ul { margin: 0 0 8px 18px; padding: 0; } .md ul { margin: 0 0 8px 18px; padding: 0; }
.md code { font-family: var(--mono); background: var(--shade); padding: 0 3px; .md code { font-family: var(--mono); background: var(--shade); padding: 0 3px;

View file

@ -529,6 +529,12 @@
(let [next-db (assoc-in db [:view :playheads ctx] lf)] (let [next-db (assoc-in db [:view :playheads ctx] lf)]
(sync-route {:db next-db} next-db)))) (sync-route {:db next-db} next-db))))
(rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p))) (rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p)))
;; reveal an annotation's immediate children into the current timeline lane
(rf/reg-event-db ::toggle-children
(fn [db [_ gid]]
(update-in db [:view :revealed]
(fn [s] (let [s (or s #{})]
(if (s gid) (disj s gid) (conj s gid)))))))
(rf/reg-event-db ::set-zoom (fn [db [_ z]] (assoc-in db [:view :zoom] z))) (rf/reg-event-db ::set-zoom (fn [db [_ z]] (assoc-in db [:view :zoom] z)))
(rf/reg-event-db ::set-row-h (fn [db [_ h]] (assoc-in db [:view :row-h] h))) (rf/reg-event-db ::set-row-h (fn [db [_ h]] (assoc-in db [:view :row-h] h)))

View file

@ -203,6 +203,38 @@
vec) vec)
(resolve scene ctx))) (resolve scene ctx)))
(defn- src-intersect
"Clip source ranges `rs` (each [a b)) to the coverage `cover` (each [c d))."
[rs cover]
(vec (for [[a b] rs [c d] cover
:let [lo (max a c) hi (min b d)]
:when (< lo hi)]
[lo hi])))
(defn nested-src
"Source ranges of annotation `gid` clipped to every ancestor annotation between
it and `ctx` — what's still visible once the reveal chain trims it, i.e. the
same content you'd see if you expanded into its parent. A direct child of `ctx`
is just its own resolved source (the timeline itself does the clipping later).
Empty ⇒ the annotation is out of range in this reveal chain."
[scene ctx gid]
(loop [p (:parent (grp scene gid)), rs (mapv :src (resolve scene gid))]
(if (or (nil? p) (= p ctx))
rs
(recur (:parent (grp scene p))
(src-intersect rs (mapv :src (resolve scene p)))))))
(defn clip-loss?
"True when annotation `gid` loses any content once clipped to its immediate
parent annotation — it references frames outside the parent timeline, so it's
(partly) out of range there. Timeline/root parents contain everything."
[scene gid]
(let [p (:parent (grp scene gid))]
(when (and p (= :annotation (:type (grp scene p))))
(let [len (fn [rs] (reduce + (map (fn [[a b]] (- b a)) rs)))
own (mapv :src (resolve scene gid))]
(< (len (src-intersect own (mapv :src (resolve scene p)))) (len own))))))
;; --- editing: split a local selection into a run of single-clip marks ----- ;; --- editing: split a local selection into a run of single-clip marks -----
(defn selection->marks (defn selection->marks

View file

@ -39,6 +39,7 @@
(rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom]))) (rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom])))
(rf/reg-sub ::row-h (fn [db] (get-in db [:view :row-h]))) (rf/reg-sub ::row-h (fn [db] (get-in db [:view :row-h])))
(rf/reg-sub ::stack (fn [db] (get-in db [:view :stack]))) (rf/reg-sub ::stack (fn [db] (get-in db [:view :stack])))
(rf/reg-sub ::revealed (fn [db] (get-in db [:view :revealed] #{})))
(rf/reg-sub ::context :<- [::stack] (fn [stack _] (peek stack))) (rf/reg-sub ::context :<- [::stack] (fn [stack _] (peek stack)))
@ -90,24 +91,31 @@
;; child annotations of the current context, with their bars in local coords ;; child annotations of the current context, with their bars in local coords
(rf/reg-sub (rf/reg-sub
::annotations ::annotations
:<- [::scene] :<- [::context] :<- [::segments] :<- [::hidden-tags] :<- [::scene] :<- [::context] :<- [::segments] :<- [::hidden-tags] :<- [::revealed]
(fn [[scene ctx segs hidden-tags] _] (fn [[scene ctx segs hidden-tags revealed] _]
(let [nested (frequencies (keep (fn [[_ g]] (when (= :annotation (:type g)) (:parent g))) (let [nested (frequencies (keep (fn [[_ g]] (when (= :annotation (:type g)) (:parent g)))
(:groups scene)))] (:groups scene)))
;; shown when every ancestor up to ctx is revealed: a direct child of ctx
;; always shows; a deeper annotation shows only if its parent is revealed
;; AND that parent is itself shown.
shown? (fn shown? [p] (or (= ctx p)
(and (revealed p) (shown? (get-in scene [:groups p :parent])))))]
(->> (:groups scene) (->> (:groups scene)
(keep (fn [[gid g]] (keep (fn [[gid g]]
(when (and (= :annotation (:type g)) (= ctx (:parent g)) (when (and (= :annotation (:type g)) (shown? (:parent g))
;; a hidden tag hides every annotation carrying it; the ;; a hidden tag hides every annotation carrying it; the
;; :untagged sentinel hides annotations with no tags ;; :untagged sentinel hides annotations with no tags
(let [tags (get-in g [:meta :tags])] (let [tags (get-in g [:meta :tags])]
(not (or (some hidden-tags tags) (not (or (some hidden-tags tags)
(and (empty? tags) (contains? hidden-tags :untagged)))))) (and (empty? tags) (contains? hidden-tags :untagged))))))
(let [src-segs (scene/resolve scene gid) (let [src-ranges (scene/nested-src scene ctx gid) ; best effort here
bars (scene/merge-bars bars (scene/merge-bars
(mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs)) (mapcat (fn [[a b]] (scene/pieces segs a b)) src-ranges))
reason (scene/broken-reason scene gid) reason (scene/broken-reason scene gid)
oor (boolean (scene/clip-loss? scene gid))
hidden (get-in g [:meta :hidden])] hidden (get-in g [:meta :hidden])]
{:id gid :name (:name g) :color (or (:color g) "#4e8fc2") {:id gid :parent (:parent g)
:name (:name g) :color (or (:color g) "#4e8fc2")
:content (:content g) :children (count (:marks g)) :content (:content g) :children (count (:marks g))
:nested (get nested gid 0) :nested (get nested gid 0)
:draft (boolean (:draft g)) :draft (boolean (:draft g))
@ -116,7 +124,7 @@
:notes (->> (concat (:notes g) (mapcat :notes (:marks g))) :notes (->> (concat (:notes g) (mapcat :notes (:marks g)))
distinct distinct
(filterv #(= :script-note (get-in scene [:groups % :type])))) (filterv #(= :script-note (get-in scene [:groups % :type]))))
:broken (boolean reason) :reason reason :broken (boolean reason) :reason reason :oor oor
:hidden (boolean hidden) :hidden (boolean hidden)
:tags (vec (get-in g [:meta :tags])) :tags (vec (get-in g [:meta :tags]))
;; jump targets labelled from the marks' clip refs (same as ;; jump targets labelled from the marks' clip refs (same as

View file

@ -490,6 +490,40 @@
(r/after-render #(position-playhead! fps zoom playhead)) (r/after-render #(position-playhead! fps zoom playhead))
nil)) nil))
(defn- bars-overlap?
"Any bar in `as` overlapping any bar in `bs`? Bars are half-open [lo hi), so
touching (hi == lo) counts as side-by-side, not overlap."
[as bs]
(boolean (some (fn [[lo hi]]
(some (fn [[lo2 hi2]] (and (< lo hi2) (< lo2 hi))) bs))
as)))
(defn- ann-lanes
"Greedy vertical lane packing for annotation bars. An annotation keeps ALL its
segments in one lane, and drops to a lower lane if ANY of its bars overlaps ANY
bar already placed in that lane. Priority for the top lanes: earliest start,
then (same start) longest span. Returns {ann-id -> lane-index}."
[anns]
(let [prep (->> anns
(keep (fn [a]
(when-let [bars (seq (:bars a))]
(let [lo (reduce min (map first bars))
hi (reduce max (map second bars))]
{:id (:id a) :bars bars :start lo :len (- hi lo)}))))
(sort-by (juxt :start (comp - :len))))]
(loop [[a & more] prep, lanes [], out {}]
(if (nil? a)
out
(let [i (or (first (keep-indexed
(fn [idx lane-bars]
(when-not (bars-overlap? (:bars a) lane-bars) idx))
lanes))
(count lanes))
lanes (if (< i (count lanes))
(update lanes i into (:bars a))
(conj lanes (:bars a)))]
(recur more lanes (assoc out (:id a) i)))))))
(defn timeline [] (defn timeline []
(let [content (atom nil) (let [content (atom nil)
lanes-scroll (atom nil) lanes-scroll (atom nil)
@ -507,7 +541,12 @@
linking @(rf/subscribe [::subs/linking]) linking @(rf/subscribe [::subs/linking])
len @(rf/subscribe [::subs/length]) len @(rf/subscribe [::subs/length])
width (px len fps zoom) width (px len fps zoom)
lane-h (max 18 (* 18 (count anns))) hidden? (fn [a] (get-in scene [:groups (:id a) :meta :hidden]))
visible (remove hidden? anns)
hidden (filter hidden? anns)
lane-of (ann-lanes visible) ; {ann-id -> lane}
n-lanes (if (empty? lane-of) 0 (inc (reduce max (vals lane-of))))
lane-h (max 18 (* 18 (max n-lanes (count hidden))))
tracks-h (* row-h (count tracks)) tracks-h (* row-h (count tracks))
track-y (into {} (map-indexed (fn [i t] [(:id t) i]) tracks))] track-y (into {} (map-indexed (fn [i t] [(:id t) i]) tracks))]
[:div.timeline {:class (when-not @tracks-open? "tracks-collapsed")} [:div.timeline {:class (when-not @tracks-open? "tracks-collapsed")}
@ -532,8 +571,8 @@
[:div.ann-lanes {:style {:height lane-h :width width} [:div.ann-lanes {:style {:height lane-h :width width}
:on-mouse-down #(scrub! @content fps zoom %)} :on-mouse-down #(scrub! @content fps zoom %)}
;; annotation bars — solid for saved, dashed for the in-progress draft ;; annotation bars — solid for saved, dashed for the in-progress draft
;; visible annotations: full bars with labels ;; visible annotations: full bars with labels, greedily packed into lanes
(for [[i a] (map-indexed vector (filter (fn [a] (not (get-in (get-in scene [:groups (:id a)]) [:meta :hidden]))) anns)) (for [a visible
[j [lo hi]] (map-indexed vector (:bars a))] [j [lo hi]] (map-indexed vector (:bars a))]
^{:key (str (:id a) "-" j)} ^{:key (str (:id a) "-" j)}
[:div.ann-bar {:title (:name a) [:div.ann-bar {:title (:name a)
@ -542,20 +581,25 @@
(if linking (if linking
(do (.preventDefault e) ; pick: link to this timeline (do (.preventDefault e) ; pick: link to this timeline
(commit-link! {:kind :timeline :ref (:id a)} (:name a))) (commit-link! {:kind :timeline :ref (:id a)} (:name a)))
(rf/dispatch [::events/expand (:id a)])))) ;; jump the playhead to this mark and scroll its
:style {:top (+ 2 (* i 18)) :height 14 ;; card into view (don't drill into the timeline)
(do (goto! lo true)
(when-let [node (js/document.getElementById
(str "ann-" (name (:id a))))]
(.scrollIntoView node #js {:block "center" :behavior "smooth"}))))))
:style {:top (+ 2 (* (lane-of (:id a)) 18)) :height 14
:left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom)) :left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom))
:background (str (:color a) (if (:draft a) "44" "cc")) :background (str (:color a) (if (:draft a) "44" "cc"))
:cursor (if (:draft a) "default" "pointer") :cursor (if (:draft a) "default" "pointer")
:border-radius 2 :border-radius 2
:border (str (if (:draft a) "1px dashed " "1px solid ") (:color a))}}]) :border (str (if (:draft a) "1px dashed " "1px solid ") (:color a))}}])
;; visible annotation labels ;; visible annotation labels
(for [[i a] (map-indexed vector (filter (fn [a] (not (get-in (get-in scene [:groups (:id a)]) [:meta :hidden]))) anns)) :let [[lo _] (first (:bars a))] :when lo] (for [a visible :let [[lo _] (first (:bars a))] :when lo]
^{:key (str "lbl-" (:id a))} ^{:key (str "lbl-" (:id a))}
[:div.ann-bar-label {:style {:top (* i 18) [:div.ann-bar-label {:style {:top (* (lane-of (:id a)) 18)
:left (+ 4 (px lo fps zoom))}} (:name a)]) :left (+ 4 (px lo fps zoom))}} (:name a)])
;; hidden annotations: tiny colored dots with dither pattern — click to unhide ;; hidden annotations: tiny colored dots with dither pattern — click to unhide
(for [[i a] (map-indexed vector (filter (fn [a] (get-in (get-in scene [:groups (:id a)]) [:meta :hidden])) anns)) :let [[lo _] (first (:bars a))] :when lo] (for [[i a] (map-indexed vector hidden) :let [[lo _] (first (:bars a))] :when lo]
^{:key (str "hidden-" (:id a))} ^{:key (str "hidden-" (:id a))}
[:div.ann-hidden {:title (str (:name a) " (click to unhide)") [:div.ann-hidden {:title (str (:name a) " (click to unhide)")
:on-click #(do (.stopPropagation %) :on-click #(do (.stopPropagation %)
@ -968,22 +1012,26 @@
(defn- ann-drag-props [a authed? over?] (defn- ann-drag-props [a authed? over?]
(when authed? (when authed?
{:draggable true {:draggable true
;; cards nest, so stop each drag event at the card it fires on — otherwise it
;; bubbles to the ancestor card and the parent's handler wins (dragging a
;; child ends up moving the parent).
:on-drag-start (fn [e] :on-drag-start (fn [e]
(.stopPropagation e)
(.. e -dataTransfer (setData "text/ann" (name (:id a)))) (.. e -dataTransfer (setData "text/ann" (name (:id a))))
(set! (.. e -dataTransfer -effectAllowed) "move") (set! (.. e -dataTransfer -effectAllowed) "move")
(rf/dispatch [::events/ann-drag-start (:id a)])) (rf/dispatch [::events/ann-drag-start (:id a)]))
:on-drag-end (fn [_] (reset! over? false) (rf/dispatch [::events/ann-drag-end])) :on-drag-end (fn [_] (reset! over? false) (rf/dispatch [::events/ann-drag-end]))
:on-drag-over (fn [e] :on-drag-over (fn [e]
(when (has-type? e "text/ann") (when (has-type? e "text/ann")
(.preventDefault e) (reset! over? true))) (.stopPropagation e) (.preventDefault e) (reset! over? true)))
:on-drag-leave (fn [_] (reset! over? false)) :on-drag-leave (fn [_] (reset! over? false))
:on-drop (fn [e] :on-drop (fn [e]
(let [src (.. e -dataTransfer (getData "text/ann"))] (let [src (.. e -dataTransfer (getData "text/ann"))]
(when (seq src) (when (seq src)
(.preventDefault e) (reset! over? false) (.stopPropagation e) (.preventDefault e) (reset! over? false)
(rf/dispatch [::events/reparent (keyword src) (:id a)]))))})) (rf/dispatch [::events/reparent (keyword src) (:id a)]))))}))
(defn- annotation-card [a scene ctx segs nmap authed? open] (defn- annotation-card [a scene ctx segs nmap authed? open by-parent]
(r/with-let [over? (r/atom false) hov? (r/atom false)] (r/with-let [over? (r/atom false) hov? (r/atom false)]
(let [active? @(rf/subscribe [::subs/annotation-active? (:id a)]) (let [active? @(rf/subscribe [::subs/annotation-active? (:id a)])
dragging @(rf/subscribe [::subs/dragging-ann]) dragging @(rf/subscribe [::subs/dragging-ann])
@ -1013,7 +1061,9 @@
[:div.ann-head [:div.ann-head
[:div.ann-title [:div.ann-title
[:span.ann-swatch {:style {:background (:color a)}}] [:span.ann-swatch {:style {:background (:color a)}}]
(when (:broken a) [:span {:title (:reason a)} "△ "]) (:name a)] (when (:broken a) [:span.ann-warn {:title (:reason a)} "△ "])
(when (:oor a) [:span.ann-warn {:title "Out of range — trimmed by the parent timeline"} "⚠ "])
(:name a)]
[:div.ann-actions [:div.ann-actions
[jump-control open a scene segs] [jump-control open a scene segs]
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
@ -1031,7 +1081,16 @@
^{:key (name ng)} [note-live-chip ng n])]) ^{:key (name ng)} [note-live-chip ng n])])
(when (seq (:tags a)) (when (seq (:tags a))
(into [:div.ann-tags] (into [:div.ann-tags]
(for [t (:tags a)] ^{:key t} [tag-chip t])))])))) (for [t (:tags a)] ^{:key t} [tag-chip t])))
(when (pos? (:nested a))
(let [shown? (contains? @(rf/subscribe [::subs/revealed]) (:id a))]
[:button.show-children {:on-click #(rf/dispatch [::events/toggle-children (:id a)])}
(str (if shown? "▾ Hide " "▸ Show ") (:nested a)
(if (= 1 (:nested a)) " annotation" " annotations"))]))
(when-let [kids (seq (get by-parent (:id a)))]
(into [:div.ann-children]
(for [k kids]
^{:key (:id k)} [annotation-card k scene ctx segs nmap authed? open by-parent])))]))))
;; Renders nothing: owns the active-annotation subscription and scrolls the active ;; Renders nothing: owns the active-annotation subscription and scrolls the active
;; card into view, so `commentary` itself no longer re-renders on every crossing. ;; card into view, so `commentary` itself no longer re-renders on every crossing.
@ -1058,15 +1117,19 @@
;; edit button — Edit drops into the parent timeline so marks are editable. ;; edit button — Edit drops into the parent timeline so marks are editable.
(let [cg (get-in scene [:groups ctx])] (let [cg (get-in scene [:groups ctx])]
[:div.ctx-content [:div.ctx-content
(when (scene/clip-loss? scene ctx)
[:span.ann-warn {:title "Out of range — this annotation references frames its parent timeline trims"} "⚠ "])
(if (seq (:content cg)) (if (seq (:content cg))
[content-display scene (or (:parent cg) ctx) (:content cg)] [content-display scene (or (:parent cg) ctx) (:content cg)]
[:div.muted "No description yet."]) [:div.muted "No description yet."])
(when authed? (when authed?
[:button.edit-btn {:on-click #(rf/dispatch [::events/edit-here ctx])} "✎ Edit"])]) [:button.edit-btn {:on-click #(rf/dispatch [::events/edit-here ctx])} "✎ Edit"])])
(if (seq anns) (if (seq anns)
(let [by-parent (group-by :parent anns)] ; nest revealed children under their parent
(doall (doall
(for [a anns] (for [a (get by-parent ctx)]
^{:key (:id a)} [annotation-card a scene ctx segs nmap authed? open])) ^{:key (:id a)}
[annotation-card a scene ctx segs nmap authed? open by-parent])))
[:div.ann-empty "No annotations here."])])))) [:div.ann-empty "No annotations here."])]))))
(defn- to-frame [v len] (defn- to-frame [v len]