feat: annotation tags + timeline tag filter
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 <noreply@anthropic.com>
This commit is contained in:
parent
ab16763f8d
commit
2508066381
4 changed files with 168 additions and 5 deletions
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue