feat: reuse marks picker for annotation links

This commit is contained in:
Your Name 2026-06-29 12:14:08 -04:00
parent d3c8dcbdf2
commit 5b206635fb
6 changed files with 498 additions and 30 deletions

View file

@ -136,7 +136,18 @@ body { overflow: hidden; }
.form-marks-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
color: #7aa6d6; margin-top: 4px; }
.mark-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
/* contenteditable content surface + inline link chips */
.content-editor { white-space: pre-wrap; word-break: break-word; outline: none; cursor: text; }
.content-editor:empty:before { content: attr(data-placeholder); color: #666; }
.link-chip { display: inline-flex; align-items: center; gap: 3px; vertical-align: baseline;
background: #173049; color: #9fd0ff; border: 1px solid #2f5d86; border-radius: 4px;
padding: 0 5px; margin: 0 1px; font-size: 12px; cursor: pointer; user-select: none; }
.link-chip:hover { background: #1d3e60; }
.link-chip.broken { background: #3a1d1d; color: #e69; border-color: #a44; }
.link-chip .link-f { color: #6f9; opacity: .7; font-size: 11px; }
.md .link-chip .link-f { color: #6f9; }
.mark-row { display: flex; flex-wrap: nowrap; align-items: center; gap: 6px; min-width: 0; }
.mark-arrow { color: #666; }
.row-x, .add-mark {
background: #1c1c1c; color: #aaa; border: 1px solid #333; border-radius: 4px;
@ -151,16 +162,18 @@ body { overflow: hidden; }
}
/* point editor + autocomplete */
.pt-input { position: relative; flex: 1 1 130px; display: inline-block; }
.pt-input { position: relative; flex: 1 1 130px; display: flex; align-items: center; min-width: 0; }
.mark-row .pt-input { flex: 0 1 160px; max-width: 180px; }
.pt-text {
width: 100%; box-sizing: border-box;
flex: 1; min-width: 0; box-sizing: border-box;
background: #0e0e0e; color: #ddd; border: 1px solid #333;
border-radius: 4px; padding: 6px 8px; font-size: 13px;
}
.pt-frame-row { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; }
.pt-dropdown {
position: absolute; left: 0; right: 0; top: 100%; z-index: 5;
margin: 2px 0 0; padding: 4px; list-style: none;
max-height: 220px; overflow-y: auto;
max-height: 124px; overflow-y: auto;
background: #1b1b1b; border: 1px solid #3a6ea5; border-radius: 4px;
box-shadow: 0 6px 18px rgba(0,0,0,0.5);
}
@ -168,8 +181,13 @@ body { overflow: hidden; }
color: #cde; cursor: pointer; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.pt-dropdown li.hi { background: #2a4a6a; }
.pt-dropdown .cand-group { color: #789; font-size: 11px; }
.pt-dropdown .link-f { color: #6f9; opacity: .7; }
.pt-chip { flex: 1; display: flex; align-items: center; gap: 4px;
.link-insert { display: flex; align-items: flex-start; gap: 6px; align-self: stretch; }
.link-insert .pt-input { flex: 1 1 auto; max-width: none; }
.pt-chip { flex: 1 1 0; min-width: 0; display: flex; align-items: center; gap: 4px;
background: #233; border: 1px solid #3a6ea5; border-radius: 4px;
padding: 2px 4px; }
.pt-chip-name { font-size: 12px; color: #cfe3f5; white-space: nowrap;

View file

@ -135,6 +135,29 @@
(fn [db [_ gid page rect]]
(update-in db [:scene :groups gid :script] (fnil conj []) {:page page :rect rect})))
;; content edits go through their own event (not put-group) so the contenteditable
;; surface can update :content without re-reading a possibly-stale draft map.
(rf/reg-event-db ::set-content
(fn [db [_ gid content]] (assoc-in db [:scene :groups gid :content] content)))
;; Linking is an edit-mode sub-task, like script highlighting: "Insert link"
;; remembers the playhead and arms the mode; while armed, clicking the timeline
;; or picking from the autocomplete inserts a link chip at the caret. Esc/Cancel
;; restores the playhead (the arrow-key previews were just seeks).
(rf/reg-event-db ::start-linking
(fn [db [_ gid]]
(let [ctx (peek (get-in db [:view :stack]))]
(assoc-in db [:view :linking]
{:gid gid :ctx ctx :playhead (scene/playhead (:view db) ctx)}))))
(rf/reg-event-db ::stop-linking (fn [db _] (assoc-in db [:view :linking] nil)))
(rf/reg-event-fx ::cancel-linking
(fn [{:keys [db]} _]
(let [{:keys [ctx playhead]} (get-in db [:view :linking])
sf (scene/local->source (scene/content-segments (:scene db) ctx) playhead)]
{:db (-> db (assoc-in [:view :linking] nil)
(assoc-in [:view :playheads ctx] playhead))
:player/seek (when sf (/ sf (:fps db)))})))
(rf/reg-event-db ::set-playhead
(fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf)))
(rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p)))

View file

@ -305,3 +305,100 @@
:marks [{:id :root-m :start 0 :end duration}]})}))
(defn clip-name [scene gid] (:name (grp scene gid)))
;; --- links ----------------------------------------------------------------
;; A link is a ref-point {:ref id :at n} — the same shape as a mark endpoint, so
;; it resolves through the usual machinery — named inside an annotation's markdown
;; content as `[label](mark:ref@at)`. The id is just a mark id in the flat pool:
;; a clip group (clip-scoped time), the context itself (absolute time), or another
;; annotation's mark (a moment in that annotation). We only need the ctx-local
;; frame (to seek / label it) and the pickable targets within a context.
(defn link-local
"Local frame within `ctx` for link ref-point `point`, or nil if it no longer
resolves into ctx. An absolute link (:ref = ctx) IS the local frame."
[scene ctx {:keys [ref at] :as point}]
(if (= ref ctx)
at
(when-let [{:keys [frame]} (point-frame scene ctx point)]
(source->local (content-segments scene ctx) frame))))
(defn seg-point
"Ref-point {:ref :at} for mark-time frame `f` within content-segment `seg`
(the convention selection->marks uses, so it resolves identically)."
[scene {:keys [mark src]} f]
{:ref mark :at (+ (- (first src) (:xs (target-range scene mark))) (or f 0))})
(defn- runs
"Contiguous runs of `gid`'s marks in `ctx`-local coords, each {:id :lo :len}
where :id is the run's first mark (a discontinuous annotation lists each
piece) and :len is that first mark's frame count — the offset you can pick
into it, since the link's ref only spans that one mark."
[scene ctx gid]
(let [csegs (content-segments scene ctx)
pts (->> (:marks (grp scene gid))
(keep (fn [m] (when-let [seg (first (resolve-mark scene gid m))]
(let [[s e] (:src seg)]
{:id (:id m) :lo (source->local csegs s)
:hi (source->local csegs (dec e))}))))
(filter :lo)
(sort-by :lo))]
(reduce (fn [out {:keys [id lo hi]}]
(if-let [p (peek out)]
(if (and (:hi p) hi (<= (- lo (:hi p)) 1))
(conj (pop out) (assoc p :hi hi)) ; extend run (display only)
(conj out {:id id :lo lo :hi hi :len (inc (- hi lo))}))
[{:id id :lo lo :hi hi :len (inc (- hi lo))}]))
[] pts)))
(defn linkables
"Pickable link targets within `ctx`, grouped for the autocomplete: one group
per video track (its clips, by start) and per child annotation (its run
starts). Each candidate is {:label :point {:ref :at} :local}."
[scene ctx]
(let [tracks (->> (content-segments scene ctx)
(group-by :track)
(mapv (fn [[t segs]]
(let [track-name (get-in scene [:tracks t :name] (name t))]
{:name track-name :kind :track
:items (mapv (fn [i {:keys [mark] [c d] :local :as seg}]
{:label (str track-name " (" (inc i) ")")
:search (clip-name scene mark)
:point (seg-point scene seg 0) :local c :len (- d c)})
(range)
(sort-by (comp first :local) segs))}))))
anns (->> (:groups scene)
(keep (fn [[gid g]]
(when (and (= :annotation (:type g)) (= ctx (:parent g))
(not (:draft g)))
{:name (or (:name g) (name gid)) :kind :annotation
:items (mapv (fn [{:keys [id lo len]}]
{:label (or (:name g) (name gid))
:point {:ref id :at 0} :local lo :len len})
(runs scene ctx gid))}))))]
(vec (concat (sort-by :name tracks) (sort-by :name anns)))))
(def ^:private link-re "\\[([^\\]]*)\\]\\(mark:([^@)]+)@(-?\\d+)\\)")
(defn link-token
"The markdown token for a link {:label :ref :at}."
[{:keys [label ref at]}]
(str "[" label "](mark:" (name ref) "@" at ")"))
(defn parse-content
"Split markdown `s` into a vector of [:text str] / [:link {:label :ref :at}]."
[s]
(if (empty? s)
[]
(let [re (js/RegExp. link-re "g")]
(loop [out [] last 0]
(if-let [m (.exec re s)]
(let [idx (.-index m) pre (subs s last idx)]
(recur (cond-> out
(seq pre) (conj [:text pre])
:always (conj [:link {:label (aget m 1)
:ref (keyword (aget m 2))
:at (js/parseInt (aget m 3) 10)}]))
(+ idx (.-length (aget m 0)))))
(let [tail (subs s last)]
(cond-> out (seq tail) (conj [:text tail]))))))))

View file

@ -14,6 +14,7 @@
(rf/reg-sub ::script-url (fn [db] (get-in db [:project :script])))
(rf/reg-sub ::pane (fn [db] (get-in db [:view :pane] :annotations)))
(rf/reg-sub ::highlighting (fn [db] (get-in db [:view :highlighting])))
(rf/reg-sub ::linking (fn [db] (get-in db [:view :linking])))
(rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails])))
(rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status])))
(rf/reg-sub ::create-error (fn [db] (:create-error db)))

View file

@ -11,6 +11,8 @@
(defonce raf (atom nil))
(defonce scroll-el (atom nil))
(defonce play (atom nil)) ; {:ctx :segs :fps :idx} while playing, else nil
(defonce active-insert! (atom nil)) ; the live content-editor's (insert! link) fn
(declare commit-link!)
(def ^:private seek-guard-frames 0.75)
(def ^:private seek-settle-frames 2)
@ -144,11 +146,16 @@
:ref (fn [n] (when n (reset! video-el n)))}])
(defn- clip-label
"“<video track> <clip name>” for the content segment whose :mark is `mid`."
"Track-local clip label for the content segment whose :mark is `mid`."
[scene segs mid]
(let [tname (some (fn [{m :mark t :track}] (when (= m mid) (get-in scene [:tracks t :name]))) segs)
cname (or (scene/clip-name scene mid) "clip")]
(if tname (str tname " " cname) cname)))
(or (some (fn [[t tsegs]]
(some (fn [[i {m :mark}]]
(when (= m mid)
(str (get-in scene [:tracks t :name] (name t))
" (" (inc i) ")")))
(map-indexed vector (sort-by (comp first :local) tsegs))))
(group-by :track segs))
"clip"))
(defn frame-readout
"Bottom-right of the video: the timeline (context-absolute) frame, and the
@ -158,16 +165,22 @@
(let [ph @(rf/subscribe [::subs/playhead])
segs @(rf/subscribe [::subs/segments])
scene @(rf/subscribe [::subs/scene])
linking @(rf/subscribe [::subs/linking])
auth? (some? @(rf/subscribe [::subs/draft-group]))
seg (some (fn [{[c d] :local :as s}] (when (and (<= c ph) (< ph d)) s)) segs)
cf (when seg (js/Math.round (- ph (first (:local seg)))))]
cf (when seg (js/Math.round (- ph (first (:local seg)))))
click? (and auth? seg)]
[:div.frame-readout
[:span.fr-abs (str (js/Math.round ph) "f")]
(when seg
[:span.fr-clip {:class (when auth? "clickable")
:title (when auth? "Click to set endpoint at this frame")
:on-click (when auth?
#(rf/dispatch [::events/draft-click-seg (:mark seg) cf]))}
[:span.fr-clip {:class (when click? "clickable")
:title (when click? (if linking "Click to link this frame"
"Click to set endpoint at this frame"))
:on-click (when click?
#(if linking
(commit-link! (scene/seg-point scene seg cf)
(str (clip-label scene segs (:mark seg)) " @" cf "f"))
(rf/dispatch [::events/draft-click-seg (:mark seg) cf])))}
(str (clip-label scene segs (:mark seg)) " " cf "f")])]))
;; --- timeline -------------------------------------------------------------
@ -203,6 +216,7 @@
playhead @(rf/subscribe [::subs/playhead])
playing? @(rf/subscribe [::subs/playing?])
authoring? (some? @(rf/subscribe [::subs/draft-group]))
linking @(rf/subscribe [::subs/linking])
len @(rf/subscribe [::subs/length])
width (px len fps zoom)
lane-h (* 18 (count anns))
@ -245,12 +259,254 @@
[:div.clip {:class (when authoring? "clickable")
:on-mouse-down (when authoring?
(fn [e] (.stopPropagation e) (.preventDefault e)
(rf/dispatch [::events/draft-click-seg (:mark seg)])))
(if linking
(commit-link! (scene/seg-point scene seg 0)
(clip-label scene segs (:mark seg)))
(rf/dispatch [::events/draft-click-seg (:mark seg)]))))
:style {:left (px c fps zoom) :width (max 1 (px (- d c) fps zoom))
:top (+ lane-h (* (get track-y track 0) row-h)) :height (- row-h 2)
:line-height (str (- row-h 2) "px") :position "absolute"}}
[clip-thumbnail-layer thumbs fps zoom row-h seg]
[:div.clip-name (scene/clip-name scene mark)]]))]]]))))
[:div.clip-name (clip-label scene segs mark)]]))]]]))))
;; --- links: inline markdown chips that seek the timeline ------------------
;; A link is a ref-point named in the content as `[label](mark:ref@at)` (see
;; tl.scene). The editor is a contenteditable surface where links live as atomic
;; chips (delete with backspace, click to seek); everything else is plain text.
(defn- link-frame
"ctx-local frame for a chip's {:ref :at}, resolved against the live scene."
[ref at]
(scene/link-local @(rf/subscribe [::subs/scene]) @(rf/subscribe [::subs/context])
{:ref (keyword ref) :at (js/parseInt at 10)}))
(defn content-display
"Read-only render of markdown `content`: text plus clickable link chips."
[scene ctx content]
(into [:div.md]
(map (fn [[k v]]
(if (= k :text)
v
(let [lf (scene/link-local scene ctx (select-keys v [:ref :at]))]
[:span.link-chip {:class (when-not lf "broken")
:title (when-not lf "linked clip no longer in this timeline")
:on-click #(when lf (goto! lf))}
(when-not lf "⚠ ") (:label v)
(when lf [:span.link-f (str " " (js/Math.round lf) "f")])])))
(scene/parse-content content))))
(defn- build-chip [{:keys [label ref at]}]
(let [span (js/document.createElement "span")
lf (scene/link-local @(rf/subscribe [::subs/scene]) @(rf/subscribe [::subs/context])
{:ref ref :at at})]
(set! (.-className span) (if lf "link-chip" "link-chip broken"))
(set! (.-contentEditable span) "false")
(when-not lf (set! (.-title span) "linked clip no longer in this timeline"))
(set! (.-textContent span) (str (when-not lf "⚠ ") label))
(aset (.-dataset span) "label" label)
(aset (.-dataset span) "ref" (name ref))
(aset (.-dataset span) "at" (str at))
(when lf
(let [f (js/document.createElement "span")]
(set! (.-className f) "link-f")
(set! (.-textContent f) (str " " (js/Math.round lf) "f"))
(.appendChild span f)))
span))
(defn- populate! [^js el content]
(set! (.-innerHTML el) "")
(doseq [[k v] (scene/parse-content content)]
(.appendChild el (if (= k :text)
(.createTextNode js/document v)
(build-chip v)))))
(defn- serialize-editor [^js el]
(apply str
(map (fn [^js n]
(cond
(= 3 (.-nodeType n)) (.-textContent n)
(and (.-classList n) (.contains (.-classList n) "link-chip"))
(scene/link-token {:label (.. n -dataset -label)
:ref (keyword (.. n -dataset -ref))
:at (js/parseInt (.. n -dataset -at) 10)})
(= "BR" (.-tagName n)) "\n"
:else (str "\n" (.-textContent n))))
(array-seq (.-childNodes el)))))
(defn content-editor
"Contenteditable markdown surface for the draft's :content. Uncontrolled: we
populate it once from `initial`, then serialize back out on input. Exposes its
chip-insert fn via active-insert! so the link picker / timeline can drop a
chip at the caret."
[gid initial]
(r/with-let [el (atom nil) saved (atom nil)
emit #(rf/dispatch [::events/set-content gid (serialize-editor @el)])
save! (fn [] (let [s (.getSelection js/window)]
(when (and @el (= @el (.-activeElement js/document))
(pos? (.-rangeCount s)) (.contains @el (.-anchorNode s)))
(reset! saved (.cloneRange (.getRangeAt s 0))))))
insert! (fn [link]
(let [r (or @saved (doto (.createRange js/document)
(.selectNodeContents @el) (.collapse false)))
chip (build-chip link)
sp (.createTextNode js/document " ")]
(.deleteContents r)
(.insertNode r chip)
(.. chip -parentNode (insertBefore sp (.-nextSibling chip)))
(doto r (.setStartAfter sp) (.collapse true))
(reset! saved (.cloneRange r))
(.focus @el)
(emit)))]
[:div.content-editor.form-content
{:content-editable true :suppress-content-editable-warning true
:data-placeholder "Content (optional)"
:ref (fn [n] (when (and n (not= n @el))
(reset! el n) (populate! n initial) (reset! active-insert! insert!)))
:on-input emit :on-key-up save! :on-mouse-up save! :on-blur save!
:on-click (fn [e] (when-let [chip (.closest (.-target e) ".link-chip")]
(when-let [lf (link-frame (.. chip -dataset -ref) (.. chip -dataset -at))]
(goto! lf))))}]))
(defn- point-candidates [scene ctx q]
(let [needle (str/lower-case q)
n (when (re-matches #"-?\d+" q) (js/parseInt q 10))]
(vec
(concat
(when n [{:label (str n "f") :point {:ref ctx :at n} :local n :abs true}])
(->> (scene/linkables scene ctx)
(mapcat (fn [g]
(keep (fn [item]
(let [hay (str/lower-case (str (:name g) " " (:label item) " "
(:search item)))]
(when (str/includes? hay needle)
(assoc item :group (:name g)))))
(:items g)))))))))
(defn- point-label [cand off local]
(str (:label cand) " @" (js/Math.round local) "f"
(when (and (not (:abs cand)) (pos? off))
(str " +" off))))
(defn- pick-point [cand off]
(let [o (if (:abs cand) 0 (min (max 0 off) (max 0 (dec (or (:len cand) 1)))))
local (+ (:local cand) o)]
(assoc (cond-> (:point cand) (not (:abs cand)) (update :at + o))
:label (point-label cand o local)
:local local
:candidate cand
:offset o)))
(defn point-picker
"Shared clip / annotation / frame autocomplete. `on-pick` receives the link
point plus :label and resolved ctx-local :local. Callers decide whether that
pick advances mark entry or just stages a link."
[{:keys [scene ctx value on-pick on-cancel placeholder auto-focus? class]}]
(r/with-let [text (r/atom (or (:label value) ""))
idx (r/atom 0)
off (r/atom (or (:offset value) 0))
picked (r/atom value)
open? (r/atom true)]
(let [cands (point-candidates scene ctx @text)
i (min @idx (max 0 (dec (count cands))))
cur (or (:candidate @picked) (get cands i))
maxo (max 0 (dec (or (:len cur) 1)))
o (min (max 0 @off) maxo)
frame? (and cur (not (:abs cur)) (pos? maxo))
emit! (fn [cand o2]
(when cand
(let [p (pick-point cand o2)]
(reset! picked p)
(reset! text (:label p))
(reset! off (:offset p))
(reset! open? false)
(goto! (:local p))
(on-pick p))))
preview! (fn [cand o2]
(when cand
(goto! (:local (pick-point cand o2)))))
go! (fn [j]
(reset! picked nil)
(reset! idx j)
(reset! open? true)
(reset! off 0)
(preview! (get cands j) 0))]
[:div.pt-input {:class class}
[:input.pt-text
{:auto-focus auto-focus?
:placeholder (or placeholder "clip / annotation / frame...")
:value @text
:on-focus #(reset! open? true)
:on-change #(do (reset! text (.. % -target -value))
(reset! picked nil)
(reset! idx 0)
(reset! off 0)
(reset! open? true)
(preview! (first (point-candidates scene ctx (.. % -target -value))) 0))
:on-key-down
(fn [e]
(case (.-key e)
"ArrowDown" (do (.preventDefault e) (go! (mod (inc i) (max 1 (count cands)))))
"ArrowUp" (do (.preventDefault e) (go! (mod (dec i) (max 1 (count cands)))))
"Enter" (do (.preventDefault e) (emit! (or cur (get cands i)) o))
"Escape" (do (.preventDefault e)
(if @open?
(reset! open? false)
(when on-cancel (on-cancel))))
nil))}]
(when frame?
[:div.pt-frame-row
[:input.pt-frame
{:type "number" :min 0 :max maxo :value o :title "frame offset"
:on-change #(let [v (js/parseInt (.. % -target -value) 10)
v (if (js/isNaN v) 0 (min (max 0 v) maxo))]
(emit! cur v))
:on-key-down (fn [e]
(case (.-key e)
"Enter" (do (.preventDefault e) (emit! cur o))
"Escape" (do (.preventDefault e) (reset! open? false))
nil))}]
[:span.pt-dur (str "/" maxo)]])
(when (and @open? (seq cands))
(into [:ul.pt-dropdown]
(for [[j c] (map-indexed vector cands)]
^{:key j}
[:li {:class (when (and (nil? @picked) (= j i)) "hi")
:on-mouse-down #(.preventDefault %)
:on-mouse-enter #(go! j)
:on-click #(emit! c 0)}
[:span.cand-label (:label c)]
(when (:group c) [:span.cand-group (str " " (:group c))])
[:span.link-f (str " " (js/Math.round (:local c)) "f")]])))])))
(defn- local->draft-point [segs local]
(some (fn [{m :mark [c d] :local}]
(when (and (<= c local) (< local d))
{:seg m :f (js/Math.round (- local c))}))
segs))
(defn- local->draft-end-point [segs local]
(some (fn [{m :mark [c d] :local}]
(when (and (<= c local) (<= local d))
{:seg m :f (js/Math.round (- local c))}))
segs))
(defn- link-picker [{:keys [scene ctx on-commit on-cancel]}]
(r/with-let [picked (r/atom nil)]
[:div.link-insert
[point-picker {:scene scene :ctx ctx :value @picked :auto-focus? true
:on-pick #(reset! picked %)
:on-cancel on-cancel}]
[:button.hl-ok {:type "button" :title "Insert link" :disabled (nil? @picked)
:on-click #(when @picked (on-commit @picked))}
"✓"]
[:button.hl-cancel {:type "button" :title "Cancel" :on-click on-cancel} "✕"]]))
(defn- commit-link!
"Insert a link chip for ref-point `pt` (label `label`) at the editor caret."
[pt label]
(when-let [ins @active-insert!]
(ins (assoc pt :label label))
(rf/dispatch [::events/stop-linking])))
;; --- annotation list (commentary) + authoring ----------------------------
@ -276,7 +532,9 @@
(fn []
(let [anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation])
authed? @(rf/subscribe [::subs/authed?])]
authed? @(rf/subscribe [::subs/authed?])
scene @(rf/subscribe [::subs/scene])
ctx @(rf/subscribe [::subs/context])]
(r/after-render
(fn [] (when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
@ -306,7 +564,7 @@
[: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)) [:div.md (:content a)])]))
(when (not-empty (:content a)) [content-display scene ctx (:content a)])]))
[:div.ann-empty "No annotations here."])]))))
(defn- to-frame [v len]
@ -327,20 +585,32 @@
(let [d @(rf/subscribe [::subs/draft-group])
scene @(rf/subscribe [::subs/scene])
segs @(rf/subscribe [::subs/segments])
ctx @(rf/subscribe [::subs/context])
pt @(rf/subscribe [::subs/pt])
linking @(rf/subscribe [::subs/linking])
gid (:gid d)
new? (= :new (:draft d))
put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)]))
rows (scene/marks->rows scene (:marks d))]
[:div.form
rows (scene/marks->rows scene (:marks d))
valid? (and (not (str/blank? (:name d))) (seq (:marks d)))
save #(when valid? (put (dissoc d :draft)))]
[:form.form {:on-submit (fn [e] (.preventDefault e) (save))}
[:div.form-head (if new? "New annotation" "Edit annotation")]
[:div.form-row
[:input.form-name {:placeholder "Name" :value (:name d)
:on-change #(put (assoc d :name (.. % -target -value)))}]
[:input.form-color {:type "color" :value (:color d)
:on-change #(put (assoc d :color (.. % -target -value)))}]]
[:textarea.form-content {:placeholder "Content (optional)" :value (:content d)
:on-change #(put (assoc d :content (.. % -target -value)))}]
[:div.form-marks-label "Content"]
^{:key gid} [content-editor gid (:content orig)]
(if (and linking (= gid (:gid linking)))
[link-picker {:scene scene :ctx ctx
:on-commit #(commit-link! (select-keys % [:ref :at]) (:label %))
:on-cancel #(rf/dispatch [::events/cancel-linking])}]
[:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-linking gid])}
"🔗 Insert link"])
(when (and linking (= gid (:gid linking)))
[:div.form-hint "Click a clip or the frame-readout to link it, or pick above."])
[:div.form-marks-label "Marks"]
(doall
(for [[i row] (map-indexed vector rows)]
@ -349,23 +619,36 @@
[frame-chip scene segs put d i :start (:s row)]
[:span.mark-arrow "→"]
[frame-chip scene segs put d i :end (:e row)]
[:button.row-x {:title "Remove"
[:button.row-x {:type "button" :title "Remove"
:on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]]))
(when (map? pt)
[:div.mark-row
[:div.pt-chip [:span.pt-chip-name (clip-label scene segs (:seg pt))]]
[:span.mark-arrow "→"] [:div.pt-input.active [:div.pt-text "click end clip…"]]])
[:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt))
" @" (:f pt) "f")]]
[:span.mark-arrow "→"]
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
:placeholder "click end clip..."
:on-pick #(let [end-local (if (and (zero? (:offset %))
(pos? (or (get-in % [:candidate :len]) 0)))
(+ (:local %) (get-in % [:candidate :len]))
(:local %))]
(when-let [p (local->draft-end-point segs end-local)]
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)])))}]])
(when (= pt :new)
[:div.mark-row [:div.pt-input.active [:div.pt-text "click a clip…"]]])
[:div.mark-row
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
:placeholder "click a clip..."
:on-pick #(when-let [p (local->draft-point segs (:local %))]
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]])
[:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."]
[:div.form-marks-label "Script"]
[:button.add-mark {:on-click #(rf/dispatch [::events/start-highlighting gid])}
[:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-highlighting gid])}
(str "+ Add script annotation"
(when-let [n (seq (:script d))] (str " (" (count n) ")")))]
[:div.form-actions
[:button.save {:disabled (or (str/blank? (:name d)) (empty? (:marks d)))
:on-click #(put (dissoc d :draft))} "Save"]
[:button.cancel {:on-click #(if new? (rf/dispatch [::events/drop-group gid]) (put orig))} "Cancel"]]])))
[:button.save {:type "submit" :disabled (not valid?)} "Save"]
[:button.cancel {:type "button"
:on-click #(if new? (rf/dispatch [::events/drop-group gid]) (put orig))} "Cancel"]]])))
;; --- chrome ---------------------------------------------------------------

View file

@ -320,3 +320,49 @@
(is (= 5 (s/local->source segs 5))) ; first A
(is (= 5 (s/local->source segs 205))) ; second A — same source frame
(is (= 100 (s/local->source segs 100)))))) ; crossing into B
;; =========================================================================
;; Suite — links (ref-points named in markdown content)
;; =========================================================================
(deftest parse-content-splits-text-and-links
(testing "content round-trips through link-token / parse-content"
(let [tok (s/link-token {:label "B-roll clip @10" :ref :clip-b :at 10})
s (str "see " tok " here")]
(is (= "[B-roll clip @10](mark:clip-b@10)" tok))
(is (= [[:text "see "]
[:link {:label "B-roll clip @10" :ref :clip-b :at 10}]
[:text " here"]]
(s/parse-content s))))
(is (= [] (s/parse-content "")))
(is (= [[:text "plain note"]] (s/parse-content "plain note")))))
(deftest seg-point-and-link-local-round-trip
(testing "a clip-scoped point resolves back to the same ctx-local frame"
(let [seg (some #(when (= :clip-b (:mark %)) %) (s/content-segments base :root))
pt (s/seg-point base seg 10)]
(is (= {:ref :clip-b :at 10} pt))
(is (= 110 (s/link-local base :root pt))))))
(deftest link-local-absolute-is-the-frame
(testing "an absolute link (ref = ctx) is the local frame itself"
(is (= 42 (s/link-local base :root {:ref :root :at 42})))))
(deftest link-local-nil-when-target-gone
(testing "a link to a deleted clip no longer resolves"
(is (nil? (s/link-local (update base :groups dissoc :clip-b)
:root {:ref :clip-b :at 10})))))
(deftest linkables-groups-tracks-and-annotations
(testing "tracks (with their clips) and child annotations are pickable"
(let [ls (s/linkables base :root)
names (set (map :name ls))]
(is (contains? names "A-roll"))
(is (contains? names "B-roll"))
(is (= 1 (count (:items (some #(when (= "B-roll" (:name %)) %) ls)))))
(is (= {:ref :clip-b :at 0}
(:point (first (:items (some #(when (= "B-roll" (:name %)) %) ls)))))))
(testing "a discontinuous annotation collapses contiguous marks into runs"
(let [ann (some #(when (= :annotation (:kind %)) %) (s/linkables inter-x :root))]
(is (some? ann))
(is (= 1 (count (:items ann)))))))) ; X's four subclips tile [0,200) → one run