perf: stop full annotation-list re-render on every bar crossing

commentary dereferenced active-annotation/active-annotations/active-note-set
in its body, so the whole card list rebuilt (and React reconciled every card)
on every playhead bar-crossing during playback and on context enter — the
annotation-only jank + the enter "double flash".

Push the per-item active state into granular per-id subs
(::annotation-active? / ::note-active?): each card and each bound-note chip
subscribes to its own state, so a crossing re-renders only the item(s) that
flipped. The active-card scroll moves into a nil-rendering component that owns
the active-annotation sub, so commentary no longer re-renders for it either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-01 20:22:01 -04:00
parent d22bff7654
commit 05514f3522
2 changed files with 78 additions and 65 deletions

View file

@ -204,3 +204,12 @@
::active-note-set
:<- [::note-bars] :<- [::playhead]
(fn [[nbars ph] _] (gids-at nbars ph)))
;; per-id "is this one active right now" booleans. Subscribing these PER CARD /
;; PER CHIP (instead of the whole set in the parent) means a bar crossing only
;; re-renders the couple of items whose state actually flipped — not the entire
;; annotation list. re-frame caches each by id and only notifies on a real change.
(rf/reg-sub ::annotation-active? :<- [::active-annotations]
(fn [s [_ id]] (contains? s id)))
(rf/reg-sub ::note-active? :<- [::active-note-set]
(fn [s [_ id]] (contains? s id)))

View file

@ -897,23 +897,83 @@
[:button {:on-click #(do (goto! lo true) (reset! open nil))}
(str "▸ " (clip-at scene segs lo))])])])))
;; A bound-note chip on an annotation card. Owns its own »»» state so a bar
;; crossing only re-renders the chip that flipped, not the whole card list.
(defn- note-live-chip [ng n]
(let [live? @(rf/subscribe [::subs/note-active? ng])]
[:span.bound-note {:class (when live? "live")
:title "Jump to this passage in the script"
:on-click #(rf/dispatch [::events/jump-to-note ng])}
(when live? [:span.note-live "»»» "])
[:span.ann-swatch {:style {:background (:color n)}}]
[:span.bound-note-name (:name n)]]))
;; One annotation card. Its "active" highlight is a PER-CARD subscription, so the
;; playhead crossing bars re-renders only the card(s) whose active state flipped —
;; not the entire commentary list (the old code derefed the active SET in the
;; parent, forcing a full-list reconcile on every crossing).
(defn- annotation-card [a scene ctx segs nmap authed? open]
(let [active? @(rf/subscribe [::subs/annotation-active? (:id a)])]
(if (:hidden a)
(r/with-let [hov? (r/atom false)]
[:div.ann {:id (str "ann-" (name (:id a)))
:class (when active? "active")
:style (merge {"--ann-color" (:color a)}
{:opacity 0.4 :background "var(--desktop)" :background-size "2px 2px"
:min-height "24px" :display "flex" :align-items "center"
:justify-content "space-between" :padding "2px 6px"})
:on-mouse-enter #(reset! hov? true)
:on-mouse-leave #(reset! hov? false)}
[:span.ann-title {:style {:font-size 10}}
[:span.ann-swatch {:style {:background (:color a)}}]
(:name a)]
[:div {:style {:display "flex" :gap "2px" :visibility (if @hov? "visible" "hidden")}}
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} "⤢"]
[:button.edit-btn {:title "Edit" :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"]]])
[:div.ann {:id (str "ann-" (name (:id a)))
:class (when active? "active")
:style {"--ann-color" (:color a)}}
[:div.ann-head
[:div.ann-title
[:span.ann-swatch {:style {:background (:color a)}}]
(when (:broken a) [:span {:title (:reason a)} "△ "]) (:name a)]
[:div.ann-actions
[jump-control open a scene segs]
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
"⤢" (when (pos? (:nested a)) [:span.nest-badge (:nested a)])]
(when authed?
[:button.edit-btn {:title "Edit" :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"])
(when authed?
[:button.del-btn {:title "Delete"
:on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?"))
(rf/dispatch [::events/delete-annotation (:id a)]))} "✕"])]]
(when (not-empty (:content a)) [content-display scene ctx (:content a)])
(when (seq (:notes a))
[:div.ann-notes
(for [ng (:notes a) :let [n (nmap ng)] :when n]
^{:key (name ng)} [note-live-chip ng n])])])))
;; Renders nothing: owns the active-annotation subscription and scrolls the active
;; card into view, so `commentary` itself no longer re-renders on every crossing.
(defn- commentary-scroll [el]
(let [active @(rf/subscribe [::subs/active-annotation])]
(r/after-render
(fn [] (when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
nil))
(defn commentary []
(let [el (atom nil) open (r/atom nil)]
(fn []
(let [anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation])
active-set @(rf/subscribe [::subs/active-annotations])
authed? @(rf/subscribe [::subs/authed?])
scene @(rf/subscribe [::subs/scene])
ctx @(rf/subscribe [::subs/context])
segs @(rf/subscribe [::subs/segments])
nmap (into {} (map (juxt :id identity)) @(rf/subscribe [::subs/notes]))
live @(rf/subscribe [::subs/active-note-set])]
(r/after-render
(fn [] (when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
nmap (into {} (map (juxt :id identity)) @(rf/subscribe [::subs/notes]))]
[:div.commentary {:ref (fn [n] (reset! el n))}
[commentary-scroll el]
;; this context's own description (links resolve in its parent), with an
;; edit button — Edit drops into the parent timeline so marks are editable.
(let [cg (get-in scene [:groups ctx])]
@ -926,63 +986,7 @@
(if (seq anns)
(doall
(for [a anns]
(let [grp (get-in scene [:groups (:id a)])]
^{:key (:id a)}
(if (:hidden a)
;; Hidden annotation: small collapsed card with edit/expand on hover
(r/with-let [hov? (r/atom false)]
[:div.ann {:id (str "ann-" (name (:id a)))
:class (when (contains? active-set (:id a)) "active")
:style (merge {"--ann-color" (:color a)}
{:opacity 0.4
:background "var(--desktop)"
:background-size "2px 2px"
:min-height "24px"
:display "flex"
:align-items "center"
:justify-content "space-between"
:padding "2px 6px"})
:on-mouse-enter #(reset! hov? true)
:on-mouse-leave #(reset! hov? false)}
[:span.ann-title {:style {:font-size 10}}
[:span.ann-swatch {:style {:background (:color a)}}]
(:name a)]
[:div {:style {:display "flex" :gap "2px" :visibility (if @hov? "visible" "hidden")}}
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
"⤢"]
[:button.edit-btn {:title "Edit"
:on-click #(rf/dispatch [::events/edit-draft (:id a)])}
"✎"]]])
;; Normal annotation: full card
[:div.ann {:id (str "ann-" (name (:id a)))
:class (when (contains? active-set (:id a)) "active")
:style {"--ann-color" (:color a)}}
[:div.ann-head
[:div.ann-title
[:span.ann-swatch {:style {:background (:color a)}}]
(when (:broken a) [:span {:title (:reason a)} "△ "]) (:name a)]
[:div.ann-actions
[jump-control open a scene segs]
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
"⤢" (when (pos? (:nested a)) [:span.nest-badge (:nested a)])]
(when authed?
[:button.edit-btn {:title "Edit"
:on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"])
(when authed?
[:button.del-btn {:title "Delete"
:on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?"))
(rf/dispatch [::events/delete-annotation (:id a)]))} "✕"])]]
(when (not-empty (:content a)) [content-display scene ctx (:content a)])
(when (seq (:notes a))
[:div.ann-notes
(for [ng (:notes a) :let [n (nmap ng)] :when n]
^{:key (name ng)}
[:span.bound-note {:class (when (contains? live ng) "live")
:title "Jump to this passage in the script"
:on-click #(rf/dispatch [::events/jump-to-note ng])}
(when (contains? live ng) [:span.note-live "»»» "])
[:span.ann-swatch {:style {:background (:color n)}}]
[:span.bound-note-name (:name n)]])])]))))
^{:key (:id a)} [annotation-card a scene ctx segs nmap authed? open]))
[:div.ann-empty "No annotations here."])]))))
(defn- to-frame [v len]