From 2508066381cf3330c4687974f9c2afcdcc5ec664 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Jul 2026 11:27:44 -0400 Subject: [PATCH] feat: annotation tags + timeline tag filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags live in annotation :meta :tags (plain strings). The edit form gains a Tags section: cute price-tag chips + a shared string autocomplete that suggests any tag used elsewhere in the project (allow-new). Tags render small at the card's bottom-left. The timeline controls gain a "▽ Tags" filter opening a dropdown of every project tag (plus an "Untagged" entry mapping to a :untagged sentinel), each with an eye to show/hide. Hidden tags drop matching annotations from both the commentary list and the timeline lane via a new ::hidden-tags view state. Both the adder and the filter share one `autocomplete` component (just different callbacks). Co-Authored-By: Claude Opus 4.8 --- tl/resources/public/css/app.css | 48 +++++++++++++++++ tl/src/tl/events.cljs | 7 +++ tl/src/tl/subs.cljs | 25 +++++++-- tl/src/tl/views.cljs | 93 ++++++++++++++++++++++++++++++++- 4 files changed, 168 insertions(+), 5 deletions(-) diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index 4c55d67..71eac38 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -287,6 +287,54 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; border-radius: 0; padding: 2px 4px; font-size: 12px; font-family: var(--mono); } .pt-dur { color: var(--mute); font-size: 11px; white-space: nowrap; } .pt-x { background: none; border: none; color: var(--ink); cursor: pointer; font-size: 14px; } + +/* --- tags: cute price-tag chips ----------------------------------------- */ +/* rounded-right, notched-left rect with a punched hole (the ::before dot). No #. */ +.tag { + display: inline-flex; align-items: center; gap: 3px; + padding: 1px 8px 1px 15px; position: relative; + font-family: var(--geneva); font-size: 11px; line-height: 1.5; + color: var(--ink); background: var(--paper); + border: 1px solid var(--ink); border-radius: 3px 10px 10px 3px; + white-space: nowrap; +} +.tag::before { /* the tag hole */ + content: ""; position: absolute; left: 6px; top: 50%; transform: translateY(-50%); + width: 4px; height: 4px; border-radius: 50%; + border: 1px solid var(--ink); background: var(--paper); +} +.tag-x { background: none; border: none; color: var(--mute); cursor: pointer; + font-size: 10px; padding: 0; line-height: 1; } +.tag-x:hover { color: var(--ink); } + +.tag-editor { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.tag-editor .tag-list { display: flex; flex-wrap: wrap; gap: 4px; } +.tag-editor .ac { flex: 1 1 120px; max-width: 200px; } + +/* on a card: bottom-left, extra small */ +.ann-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } +.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; } + +/* --- timeline tag filter ------------------------------------------------ */ +.tag-filter { position: relative; display: inline-flex; } +.filter-btn { + font-family: var(--chicago); background: var(--paper); color: var(--ink); + border: 1px solid var(--ink); border-radius: 8px; cursor: pointer; + font-size: 11px; padding: 3px 8px; white-space: nowrap; +} +.filter-btn:hover { background: var(--hover); } +.filter-btn.active { background: var(--ink); color: var(--paper); } +.filter-pop { + position: absolute; top: 100%; right: 0; z-index: 31; margin-top: 4px; width: 200px; + background: var(--paper); border: 1px solid var(--ink); box-shadow: 2px 2px 0 var(--ink); padding: 6px; +} +/* stack input then list vertically (.pt-input is a flex row by default, which + would otherwise put the static dropdown to the RIGHT of the input) */ +.filter-pop .pt-input { flex-direction: column; align-items: stretch; } +.filter-pop .pt-dropdown { position: static; box-shadow: none; border: none; margin-top: 4px; max-height: 180px; } +.ac .pt-dropdown li { display: flex; align-items: center; justify-content: space-between; gap: 6px; } +.tag-eye { font-size: 12px; flex: none; } .pt-x:hover { text-decoration: underline; } .form-hint { font-size: 11px; color: var(--mute); } diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index b52e238..01e2151 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -332,6 +332,13 @@ (let [s (get-in db [:view :hidden-notes] #{})] (assoc-in db [:view :hidden-notes] (if (contains? s gid) (disj s gid) (conj s gid)))))) +;; per-tag annotation visibility (view-only, ephemeral): eye toggles in the tag +;; filter. A tag in :hidden-tags hides every annotation carrying it. +(rf/reg-event-db ::toggle-tag-filter + (fn [db [_ tag]] + (let [s (get-in db [:view :hidden-tags] #{})] + (assoc-in db [:view :hidden-tags] (if (contains? s tag) (disj s tag) (conj s tag)))))) + ;; click a highlight on the page → activate its note and focus that region's row ;; in the right pane (rf/reg-event-db ::open-region diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 8903fd2..315c71b 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -18,6 +18,7 @@ (rf/reg-sub ::active-note (fn [db] (get-in db [:view :active-note]))) (rf/reg-sub ::script-jump (fn [db] (get-in db [:view :script-jump]))) (rf/reg-sub ::hidden-notes (fn [db] (get-in db [:view :hidden-notes] #{}))) +(rf/reg-sub ::hidden-tags (fn [db] (get-in db [:view :hidden-tags] #{}))) (rf/reg-sub ::region-focus (fn [db] (get-in db [:view :region-focus]))) (rf/reg-sub ::linking (fn [db] (get-in db [:view :linking]))) (rf/reg-sub ::dragging-ann (fn [db] (get-in db [:view :dragging-ann]))) @@ -89,13 +90,18 @@ ;; child annotations of the current context, with their bars in local coords (rf/reg-sub ::annotations - :<- [::scene] :<- [::context] :<- [::segments] - (fn [[scene ctx segs] _] + :<- [::scene] :<- [::context] :<- [::segments] :<- [::hidden-tags] + (fn [[scene ctx segs hidden-tags] _] (let [nested (frequencies (keep (fn [[_ g]] (when (= :annotation (:type g)) (:parent g))) (:groups scene)))] (->> (:groups scene) (keep (fn [[gid g]] - (when (and (= :annotation (:type g)) (= ctx (:parent g))) + (when (and (= :annotation (:type g)) (= ctx (:parent g)) + ;; a hidden tag hides every annotation carrying it; the + ;; :untagged sentinel hides annotations with no tags + (let [tags (get-in g [:meta :tags])] + (not (or (some hidden-tags tags) + (and (empty? tags) (contains? hidden-tags :untagged)))))) (let [src-segs (scene/resolve scene gid) bars (scene/merge-bars (mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs)) @@ -112,6 +118,7 @@ (filterv #(= :script-note (get-in scene [:groups % :type])))) :broken (boolean reason) :reason reason :hidden (boolean hidden) + :tags (vec (get-in g [:meta :tags])) ;; jump targets labelled from the marks' clip refs (same as ;; the editor) — not re-derived from a floored bar frame :jumps (scene/jump-targets scene ctx gid) @@ -119,6 +126,18 @@ (sort-by (juxt :broken :start)) ; broken annotations sink to the bottom vec)))) +;; every distinct tag used by any annotation in the project — feeds both the tag +;; adder's autocomplete and the timeline tag filter. +(rf/reg-sub + ::project-tags + :<- [::scene] + (fn [scene _] + (->> (:groups scene) + (mapcat (fn [[_ g]] (when (= :annotation (:type g)) (get-in g [:meta :tags])))) + distinct + (sort-by str/lower-case) + vec))) + ;; the annotation the playhead is currently inside (or the latest one passed) — ;; drives the rolling highlight/scroll in the commentary (rf/reg-sub diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 93d3369..05832e9 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -850,6 +850,57 @@ {:seg m :f (js/Math.round (- local c))})) segs)) +;; --- shared string autocomplete ------------------------------------------ +;; A plain text input with a filtered dropdown of `items` (strings). Arrow keys / +;; mouse to highlight, Enter or click to choose. `allow-new?` offers the typed +;; text itself as a candidate. `item-suffix` renders trailing content per row (the +;; eye in the tag filter). The tag adder and the tag filter are just two callbacks +;; on this one component. Reuses the point-picker's .pt-* dropdown styling. +(defn- autocomplete + [{:keys [items placeholder allow-new? on-choose item-suffix auto-focus? clear-on-choose?] + :or {clear-on-choose? true}}] + (r/with-let [text (r/atom "") hi (r/atom 0) open? (r/atom false)] + (let [q (str/lower-case (str/trim @text)) + matches (filterv #(str/includes? (str/lower-case %) q) items) + new? (and allow-new? (seq q) (not (some #(= q (str/lower-case %)) items))) + cands (cond-> matches new? (conj (str/trim @text))) + i (min @hi (max 0 (dec (count cands)))) + choose (fn [s] (when (seq (str/trim s)) + (on-choose (str/trim s)) + (when clear-on-choose? (reset! text "")) + (reset! hi 0)))] + [:div.pt-input.ac + [:input.pt-text + {:auto-focus auto-focus? :placeholder (or placeholder "type…") :value @text + :on-focus #(reset! open? true) + :on-blur #(js/setTimeout (fn [] (reset! open? false)) 120) + :on-change #(do (reset! text (.. % -target -value)) (reset! hi 0) (reset! open? true)) + :on-key-down + (fn [e] + (case (.-key e) + "ArrowDown" (do (.preventDefault e) (reset! open? true) (reset! hi (mod (inc i) (max 1 (count cands))))) + "ArrowUp" (do (.preventDefault e) (reset! open? true) (reset! hi (mod (dec i) (max 1 (count cands))))) + "Enter" (do (.preventDefault e) (choose (or (get cands i) @text))) + "Escape" (do (.preventDefault e) (reset! open? false)) + nil))}] + (when (and @open? (seq cands)) + (into [:ul.pt-dropdown] + (for [[j c] (map-indexed vector cands)] + ^{:key c} + [:li {:class (when (= j i) "hi") + :on-mouse-down #(.preventDefault %) + :on-mouse-enter #(reset! hi j) + :on-click #(choose c)} + [:span.cand-label c] + (when (and new? (= c (str/trim @text))) [:span.cand-group " (new)"]) + (when item-suffix (item-suffix c))])))]))) + +;; a cute price-tag-shaped chip (the ::before dot is the tag hole). `on-remove` +;; adds a ✕ (edit form); omit it for read-only display. +(defn- tag-chip [t & [on-remove]] + [:span.tag t (when on-remove [:button.tag-x {:type "button" :title "Remove tag" + :on-click #(on-remove t)} "✕"])]) + (defn- link-picker [{:keys [scene ctx on-commit on-cancel]}] (r/with-let [picked (r/atom nil)] [:div.link-insert @@ -977,7 +1028,10 @@ (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])])])))) + ^{:key (name ng)} [note-live-chip ng n])]) + (when (seq (:tags a)) + (into [:div.ann-tags] + (for [t (:tags a)] ^{:key t} [tag-chip t])))])))) ;; Renders nothing: owns the active-annotation subscription and scrolls the active ;; card into view, so `commentary` itself no longer re-renders on every crossing. @@ -1130,7 +1184,16 @@ [:label.form-check {:title "Hide this annotation from the timeline lane (still usable in links)"} [:input {:type "checkbox" :checked (boolean (get-in d [:meta :hidden])) :on-change #(put (assoc-in d [:meta :hidden] (.. % -target -checked)))}] - [:span "Hide from timeline"]]]) + [:span "Hide from timeline"]] + [:div.form-marks-label "Tags"] + (let [tags (vec (get-in d [:meta :tags]))] + [:div.tag-editor + (into [:div.tag-list] + (for [t tags] + ^{:key t} [tag-chip t #(put (assoc-in d [:meta :tags] (vec (remove #{%} tags))))])) + [autocomplete {:items (filterv (complement (set tags)) @(rf/subscribe [::subs/project-tags])) + :placeholder "add tag…" :allow-new? true + :on-choose #(put (assoc-in d [:meta :tags] (conj tags %)))}]])]) [:div.form-marks-label "Content"] ^{:key gid} [content-editor gid (:content orig)] (if (and linking (= gid (:gid linking))) @@ -1250,6 +1313,31 @@ (for [[i c] (map-indexed vector crumbs)] ^{:key (:id c)} [crumb i c (= i (dec (count crumbs)))])]))) +;; Timeline tag filter: a button opening a dropdown that lists the project's tags +;; (plus a fake "Untagged" entry) with an eye per row. Click a row (or Enter) to +;; show/hide every annotation carrying that tag. Same autocomplete as the adder. +;; "Untagged" is just another item that maps to the :untagged sentinel. +(def ^:private untagged-label "Untagged") + +(defn- tag-filter [] + (r/with-let [open? (r/atom false)] + (let [tags @(rf/subscribe [::subs/project-tags]) + hidden @(rf/subscribe [::subs/hidden-tags]) + key-of #(if (= % untagged-label) :untagged %)] + (when (seq tags) + [:span.tag-filter + [:button.filter-btn {:class (when (seq hidden) "active") :title "Show / hide annotations by tag" + :on-click #(swap! open? not)} + "▽ Tags" (when (seq hidden) (str " (" (count hidden) ")"))] + (when @open? + [:<> + [:div.menu-backdrop {:on-click #(reset! open? false)}] + [:div.filter-pop + [autocomplete {:items (into [untagged-label] tags) + :placeholder "filter tags…" :clear-on-choose? false :auto-focus? true + :on-choose #(rf/dispatch [::events/toggle-tag-filter (key-of %)]) + :item-suffix (fn [t] [:span.tag-eye (if (contains? hidden (key-of t)) "🙈" "👁")])}]]])])))) + (defn toolbar [] ;; NB: subscribe the coarse ::at-start?/::at-end? edges, not the raw playhead — ;; else the whole toolbar (sliders included) re-renders every frame in playback. @@ -1279,6 +1367,7 @@ [:label.slider {:title "Vertical zoom"} "↕" [:input {:type "range" :min 8 :max 120 :value row-h :on-change #(rf/dispatch [::events/set-row-h (js/parseFloat (.. % -target -value))])}]] + [tag-filter] [dark-toggle]])) (defn- drag-top!