From 6ed11fbca526533e58bf5142208ccbad40238552 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 27 Jun 2026 12:56:05 -0400 Subject: [PATCH] local storage, mobile improvements, re-frame refactor, etc --- tl/src/tl/db.cljs | 40 +----- tl/src/tl/events.cljs | 44 +++++- tl/src/tl/storage.cljs | 16 +++ tl/src/tl/subs.cljs | 24 +++- tl/src/tl/views.cljs | 314 ++++++++++++++++++++++++++++++++++------- 5 files changed, 351 insertions(+), 87 deletions(-) create mode 100644 tl/src/tl/storage.cljs diff --git a/tl/src/tl/db.cljs b/tl/src/tl/db.cljs index 4962432..8f12cf5 100644 --- a/tl/src/tl/db.cljs +++ b/tl/src/tl/db.cljs @@ -15,38 +15,7 @@ ;; decides what's currently visible. For now everything is hardcoded. :marks {:timeline-stack [:root] - :groups - {:root {:type :timeline :name "Sequence" :mark-groups "*"} - - :opening-wides - {:type :annotation :name "The opening wides" - :marks [:t0-c0 :t1-c0] - :content "# The opening wides\n\nWe **establish the geography** before a single face. Two wides, back to back — court, players, the *distance* between them. Everything tightens from here.\n\n- full court\n- baseline"} - - :tashis-face - {:type :annotation :name "Tashi's gravity" - :marks [:t2-c0 :t2-c1 :t2-c2 :t2-c3 :t2-c4] - :content "## Tashi's gravity\n\nThe cut keeps *returning* to her. Five times in the first minute we come back to this **CU** — the edit can't leave her alone, and neither can they."} - - :the-volley - {:type :annotation :name "Beat: the volley" - :marks [[1831 2660]] - :content "## Beat: the volley\n\nTashi → Art → Patrick, **MS**, accelerating. The rhythm of the cutting *is* the rally — shot lengths shrink as the point builds."} - - :the-eyes - {:type :annotation :name "Eyes" - :marks [:t19-c0 :t20-c0 :t21-c0 :t19-c1 :t20-c1] - :content "## Eyes\n\nThree pairs of eyes, intercut. Hers searching, his certain, his afraid. The film **rhymes** them and lets you do the math."} - - :a-spectator - {:type :annotation :name "A spectator" - :marks [[:at :t11-c0 0]] - :content "A spectator, caught for a single frame. *Remember this face.*"} - - :slow-mo - {:type :annotation :name "Climax: the serve" - :marks [:t30-c0] - :content "# Climax: the serve (slow-mo)\n\n3000+ frames on one motion. Time **dilates** at the decisive moment — the cut finally stops cutting and just *watches*."}}} + :groups {:root {:type :timeline :name "Sequence" :mark-groups "*"}}} ;; transient view state (mutates while scrubbing/zooming) :view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps) @@ -54,6 +23,7 @@ :playhead 0.0 ; frames (media/display coordinate) :playing? false :selected-clip nil - ;; resizable layout (px): top region height, video pane width - :top-h 360 - :video-w 560}}) + ;; resizable layout (relative): top region height as vh, video pane + ;; width as % of the top row. Pixel minimums enforced in CSS. + :top-h 46 + :video-w 50}}) diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index 5f19225..6de46e6 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -3,12 +3,19 @@ [re-frame.core :as rf] [tl.db :as db] [tl.otio :as otio] + [tl.storage :as storage] [ajax.core :as ajax])) +;; Persist the :marks map to localStorage as a side effect. +(rf/reg-fx :tl/save (fn [marks] (storage/save-marks! marks))) + (rf/reg-event-db ::initialize-db (fn [_ _] - db/default-db)) + ;; hydrate the annotation layer from localStorage if present + (if-let [saved (storage/load-marks)] + (assoc db/default-db :marks saved) + db/default-db))) ;; --- load + parse --------------------------------------------------------- @@ -69,3 +76,38 @@ (rf/reg-event-db ::set-video-w (fn [db [_ w]] (assoc-in db [:view :video-w] w))) + +;; --- authoring draft (lives in app-db: reactive fields + live preview) --- + +(rf/reg-event-db ::open-draft (fn [db [_ d]] (assoc db :draft d))) +(rf/reg-event-db ::close-draft (fn [db _] (dissoc db :draft))) +(rf/reg-event-db ::draft-set (fn [db [_ path v]] (assoc-in db (into [:draft] path) v))) +(rf/reg-event-db ::draft-conj-row (fn [db [_ row]] (update-in db [:draft :rows] (fnil conj []) row))) +(rf/reg-event-db ::draft-remove-row (fn [db [_ i]] + (update-in db [:draft :rows] + #(into (subvec % 0 i) (subvec % (inc i)))))) + +;; --- authoring: create / delete annotations (persisted) ----------------- + +(rf/reg-event-fx + ::add-annotation + (fn [{:keys [db]} [_ {:keys [name content color marks]}]] + (let [id (keyword (str "ann-" (random-uuid))) + group (cond-> {:type :annotation :name name :color color :marks marks} + (seq content) (assoc :content content)) + db' (assoc-in db [:marks :groups id] group)] + {:db db' :tl/save (:marks db')}))) + +(rf/reg-event-fx + ::update-annotation + (fn [{:keys [db]} [_ id {:keys [name content color marks]}]] + (let [group (cond-> {:type :annotation :name name :color color :marks marks} + (seq content) (assoc :content content)) + db' (assoc-in db [:marks :groups id] group)] + {:db db' :tl/save (:marks db')}))) + +(rf/reg-event-fx + ::delete-annotation + (fn [{:keys [db]} [_ id]] + (let [db' (update-in db [:marks :groups] dissoc id)] + {:db db' :tl/save (:marks db')}))) diff --git a/tl/src/tl/storage.cljs b/tl/src/tl/storage.cljs new file mode 100644 index 0000000..93f9ec1 --- /dev/null +++ b/tl/src/tl/storage.cljs @@ -0,0 +1,16 @@ +(ns tl.storage + "Persist the user's mark-groups to localStorage as EDN. The OTIO-derived clips + are NOT stored — only the authored annotation layer (:marks)." + (:require [cljs.reader :as reader])) + +(def ^:private k "tl/marks") + +(defn load-marks + "Read the saved :marks map, or nil if absent/corrupt." + [] + (when-let [s (.getItem js/localStorage k)] + (try (reader/read-string s) + (catch :default _ nil)))) + +(defn save-marks! [marks] + (.setItem js/localStorage k (pr-str marks))) diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index d364b7a..218a723 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -10,11 +10,32 @@ (rf/reg-sub ::duration :<- [::timeline] (fn [t] (:duration t))) (rf/reg-sub ::tracks :<- [::timeline] (fn [t] (:tracks t))) +;; --- authoring draft ----------------------------------------------------- +(rf/reg-sub ::draft (fn [db] (:draft db))) +(rf/reg-sub ::draft-at (fn [db [_ path]] (get-in db (into [:draft] path)))) + ;; --- mark-groups / annotations ------------------------------------------ (rf/reg-sub ::groups (fn [db] (get-in db [:marks :groups]))) (rf/reg-sub ::timeline-stack (fn [db] (get-in db [:marks :timeline-stack]))) (rf/reg-sub ::clip-index :<- [::timeline] (fn [t] (when t (marks/clip-index t)))) +;; Flat catalog of clips for the authoring autocomplete: one entry per clip, +;; with its setup name + 0-based occurrence within that setup, ordered by time. +(rf/reg-sub + ::clip-catalog + :<- [::clip-index] + (fn [idx _] + (when idx + (->> (vals idx) + (group-by :track) + (mapcat (fn [[setup clips]] + (map-indexed (fn [occ c] + {:clip-id (keyword (:id c)) :setup setup + :occ occ :start (:media-in c)}) + (sort-by :media-in clips)))) + (sort-by :start) + vec)))) + (rf/reg-sub ::current-timeline :<- [::groups] :<- [::timeline-stack] @@ -39,7 +60,8 @@ (filter (fn [[gid g]] (and (= :annotation (:type g)) (admits? rule gid)))) (map (fn [[gid g]] (let [spans (marks/resolve-group idx groups gid)] - {:id gid :name (:name g) :content (:content g) + {:id gid :name (:name g) :content (:content g) :color (:color g) + :marks (:marks g) :spans spans :anchor (marks/anchor spans) :instant? (every? :instant? spans)}))) (sort-by :anchor) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 4db93fe..5766373 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -1,8 +1,10 @@ (ns tl.views (:require + [clojure.string :as str] [reagent.core :as r] [re-frame.core :as rf] [tl.subs :as subs] + [tl.marks :as marks] [tl.md :as md] [tl.events :as events])) @@ -42,6 +44,69 @@ (rf/dispatch [::events/set-playhead frame]) (seek-to-frame! fps frame)) +(defn- jump-and-scroll! + "Jump (seek video + move playhead) AND scroll the timeline to center the frame." + [fps frame] + (jump! fps frame) + (center-on-playhead! fps (or @(rf/subscribe [::subs/zoom]) 50) frame)) + +;; --- authoring draft (in app-db at :draft; nil = closed) ----------------- +;; A point is {:text "..."} (a typed frame) or {:clip :setup :occ +;; :frame } (a chosen clip). A row is {:a :b }; b empty => an +;; instant, b filled => a range. + +(defn- blank-row [] {:a {:text ""} :b {:text ""}}) +(defn- blank-draft [] {:name "" :content "" :color "#4e8fc2" :rows [(blank-row)]}) + +(defn- insert-clip! + "Clicking a clip while authoring appends a whole-clip range row (clip[0]→clip[-1])." + [clip-id] + (let [{:keys [setup occ]} (some #(when (= clip-id (:clip-id %)) %) + @(rf/subscribe [::subs/clip-catalog]))] + (when setup + (rf/dispatch [::events/draft-conj-row + {:a {:clip clip-id :setup setup :occ occ :frame 0} + :b {:clip clip-id :setup setup :occ occ :frame -1}}])))) + +;; reverse of the form's row->mark: turn stored marks back into editor rows +(defn- clip-point [cat clip frame] + (let [{:keys [setup occ]} (some #(when (= clip (:clip-id %)) %) cat)] + {:clip clip :setup setup :occ occ :frame frame})) + +(defn- mp->point [cat mp] + (cond + (number? mp) {:text (str mp)} + (and (vector? mp) (= :at (first mp))) (clip-point cat (second mp) (nth mp 2)) + :else {:text ""})) + +(defn- mark->row [cat m] + (cond + (number? m) {:a {:text (str m)} :b {:text ""}} + (keyword? m) {:a (clip-point cat m 0) :b (clip-point cat m -1)} ; whole-clip ref + (and (vector? m) (= :at (first m))) {:a (mp->point cat m) :b {:text ""}} ; instant + (and (vector? m) (= 2 (count m))) {:a (mp->point cat (first m)) ; range + :b (mp->point cat (second m))} + :else {:a {:text ""} :b {:text ""}})) + +(defn- edit-draft [cat id {:keys [name content color marks]}] + {:id id :name name :content (or content "") :color (or color "#4e8fc2") + :rows (mapv #(mark->row cat %) marks)}) + +;; forward: editor row -> a mark (for save + the live timeline preview) +(defn- point->mp + "Point editor -> a mark point: a frame number, or [:at clip frame], or nil." + [{:keys [clip frame text]}] + (cond + clip [:at clip (or frame 0)] + (and text (re-matches #"\s*-?\d+\s*" text)) (js/parseInt (str/trim text)) + :else nil)) + +(defn- row->mark [{:keys [a b]}] + (let [pa (point->mp a) pb (point->mp b)] + (cond (and pa pb) [pa pb] ; range + pa pa ; instant + :else nil))) + (defn- toggle-play! [] (when-let [v @video-el] (if (.-paused v) (.play v) (.pause v)))) ; play/pause events drive :playing? + rAF @@ -138,9 +203,12 @@ (when-let [node (and active (.querySelector c (str "#ann-" (name active))))] (set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3)))))))) [:div.commentary {:ref (fn [n] (reset! el n))} + [:div.commentary-head + [:button.add-btn {:on-click #(rf/dispatch [::events/open-draft (blank-draft)])} + "+ Add annotation"]] (if (seq anns) (doall - (for [{:keys [id content spans] nm :name} anns] + (for [{:keys [id content spans] nm :name :as ann} anns] (let [targets (vec (jump-targets fps nm spans)) one? (= 1 (count targets))] ^{:key id} @@ -148,14 +216,24 @@ :class (when (= id active) "active")} [:div.ann-head [:div.ann-title nm] - (if one? - [:button.jump-btn - {:on-click #(jump! fps (:frame (first targets)))} "↪ jump"] - ;; native popover: the button toggles it; the browser - ;; handles light-dismiss + Escape + top-layer. Positioning is - ;; the one bit of JS (anchor() misbehaves in a scroll box). - [:button.jump-btn {:popovertarget (str "jp-" (name id))} - (str "↪ jump (" (count targets) ")")])] + [:div.ann-actions + (if one? + [:button.jump-btn + {:on-click #(jump-and-scroll! fps (:frame (first targets)))} "↪ jump"] + ;; native popover: the button toggles it; the browser + ;; handles light-dismiss + Escape + top-layer. Positioning is + ;; the one bit of JS (anchor() misbehaves in a scroll box). + [:button.jump-btn {:popovertarget (str "jp-" (name id))} + (str "↪ jump (" (count targets) ")")]) + [:button.edit-btn + {:title "Edit annotation" + :on-click #(rf/dispatch [::events/open-draft + (edit-draft @(rf/subscribe [::subs/clip-catalog]) id ann)])} + "✎"] + [:button.del-btn {:title "Delete annotation" + :on-click #(when (js/confirm (str "Delete \"" nm "\"?")) + (rf/dispatch [::events/delete-annotation id]))} + "✕"]]] (when-not one? [:div.jump-pop {:popover "auto" :id (str "jp-" (name id)) :ref position-popover-ref} @@ -163,27 +241,25 @@ ^{:key k} [:button {:popovertarget (str "jp-" (name id)) :popovertargetaction "hide" - :on-click #(jump! fps (:frame t))} + :on-click #(jump-and-scroll! fps (:frame t))} (:label t)])]) [md/render content]]))) [:div.ann-empty "No annotations."])])))) ;; --- timeline ------------------------------------------------------------- -(def ^:private ann-colors ["#c2554e" "#4e8fc2" "#5ab07a" "#b08a3a" "#8a5ab0" "#3aa0a0"]) - (defn annotation-lane "One labeled row per annotation, above the tracks. Bars are positioned in display frames (same coordinate as clips), so they align with the timeline." [fps zoom annotations active] [:div (doall - (map-indexed - (fn [i {:keys [id name spans]}] - (let [color (nth ann-colors (mod i (count ann-colors))) - on? (= id active)] - ^{:key id} - [:div.ann-lane-row + (for [{:keys [id name spans preview?] c :color} annotations] + (let [color (or c "#4e8fc2") + on? (= id active)] + ^{:key id} + [:div.ann-lane-row + (doall (for [[j s] (map-indexed vector spans)] (let [left (* (secs (:start s) fps) zoom) w (* (secs (- (:end s) (:start s)) fps) zoom)] @@ -191,23 +267,28 @@ [:div.ann-bar {:title name :style {:left left :width (max 3 w) - :background (if on? color (str color "80")) - :border (str "1px solid " color)}}])) - (when-let [a (some-> spans first :start)] - [:div.ann-bar-label {:style {:left (+ 4 (* (secs a fps) zoom))}} name])])) - annotations))]) + :background (cond preview? (str color "44") on? color :else (str color "80")) + :border (str (if preview? "1px dashed " "1px solid ") color)}}]))) + (when-let [a (some-> spans first :start)] + [:div.ann-bar-label {:style {:left (+ 4 (* (secs a fps) zoom))}} name])])))]) -(defn clip-block [fps zoom row-h {:keys [name media-in duration] :as _clip}] +(defn clip-block [fps zoom row-h authoring? {:keys [id name media-in duration] :as _clip}] (let [left (* (secs media-in fps) zoom) width (max 1 (* (secs duration fps) zoom))] [:div.clip {:style {:left left :width width :height (- row-h 2) - :line-height (str (- row-h 2) "px")}} + :line-height (str (- row-h 2) "px")} + ;; while authoring, click a clip to drop it into the marks list + :class (when authoring? "clickable") + :on-mouse-down (when authoring? + (fn [e] + (.stopPropagation e) ; don't start a scrub + (insert-clip! (keyword id))))} name])) -(defn track-row [fps zoom row-h track] +(defn track-row [fps zoom row-h authoring? track] [:div.track-row {:style {:height row-h}} (for [c (:clips track)] - ^{:key (:id c)} [clip-block fps zoom row-h c])]) + ^{:key (:id c)} [clip-block fps zoom row-h authoring? c])]) (defn- ev->frames "Pointer clientX -> clamped timeline frame, relative to the content div @@ -246,8 +327,23 @@ playing? @(rf/subscribe [::subs/playing?]) anns @(rf/subscribe [::subs/annotations]) active @(rf/subscribe [::subs/active-annotation]) + ;; live, un-saved preview of the annotation currently being authored + d @(rf/subscribe [::subs/draft]) + preview (when d + (let [cidx @(rf/subscribe [::subs/clip-index]) + groups @(rf/subscribe [::subs/groups]) + marks (vec (keep row->mark (:rows d))) + spans (when (and cidx (seq marks)) + (try (marks/resolve-marks cidx groups #{} marks) + (catch :default _ nil)))] + (when (seq spans) + {:id ::draft :preview? true :color (:color d) + :name (or (not-empty (str/trim (str (:name d)))) "(new)") + :spans spans}))) + lane-anns (let [base (if (:id d) (remove #(= (:id %) (:id d)) anns) anns)] + (cond-> (vec base) preview (conj preview))) vtracks (filter #(= :video (:kind %)) tracks) ; audio hidden - lane-h (* 18 (count anns)) + lane-h (* 18 (count lane-anns)) width (* (secs duration fps) zoom)] (when (and playing? @following?) (r/after-render #(center-on-playhead! fps zoom playhead))) @@ -268,9 +364,9 @@ (let [x (* (secs playhead fps) zoom)] [:div.playhead {:style {:left x}} [:div.playhead-handle]]) - [annotation-lane fps zoom anns active] + [annotation-lane fps zoom lane-anns active] (for [t vtracks] - ^{:key (:id t)} [track-row fps zoom row-h t])]]])))) + ^{:key (:id t)} [track-row fps zoom row-h (some? d) t])]]])))) ;; --- zoom + layout shell -------------------------------------------------- @@ -293,45 +389,163 @@ (r/after-render #(set! (.-scrollLeft el) target)))))) (defn zoom-controls [] - (let [zoom @(rf/subscribe [::subs/zoom]) - row-h @(rf/subscribe [::subs/row-h])] + (let [zoom @(rf/subscribe [::subs/zoom]) + row-h @(rf/subscribe [::subs/row-h]) + playing? @(rf/subscribe [::subs/playing?])] [:div.toolbar + [:button.play-btn {:on-click toggle-play! :title "Play / pause (Space)"} + (if playing? "⏸" "▶")] [slider "Zoom X" zoom 2 400 zoom-x!] [slider "Zoom Y" row-h 8 120 #(rf/dispatch [::events/set-row-h %])]])) (defn- begin-resize! - "Drag a divider: dispatches `evt` with the new size, clamped to [lo hi]." - [axis evt cur lo hi ev] + "Drag a divider: dispatches `evt` with the new size as a relative percent, + clamped to [lo hi]. :x = % of the row width; :y = vh from the top." + [axis evt lo hi ev] (.preventDefault ev) - (let [p0 (if (= axis :x) (.-clientX ev) (.-clientY ev)) + (let [container (.. ev -currentTarget -parentNode) ; the .top row (for :x) move (fn [e] - (let [p (if (= axis :x) (.-clientX e) (.-clientY e)) - n (-> (+ cur (- p p0)) (max lo) (min hi))] + (let [pct (if (= axis :x) + (let [r (.getBoundingClientRect container)] + (* 100 (/ (- (.-clientX e) (.-left r)) (.-width r)))) + (* 100 (/ (.-clientY e) (.-innerHeight js/window)))) + n (-> pct (max lo) (min hi))] (rf/dispatch-sync [evt n]))) up (fn up [_] - (.removeEventListener js/document "mousemove" move) - (.removeEventListener js/document "mouseup" up))] - (.addEventListener js/document "mousemove" move) - (.addEventListener js/document "mouseup" up))) + (.removeEventListener js/document "pointermove" move) + (.removeEventListener js/document "pointerup" up))] + ;; pointer events so it works for touch (mouse events don't fire on touch) + (.addEventListener js/document "pointermove" move) + (.addEventListener js/document "pointerup" up))) + +;; --- authoring form ------------------------------------------------------- + +(defn- suggestions [catalog text fps] + (let [t (str/trim (or text ""))] + (cond + (re-matches #"-?\d+" t) + [{:type :frame :frame (js/parseInt t) :label (str "Frame " t " · " (tc (js/parseInt t) fps))}] + (seq t) + (->> catalog + (filter #(str/includes? (str/lower-case (:setup %)) (str/lower-case t))) + (map #(assoc % :type :clip + :label (str (:setup %) " [" (:occ %) "] · " (tc (:start %) fps)))) + (take 40) vec) + :else []))) + +(defn point-input + "A point editor: text input with autocomplete (frame or clip), or — once a + clip is chosen — a chip + a frame field. `path` is the path into :draft for + this point; the value lives in re-frame (the highlighted-row index is the only + local, ephemeral bit)." + [path _placeholder] + (let [hi (r/atom 0)] ; ephemeral: autocomplete highlight + (fn [path placeholder] + (let [p (or @(rf/subscribe [::subs/draft-at path]) {}) + fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001)) + set-point! (fn [v] (rf/dispatch [::events/draft-set path v]))] + (if (:clip p) + (let [dur (some-> @(rf/subscribe [::subs/clip-index]) (get (:clip p)) :duration)] + [:span.pt-chip + [:span.pt-chip-name (str (:setup p) " [" (:occ p) "]")] + [:input.pt-frame {:type "number" :value (:frame p) + :title "frame within clip (-1 = last)" + :on-change #(rf/dispatch [::events/draft-set (conj path :frame) + (js/parseInt (.. % -target -value))])}] + (when dur [:span.pt-dur (str "/" dur)]) + [:button.pt-x {:title "clear" :on-click #(set-point! {:text ""})} "×"]]) + (let [text (:text p "") + sugg (suggestions @(rf/subscribe [::subs/clip-catalog]) text fps) + preview! (fn [] (let [s (nth sugg @hi nil)] + (when (= :clip (:type s)) (jump-and-scroll! fps (:start s))))) + pick (fn [i] (when-let [s (nth sugg i nil)] + (set-point! (case (:type s) + :frame {:text (str (:frame s))} + :clip {:clip (:clip-id s) :setup (:setup s) + :occ (:occ s) :frame 0}))))] + [:span.pt-input + [:input.pt-text + {:value text :placeholder placeholder + :on-change #(do (set-point! {:text (.. % -target -value)}) (reset! hi 0)) + :on-key-down (fn [e] + (case (.-key e) + "ArrowDown" (do (.preventDefault e) + (swap! hi #(min (max 0 (dec (count sugg))) (inc %))) + (preview!)) + "ArrowUp" (do (.preventDefault e) + (swap! hi #(max 0 (dec %))) + (preview!)) + "Enter" (do (.preventDefault e) (pick @hi)) + nil)) + :on-blur #(when (seq sugg) (pick @hi))}] + (when (seq sugg) + [:ul.pt-dropdown + (doall + (map-indexed + (fn [i s] ^{:key i} + [:li {:class (when (= i @hi) "hi") + :on-mouse-enter #(reset! hi i) + :on-mouse-down (fn [e] (.preventDefault e) (pick i))} + (:label s)]) + sugg))])])))))) + +(defn mark-row [i] + [:div.mark-row + [point-input [:rows i :a] "frame or clip…"] + [:span.mark-arrow "→"] + [point-input [:rows i :b] "end (optional)"] + [:button.row-x {:title "remove mark" + :on-click #(rf/dispatch [::events/draft-remove-row i])} + "✕"]]) + +(defn- save-draft! [d] + (let [nm (str/trim (:name d)) + marks (vec (keep row->mark (:rows d)))] + (cond + (str/blank? nm) (js/alert "Name is required.") + (empty? marks) (js/alert "Add at least one valid mark.") + :else (let [payload {:name nm :content (:content d) :color (:color d) :marks marks}] + (if-let [id (:id d)] + (rf/dispatch [::events/update-annotation id payload]) + (rf/dispatch [::events/add-annotation payload])) + (rf/dispatch [::events/close-draft]))))) + +(defn annotation-form [] + (when-let [d @(rf/subscribe [::subs/draft])] + [:div.form + [:div.form-head (if (:id d) "Edit annotation" "New annotation")] + [:div.form-row + [:input.form-name {:placeholder "Name (required)" :value (:name d) :auto-focus true + :on-change #(rf/dispatch [::events/draft-set [:name] (.. % -target -value)])}] + [:input.form-color {:type "color" :value (:color d) + :on-change #(rf/dispatch [::events/draft-set [:color] (.. % -target -value)])}]] + [:textarea.form-content {:placeholder "Content (optional, markdown)" :value (:content d) + :on-change #(rf/dispatch [::events/draft-set [:content] (.. % -target -value)])}] + [:div.form-marks-label "Marks"] + (doall (for [i (range (count (:rows d)))] ^{:key i} [mark-row i])) + [:button.add-mark {:on-click #(rf/dispatch [::events/draft-conj-row (blank-row)])} "+ add mark"] + [:div.form-hint "Tip: click a clip in the timeline to drop it in as a whole-clip range."] + [:div.form-actions + [:button.save {:on-click #(save-draft! d)} "Save"] + [:button.cancel {:on-click #(rf/dispatch [::events/close-draft])} "Cancel"]]])) (defn main-panel [] (let [status @(rf/subscribe [::subs/load-status]) fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001)) top-h @(rf/subscribe [::subs/top-h]) video-w @(rf/subscribe [::subs/video-w])] - [:div.app - ;; top region: video | annotation - [:div.top {:style {:height top-h}} - [:div.video-pane {:style {:width video-w}} + [:div.app {:style {"--top-h" (str top-h "vh") "--video-w" (str video-w "%")}} + ;; top region: video | annotation (the annotation pane becomes the + ;; authoring form while a draft is open, leaving the timeline accessible) + [:div.top + [:div.video-pane [video-monitor fps]] [:div.divider-v - {:on-mouse-down #(begin-resize! :x ::events/set-video-w video-w - 220 (- (.-innerWidth js/window) 280) %)}] - [:div.annot-pane [commentary]]] + {:on-pointer-down #(begin-resize! :x ::events/set-video-w 12 88 %)}] + [:div.annot-pane (if @(rf/subscribe [::subs/draft]) [annotation-form] [commentary])]] ;; horizontal divider [:div.divider-h - {:on-mouse-down #(begin-resize! :y ::events/set-top-h top-h - 140 (- (.-innerHeight js/window) 160) %)}] + {:on-pointer-down #(begin-resize! :y ::events/set-top-h 12 88 %)}] ;; timeline region [:div.timeline-pane [zoom-controls]