feat: reuse marks picker for annotation links
This commit is contained in:
parent
d3c8dcbdf2
commit
5b206635fb
6 changed files with 498 additions and 30 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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]))))))))
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue