diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 71f90e0..9730e26 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -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))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 92cbebc..bbbc3d2 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -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]