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); }
|
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-dur { color: var(--mute); font-size: 11px; white-space: nowrap; }
|
||||||
.pt-x { background: none; border: none; color: var(--ink); cursor: pointer; font-size: 14px; }
|
.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; }
|
.pt-x:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.form-hint { font-size: 11px; color: var(--mute); }
|
.form-hint { font-size: 11px; color: var(--mute); }
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,13 @@
|
||||||
(let [s (get-in db [:view :hidden-notes] #{})]
|
(let [s (get-in db [:view :hidden-notes] #{})]
|
||||||
(assoc-in db [:view :hidden-notes] (if (contains? s gid) (disj s gid) (conj s gid))))))
|
(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
|
;; click a highlight on the page → activate its note and focus that region's row
|
||||||
;; in the right pane
|
;; in the right pane
|
||||||
(rf/reg-event-db ::open-region
|
(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 ::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 ::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-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 ::region-focus (fn [db] (get-in db [:view :region-focus])))
|
||||||
(rf/reg-sub ::linking (fn [db] (get-in db [:view :linking])))
|
(rf/reg-sub ::linking (fn [db] (get-in db [:view :linking])))
|
||||||
(rf/reg-sub ::dragging-ann (fn [db] (get-in db [:view :dragging-ann])))
|
(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
|
;; child annotations of the current context, with their bars in local coords
|
||||||
(rf/reg-sub
|
(rf/reg-sub
|
||||||
::annotations
|
::annotations
|
||||||
:<- [::scene] :<- [::context] :<- [::segments]
|
:<- [::scene] :<- [::context] :<- [::segments] :<- [::hidden-tags]
|
||||||
(fn [[scene ctx segs] _]
|
(fn [[scene ctx segs hidden-tags] _]
|
||||||
(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)))]
|
||||||
(->> (: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)) (= 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)
|
(let [src-segs (scene/resolve scene gid)
|
||||||
bars (scene/merge-bars
|
bars (scene/merge-bars
|
||||||
(mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs))
|
(mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs))
|
||||||
|
|
@ -112,6 +118,7 @@
|
||||||
(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
|
||||||
:hidden (boolean hidden)
|
:hidden (boolean hidden)
|
||||||
|
: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
|
||||||
;; the editor) — not re-derived from a floored bar frame
|
;; the editor) — not re-derived from a floored bar frame
|
||||||
:jumps (scene/jump-targets scene ctx gid)
|
:jumps (scene/jump-targets scene ctx gid)
|
||||||
|
|
@ -119,6 +126,18 @@
|
||||||
(sort-by (juxt :broken :start)) ; broken annotations sink to the bottom
|
(sort-by (juxt :broken :start)) ; broken annotations sink to the bottom
|
||||||
vec))))
|
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) —
|
;; the annotation the playhead is currently inside (or the latest one passed) —
|
||||||
;; drives the rolling highlight/scroll in the commentary
|
;; drives the rolling highlight/scroll in the commentary
|
||||||
(rf/reg-sub
|
(rf/reg-sub
|
||||||
|
|
|
||||||
|
|
@ -850,6 +850,57 @@
|
||||||
{:seg m :f (js/Math.round (- local c))}))
|
{:seg m :f (js/Math.round (- local c))}))
|
||||||
segs))
|
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]}]
|
(defn- link-picker [{:keys [scene ctx on-commit on-cancel]}]
|
||||||
(r/with-let [picked (r/atom nil)]
|
(r/with-let [picked (r/atom nil)]
|
||||||
[:div.link-insert
|
[:div.link-insert
|
||||||
|
|
@ -977,7 +1028,10 @@
|
||||||
(when (seq (:notes a))
|
(when (seq (:notes a))
|
||||||
[:div.ann-notes
|
[:div.ann-notes
|
||||||
(for [ng (:notes a) :let [n (nmap ng)] :when n]
|
(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
|
;; 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.
|
||||||
|
|
@ -1130,7 +1184,16 @@
|
||||||
[:label.form-check {:title "Hide this annotation from the timeline lane (still usable in links)"}
|
[: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]))
|
[:input {:type "checkbox" :checked (boolean (get-in d [:meta :hidden]))
|
||||||
:on-change #(put (assoc-in d [:meta :hidden] (.. % -target -checked)))}]
|
: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"]
|
[:div.form-marks-label "Content"]
|
||||||
^{:key gid} [content-editor gid (:content orig)]
|
^{:key gid} [content-editor gid (:content orig)]
|
||||||
(if (and linking (= gid (:gid linking)))
|
(if (and linking (= gid (:gid linking)))
|
||||||
|
|
@ -1250,6 +1313,31 @@
|
||||||
(for [[i c] (map-indexed vector crumbs)]
|
(for [[i c] (map-indexed vector crumbs)]
|
||||||
^{:key (:id c)} [crumb i c (= i (dec (count 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 []
|
(defn toolbar []
|
||||||
;; NB: subscribe the coarse ::at-start?/::at-end? edges, not the raw playhead —
|
;; 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.
|
;; else the whole toolbar (sliders included) re-renders every frame in playback.
|
||||||
|
|
@ -1279,6 +1367,7 @@
|
||||||
[:label.slider {:title "Vertical zoom"} "↕"
|
[:label.slider {:title "Vertical zoom"} "↕"
|
||||||
[:input {:type "range" :min 8 :max 120 :value row-h
|
[:input {:type "range" :min 8 :max 120 :value row-h
|
||||||
:on-change #(rf/dispatch [::events/set-row-h (js/parseFloat (.. % -target -value))])}]]
|
:on-change #(rf/dispatch [::events/set-row-h (js/parseFloat (.. % -target -value))])}]]
|
||||||
|
[tag-filter]
|
||||||
[dark-toggle]]))
|
[dark-toggle]]))
|
||||||
|
|
||||||
(defn- drag-top!
|
(defn- drag-top!
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue