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;
|
.form-marks-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
|
||||||
color: #7aa6d6; margin-top: 4px; }
|
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; }
|
.mark-arrow { color: #666; }
|
||||||
.row-x, .add-mark {
|
.row-x, .add-mark {
|
||||||
background: #1c1c1c; color: #aaa; border: 1px solid #333; border-radius: 4px;
|
background: #1c1c1c; color: #aaa; border: 1px solid #333; border-radius: 4px;
|
||||||
|
|
@ -151,16 +162,18 @@ body { overflow: hidden; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* point editor + autocomplete */
|
/* 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 {
|
.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;
|
background: #0e0e0e; color: #ddd; border: 1px solid #333;
|
||||||
border-radius: 4px; padding: 6px 8px; font-size: 13px;
|
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 {
|
.pt-dropdown {
|
||||||
position: absolute; left: 0; right: 0; top: 100%; z-index: 5;
|
position: absolute; left: 0; right: 0; top: 100%; z-index: 5;
|
||||||
margin: 2px 0 0; padding: 4px; list-style: none;
|
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;
|
background: #1b1b1b; border: 1px solid #3a6ea5; border-radius: 4px;
|
||||||
box-shadow: 0 6px 18px rgba(0,0,0,0.5);
|
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;
|
color: #cde; cursor: pointer; white-space: nowrap;
|
||||||
overflow: hidden; text-overflow: ellipsis; }
|
overflow: hidden; text-overflow: ellipsis; }
|
||||||
.pt-dropdown li.hi { background: #2a4a6a; }
|
.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;
|
background: #233; border: 1px solid #3a6ea5; border-radius: 4px;
|
||||||
padding: 2px 4px; }
|
padding: 2px 4px; }
|
||||||
.pt-chip-name { font-size: 12px; color: #cfe3f5; white-space: nowrap;
|
.pt-chip-name { font-size: 12px; color: #cfe3f5; white-space: nowrap;
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,29 @@
|
||||||
(fn [db [_ gid page rect]]
|
(fn [db [_ gid page rect]]
|
||||||
(update-in db [:scene :groups gid :script] (fnil conj []) {:page page :rect 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
|
(rf/reg-event-db ::set-playhead
|
||||||
(fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf)))
|
(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)))
|
(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}]})}))
|
:marks [{:id :root-m :start 0 :end duration}]})}))
|
||||||
|
|
||||||
(defn clip-name [scene gid] (:name (grp scene gid)))
|
(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 ::script-url (fn [db] (get-in db [:project :script])))
|
||||||
(rf/reg-sub ::pane (fn [db] (get-in db [:view :pane] :annotations)))
|
(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 ::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 ::thumbnails (fn [db] (get-in db [:project :thumbnails])))
|
||||||
(rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status])))
|
(rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status])))
|
||||||
(rf/reg-sub ::create-error (fn [db] (:create-error db)))
|
(rf/reg-sub ::create-error (fn [db] (:create-error db)))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
(defonce raf (atom nil))
|
(defonce raf (atom nil))
|
||||||
(defonce scroll-el (atom nil))
|
(defonce scroll-el (atom nil))
|
||||||
(defonce play (atom nil)) ; {:ctx :segs :fps :idx} while playing, else 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-guard-frames 0.75)
|
||||||
(def ^:private seek-settle-frames 2)
|
(def ^:private seek-settle-frames 2)
|
||||||
|
|
@ -144,11 +146,16 @@
|
||||||
:ref (fn [n] (when n (reset! video-el n)))}])
|
:ref (fn [n] (when n (reset! video-el n)))}])
|
||||||
|
|
||||||
(defn- clip-label
|
(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]
|
[scene segs mid]
|
||||||
(let [tname (some (fn [{m :mark t :track}] (when (= m mid) (get-in scene [:tracks t :name]))) segs)
|
(or (some (fn [[t tsegs]]
|
||||||
cname (or (scene/clip-name scene mid) "clip")]
|
(some (fn [[i {m :mark}]]
|
||||||
(if tname (str tname " " cname) cname)))
|
(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
|
(defn frame-readout
|
||||||
"Bottom-right of the video: the timeline (context-absolute) frame, and the
|
"Bottom-right of the video: the timeline (context-absolute) frame, and the
|
||||||
|
|
@ -158,16 +165,22 @@
|
||||||
(let [ph @(rf/subscribe [::subs/playhead])
|
(let [ph @(rf/subscribe [::subs/playhead])
|
||||||
segs @(rf/subscribe [::subs/segments])
|
segs @(rf/subscribe [::subs/segments])
|
||||||
scene @(rf/subscribe [::subs/scene])
|
scene @(rf/subscribe [::subs/scene])
|
||||||
|
linking @(rf/subscribe [::subs/linking])
|
||||||
auth? (some? @(rf/subscribe [::subs/draft-group]))
|
auth? (some? @(rf/subscribe [::subs/draft-group]))
|
||||||
seg (some (fn [{[c d] :local :as s}] (when (and (<= c ph) (< ph d)) s)) segs)
|
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
|
[:div.frame-readout
|
||||||
[:span.fr-abs (str (js/Math.round ph) "f")]
|
[:span.fr-abs (str (js/Math.round ph) "f")]
|
||||||
(when seg
|
(when seg
|
||||||
[:span.fr-clip {:class (when auth? "clickable")
|
[:span.fr-clip {:class (when click? "clickable")
|
||||||
:title (when auth? "Click to set endpoint at this frame")
|
:title (when click? (if linking "Click to link this frame"
|
||||||
:on-click (when auth?
|
"Click to set endpoint at this frame"))
|
||||||
#(rf/dispatch [::events/draft-click-seg (:mark seg) cf]))}
|
: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")])]))
|
(str (clip-label scene segs (:mark seg)) " " cf "f")])]))
|
||||||
|
|
||||||
;; --- timeline -------------------------------------------------------------
|
;; --- timeline -------------------------------------------------------------
|
||||||
|
|
@ -203,6 +216,7 @@
|
||||||
playhead @(rf/subscribe [::subs/playhead])
|
playhead @(rf/subscribe [::subs/playhead])
|
||||||
playing? @(rf/subscribe [::subs/playing?])
|
playing? @(rf/subscribe [::subs/playing?])
|
||||||
authoring? (some? @(rf/subscribe [::subs/draft-group]))
|
authoring? (some? @(rf/subscribe [::subs/draft-group]))
|
||||||
|
linking @(rf/subscribe [::subs/linking])
|
||||||
len @(rf/subscribe [::subs/length])
|
len @(rf/subscribe [::subs/length])
|
||||||
width (px len fps zoom)
|
width (px len fps zoom)
|
||||||
lane-h (* 18 (count anns))
|
lane-h (* 18 (count anns))
|
||||||
|
|
@ -245,12 +259,254 @@
|
||||||
[:div.clip {:class (when authoring? "clickable")
|
[:div.clip {:class (when authoring? "clickable")
|
||||||
:on-mouse-down (when authoring?
|
:on-mouse-down (when authoring?
|
||||||
(fn [e] (.stopPropagation e) (.preventDefault e)
|
(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))
|
: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)
|
:top (+ lane-h (* (get track-y track 0) row-h)) :height (- row-h 2)
|
||||||
:line-height (str (- row-h 2) "px") :position "absolute"}}
|
:line-height (str (- row-h 2) "px") :position "absolute"}}
|
||||||
[clip-thumbnail-layer thumbs fps zoom row-h seg]
|
[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 ----------------------------
|
;; --- annotation list (commentary) + authoring ----------------------------
|
||||||
|
|
||||||
|
|
@ -276,7 +532,9 @@
|
||||||
(fn []
|
(fn []
|
||||||
(let [anns @(rf/subscribe [::subs/annotations])
|
(let [anns @(rf/subscribe [::subs/annotations])
|
||||||
active @(rf/subscribe [::subs/active-annotation])
|
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
|
(r/after-render
|
||||||
(fn [] (when-let [c @el]
|
(fn [] (when-let [c @el]
|
||||||
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
|
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
|
||||||
|
|
@ -306,7 +564,7 @@
|
||||||
[:button.del-btn {:title "Delete"
|
[:button.del-btn {:title "Delete"
|
||||||
:on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?"))
|
:on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?"))
|
||||||
(rf/dispatch [::events/delete-annotation (:id 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."])]))))
|
[:div.ann-empty "No annotations here."])]))))
|
||||||
|
|
||||||
(defn- to-frame [v len]
|
(defn- to-frame [v len]
|
||||||
|
|
@ -327,20 +585,32 @@
|
||||||
(let [d @(rf/subscribe [::subs/draft-group])
|
(let [d @(rf/subscribe [::subs/draft-group])
|
||||||
scene @(rf/subscribe [::subs/scene])
|
scene @(rf/subscribe [::subs/scene])
|
||||||
segs @(rf/subscribe [::subs/segments])
|
segs @(rf/subscribe [::subs/segments])
|
||||||
|
ctx @(rf/subscribe [::subs/context])
|
||||||
pt @(rf/subscribe [::subs/pt])
|
pt @(rf/subscribe [::subs/pt])
|
||||||
|
linking @(rf/subscribe [::subs/linking])
|
||||||
gid (:gid d)
|
gid (:gid d)
|
||||||
new? (= :new (:draft d))
|
new? (= :new (:draft d))
|
||||||
put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)]))
|
put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)]))
|
||||||
rows (scene/marks->rows scene (:marks d))]
|
rows (scene/marks->rows scene (:marks d))
|
||||||
[:div.form
|
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-head (if new? "New annotation" "Edit annotation")]
|
||||||
[:div.form-row
|
[:div.form-row
|
||||||
[:input.form-name {:placeholder "Name" :value (:name d)
|
[:input.form-name {:placeholder "Name" :value (:name d)
|
||||||
:on-change #(put (assoc d :name (.. % -target -value)))}]
|
:on-change #(put (assoc d :name (.. % -target -value)))}]
|
||||||
[:input.form-color {:type "color" :value (:color d)
|
[:input.form-color {:type "color" :value (:color d)
|
||||||
:on-change #(put (assoc d :color (.. % -target -value)))}]]
|
:on-change #(put (assoc d :color (.. % -target -value)))}]]
|
||||||
[:textarea.form-content {:placeholder "Content (optional)" :value (:content d)
|
[:div.form-marks-label "Content"]
|
||||||
:on-change #(put (assoc d :content (.. % -target -value)))}]
|
^{: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"]
|
[:div.form-marks-label "Marks"]
|
||||||
(doall
|
(doall
|
||||||
(for [[i row] (map-indexed vector rows)]
|
(for [[i row] (map-indexed vector rows)]
|
||||||
|
|
@ -349,23 +619,36 @@
|
||||||
[frame-chip scene segs put d i :start (:s row)]
|
[frame-chip scene segs put d i :start (:s row)]
|
||||||
[:span.mark-arrow "→"]
|
[:span.mark-arrow "→"]
|
||||||
[frame-chip scene segs put d i :end (:e row)]
|
[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)))))))} "✕"]]))
|
:on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]]))
|
||||||
(when (map? pt)
|
(when (map? pt)
|
||||||
[:div.mark-row
|
[:div.mark-row
|
||||||
[:div.pt-chip [:span.pt-chip-name (clip-label scene segs (:seg pt))]]
|
[:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt))
|
||||||
[:span.mark-arrow "→"] [:div.pt-input.active [:div.pt-text "click end clip…"]]])
|
" @" (: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)
|
(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-hint "Click a clip in the timeline to set a start, then a clip for the end."]
|
||||||
[:div.form-marks-label "Script"]
|
[: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"
|
(str "+ Add script annotation"
|
||||||
(when-let [n (seq (:script d))] (str " (" (count n) ")")))]
|
(when-let [n (seq (:script d))] (str " (" (count n) ")")))]
|
||||||
[:div.form-actions
|
[:div.form-actions
|
||||||
[:button.save {:disabled (or (str/blank? (:name d)) (empty? (:marks d)))
|
[:button.save {:type "submit" :disabled (not valid?)} "Save"]
|
||||||
:on-click #(put (dissoc d :draft))} "Save"]
|
[:button.cancel {:type "button"
|
||||||
[:button.cancel {:on-click #(if new? (rf/dispatch [::events/drop-group gid]) (put orig))} "Cancel"]]])))
|
:on-click #(if new? (rf/dispatch [::events/drop-group gid]) (put orig))} "Cancel"]]])))
|
||||||
|
|
||||||
;; --- chrome ---------------------------------------------------------------
|
;; --- chrome ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -320,3 +320,49 @@
|
||||||
(is (= 5 (s/local->source segs 5))) ; first A
|
(is (= 5 (s/local->source segs 5))) ; first A
|
||||||
(is (= 5 (s/local->source segs 205))) ; second A — same source frame
|
(is (= 5 (s/local->source segs 205))) ; second A — same source frame
|
||||||
(is (= 100 (s/local->source segs 100)))))) ; crossing into B
|
(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