feat: allow drag reordering marks, allow reparenting, allow removing endpoints of mark

This commit is contained in:
Your Name 2026-07-02 10:05:36 -04:00
parent 0c5456d6df
commit 7f3d903452
4 changed files with 238 additions and 34 deletions

View file

@ -13,6 +13,7 @@
:root { :root {
--paper:#fff; --ink:#000; --gray:#808080; --mute:#555; --paper:#fff; --ink:#000; --gray:#808080; --mute:#555;
--shade:#eee; --hover:#ddd; /* subtle fills (code/err bg, button hover) */ --shade:#eee; --hover:#ddd; /* subtle fills (code/err bg, button hover) */
--pick-a:#00d5ff; --pick-b:#ff2e9a; /* picker-mode + drop-target glow (cyan↔magenta) */
--chicago:"Chicago","ChicagoFLF","Charcoal","Geneva",system-ui,sans-serif; --chicago:"Chicago","ChicagoFLF","Charcoal","Geneva",system-ui,sans-serif;
--geneva:"Geneva","Verdana","Tahoma","Segoe UI",sans-serif; --geneva:"Geneva","Verdana","Tahoma","Segoe UI",sans-serif;
--mono:"Monaco","Courier New",monospace; --mono:"Monaco","Courier New",monospace;
@ -658,26 +659,69 @@ html.dark .timeline-head {
.toolbar .dark-toggle { margin-left: auto; } .toolbar .dark-toggle { margin-left: auto; }
.home-head-right { display: flex; align-items: center; gap: 8px; } .home-head-right { display: flex; align-items: center; gap: 8px; }
/* picker mode: pulse a black ring around everything pickable so it's obvious /* picker mode: pulse a bright colored glow around everything pickable so it's
what to click — a 1-bit "glow" (no blur, stays HyperCard). Clips + the frame obvious what to click. Keeps a crisp ink ring for structure, but the glow
readout pulse whenever a draft is open; annotations pulse while linking. */ swells and shifts hue (cyan↔magenta) for life. Clips + the frame readout glow
whenever a draft is open; annotation bars glow while linking. */
@keyframes hc-pulse { @keyframes hc-pulse {
0%, 100% { box-shadow: 0 0 0 1px var(--ink); } 0%,100% { box-shadow: 0 0 0 1px var(--ink), 0 0 6px 1px var(--pick-a); }
50% { box-shadow: 0 0 0 4px var(--ink); } 50% { box-shadow: 0 0 0 3px var(--ink), 0 0 18px 5px var(--pick-b); }
} }
.picking .clip.clickable, .picking .clip.clickable,
.picking .fr-clip.clickable, .picking .fr-clip.clickable,
.linking .ann-bar { .linking .ann-bar {
position: relative; z-index: 6; position: relative; z-index: 6;
animation: hc-pulse 0.9s ease-in-out infinite; animation: hc-pulse 1.1s ease-in-out infinite;
} }
.linking .ann-bar { cursor: pointer; } .linking .ann-bar { cursor: pointer; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.picking .clip.clickable, .picking .fr-clip.clickable, .linking .ann-bar { .picking .clip.clickable, .picking .fr-clip.clickable, .linking .ann-bar {
animation: none; box-shadow: 0 0 0 3px var(--ink); animation: none; box-shadow: 0 0 0 2px var(--ink), 0 0 10px 2px var(--pick-a);
} }
} }
/* --- drag an annotation to reparent it ---------------------------------- */
/* Every valid drop target glows while a card is being dragged; the one under
the cursor fills. The dragged card itself dims. */
@keyframes hc-drop {
0%,100% { box-shadow: 0 0 0 1px var(--pick-a); }
50% { box-shadow: 0 0 0 2px var(--pick-a), 0 0 12px 2px var(--pick-a); }
}
.ann[draggable="true"] { cursor: grab; }
.ann[draggable="true"]:active { cursor: grabbing; }
.ann.drop-ready { animation: hc-drop 1s ease-in-out infinite; }
.ann.drop-over {
animation: none;
background: color-mix(in srgb, var(--pick-a) 20%, var(--paper));
box-shadow: 0 0 0 2px var(--pick-b), 0 0 16px 3px var(--pick-b);
}
.crumb.drop-ready { animation: hc-drop 1s ease-in-out infinite; }
.crumb.drop-over {
background: color-mix(in srgb, var(--pick-a) 28%, var(--paper));
box-shadow: 0 0 0 2px var(--pick-b), 0 0 12px 2px var(--pick-b);
}
@media (prefers-reduced-motion: reduce) {
.ann.drop-ready, .crumb.drop-ready { animation: none; box-shadow: 0 0 0 2px var(--pick-a); }
}
/* --- re-pick one mark endpoint / drag-reorder marks --------------------- */
.pt-chip-x {
border: none; background: none; color: var(--mute); cursor: pointer;
font-size: 10px; padding: 0 2px; line-height: 1; flex: none;
}
.pt-chip-x:hover { color: var(--ink); }
.mark-grip {
cursor: grab; user-select: none; color: var(--mute);
padding: 0 2px; font-size: 13px; line-height: 1; flex: none; align-self: center;
}
.mark-grip:active { cursor: grabbing; }
.mark-block.dragging { opacity: 0.4; }
.mark-block.drop-before { position: relative; }
.mark-block.drop-before::before {
content: ""; position: absolute; left: 0; right: 0; top: -3px; height: 2px;
background: var(--pick-b); box-shadow: 0 0 6px 1px var(--pick-b);
}
/* iOS Safari zooms the page when focusing a control with font-size < 16px. /* iOS Safari zooms the page when focusing a control with font-size < 16px.
On touch devices bump every form control to 16px so tapping never zooms. */ On touch devices bump every form control to 16px so tapping never zooms. */
@media (pointer: coarse) { @media (pointer: coarse) {

View file

@ -606,6 +606,41 @@
id (assoc :http-xhrio (api/put-scene id {:deleted [gid]} id (assoc :http-xhrio (api/put-scene id {:deleted [gid]}
{:on-success [::scene-saved] {:on-success [::scene-saved]
:on-failure [::save-error]})))))) :on-failure [::save-error]}))))))
;; --- move an annotation into another context (drag-drop reparent) ---------
;; Changing :parent re-homes an annotation under a new context. Marks that no
;; longer resolve there just skip (scene/resolve drops them) and reappear if the
;; annotation is moved back — no data loss. Guard against cycles: never drop a
;; group into itself or one of its own descendants (that would make the :parent
;; chain loop forever, hanging path-to / resolve).
(defn- descendant?
"Is `gid` equal to `anc` or somewhere below it in the :parent tree?"
[scene anc gid]
(loop [g gid]
(cond (nil? g) false
(= g anc) true
:else (recur (get-in scene [:groups g :parent])))))
(rf/reg-event-db ::ann-drag-start (fn [db [_ gid]] (assoc-in db [:view :dragging-ann] gid)))
(rf/reg-event-db ::ann-drag-end (fn [db _] (assoc-in db [:view :dragging-ann] nil)))
(rf/reg-event-fx
::reparent
(fn [{:keys [db]} [_ gid new-parent]]
(let [scene (:scene db)
g (get-in scene [:groups gid])]
(if (or (not= :annotation (:type g)) ; only annotations move
(:draft g) ; not while being drafted/edited
(= (:parent g) new-parent) ; no-op: already there
(descendant? scene gid new-parent)) ; target inside gid → cycle
{:db (assoc-in db [:view :dragging-ann] nil)}
(let [id (get-in db [:project :id])]
(cond-> {:db (-> db (assoc-in [:scene :groups gid :parent] new-parent)
(assoc-in [:view :dragging-ann] nil)
(assoc :save-error nil))}
id (assoc :http-xhrio (api/put-scene id {:changed {gid {:parent new-parent}}}
{:on-success [::scene-saved]
:on-failure [::save-error]}))))))))
(rf/reg-event-db ::scene-saved (fn [db _] (assoc db :save-error nil))) (rf/reg-event-db ::scene-saved (fn [db _] (assoc db :save-error nil)))
(rf/reg-event-db ::save-error (rf/reg-event-db ::save-error
(fn [db [_ failure]] (fn [db [_ failure]]
@ -615,6 +650,22 @@
;; completes the span as a run of single-clip marks. `frame` (mark time) is ;; completes the span as a run of single-clip marks. `frame` (mark time) is
;; optional — clicking a clip uses whole-clip defaults (0 / clip end), clicking ;; optional — clicking a clip uses whole-clip defaults (0 / clip end), clicking
;; the video frame-readout passes the exact frame under the playhead. ;; the video frame-readout passes the exact frame under the playhead.
;; Unset one endpoint of an existing mark to re-pick it: seed the pending-point
;; state with the endpoint we're keeping, so the existing "one end set, click the
;; other" UI takes over. We stash the original mark (:mark) + its slot (:i) so the
;; rebuilt mark keeps its id — and thus its note/drawing bindings (see the repick
;; branch of ::draft-click-seg). selection->marks re-sorts by min/max, so it
;; doesn't matter that the kept end was the start or the end.
(rf/reg-event-db
::unset-endpoint
(fn [db [_ gid i which]]
(let [marks (get-in db [:scene :groups gid :marks])
mark (get marks i)
keep (if (= which :start) (:end mark) (:start mark))]
(-> db (assoc-in [:scene :groups gid :marks]
(into (subvec marks 0 i) (subvec marks (inc i))))
(assoc-in [:view :pt] {:seg (:ref keep) :f (:at keep) :mark mark :i i})))))
(rf/reg-event-db (rf/reg-event-db
::draft-click-seg ::draft-click-seg
(fn [db [_ seg-id frame]] (fn [db [_ seg-id frame]]
@ -625,7 +676,23 @@
(if (map? pt) (if (map? pt)
(let [a (scene/seg-local segs (:seg pt) (:f pt)) (let [a (scene/seg-local segs (:seg pt) (:f pt))
b (scene/seg-local segs seg-id (or frame (scene/seg-length segs seg-id))) b (scene/seg-local segs seg-id (or frame (scene/seg-length segs seg-id)))
run (scene/selection->marks scene (:parent g) (min a b) (max a b))] lo (min a b) hi (max a b)
(-> db (update-in [:scene :groups gid :marks] into run) old (:mark pt)]
(if old
;; re-picking an endpoint: reconcile-run keeps the mark id for the
;; piece on the kept clip; re-attach that mark's bindings, and splice
;; the run back into its original slot so order is preserved.
(let [run (->> (scene/reconcile-run scene (:parent g) [old] lo hi)
(mapv (fn [m] (if (= (:id m) (:id old))
(cond-> m
(:notes old) (assoc :notes (:notes old))
(:drawings old) (assoc :drawings (:drawings old)))
m))))
i (:i pt)]
(-> db (update-in [:scene :groups gid :marks]
#(into (into (subvec % 0 i) run) (subvec % i)))
(assoc-in [:view :pt] :new))) (assoc-in [:view :pt] :new)))
(let [run (scene/selection->marks scene (:parent g) lo hi)]
(-> db (update-in [:scene :groups gid :marks] into run)
(assoc-in [:view :pt] :new)))))
(assoc-in db [:view :pt] {:seg seg-id :f (or frame 0)}))))) (assoc-in db [:view :pt] {:seg seg-id :f (or frame 0)})))))

View file

@ -20,6 +20,7 @@
(rf/reg-sub ::hidden-notes (fn [db] (get-in db [:view :hidden-notes] #{}))) (rf/reg-sub ::hidden-notes (fn [db] (get-in db [:view :hidden-notes] #{})))
(rf/reg-sub ::region-focus (fn [db] (get-in db [:view :region-focus]))) (rf/reg-sub ::region-focus (fn [db] (get-in db [:view :region-focus])))
(rf/reg-sub ::linking (fn [db] (get-in db [:view :linking]))) (rf/reg-sub ::linking (fn [db] (get-in db [:view :linking])))
(rf/reg-sub ::dragging-ann (fn [db] (get-in db [:view :dragging-ann])))
(rf/reg-sub ::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)))

View file

@ -912,27 +912,61 @@
;; playhead crossing bars re-renders only the card(s) whose active state flipped — ;; playhead crossing bars re-renders only the card(s) whose active state flipped —
;; not the entire commentary list (the old code derefed the active SET in the ;; not the entire commentary list (the old code derefed the active SET in the
;; parent, forcing a full-list reconcile on every crossing). ;; parent, forcing a full-list reconcile on every crossing).
;; Is `t` among the drag's data types? `dataTransfer.types` is a plain JS array in
;; modern browsers (a DOMStringList in older ones) — Array.from normalises both so
;; the .includes never throws (a throw here would swallow the preventDefault the
;; drop depends on).
(defn- has-type? [e t]
(.includes (js/Array.from (.. e -dataTransfer -types)) t))
;; Drag-drop wiring shared by both card variants: an annotation is a drag source
;; (carries its gid) AND a drop target (drop another annotation onto it to make it
;; a child). `over?` is a local atom driving the drop-target highlight.
(defn- ann-drag-props [a authed? over?]
(when authed?
{:draggable true
:on-drag-start (fn [e]
(.. e -dataTransfer (setData "text/ann" (name (:id a))))
(set! (.. e -dataTransfer -effectAllowed) "move")
(rf/dispatch [::events/ann-drag-start (:id a)]))
:on-drag-end (fn [_] (reset! over? false) (rf/dispatch [::events/ann-drag-end]))
:on-drag-over (fn [e]
(when (has-type? e "text/ann")
(.preventDefault e) (reset! over? true)))
:on-drag-leave (fn [_] (reset! over? false))
:on-drop (fn [e]
(let [src (.. e -dataTransfer (getData "text/ann"))]
(when (seq src)
(.preventDefault e) (reset! over? false)
(rf/dispatch [::events/reparent (keyword src) (:id a)]))))}))
(defn- annotation-card [a scene ctx segs nmap authed? open] (defn- annotation-card [a scene ctx segs nmap authed? open]
(let [active? @(rf/subscribe [::subs/annotation-active? (:id a)])] (r/with-let [over? (r/atom false) hov? (r/atom false)]
(let [active? @(rf/subscribe [::subs/annotation-active? (:id a)])
dragging @(rf/subscribe [::subs/dragging-ann])
drop-ok? (and dragging (not= dragging (:id a)))
drag-props (merge (ann-drag-props a authed? over?)
{:class (str (when active? "active ")
(when (and drop-ok? @over?) "drop-over ")
(when drop-ok? "drop-ready"))})]
(if (:hidden a) (if (:hidden a)
(r/with-let [hov? (r/atom false)] [:div.ann (merge drag-props
[:div.ann {:id (str "ann-" (name (:id a))) {:id (str "ann-" (name (:id a)))
:class (when active? "active")
:style (merge {"--ann-color" (:color a)} :style (merge {"--ann-color" (:color a)}
{:opacity 0.4 :background "var(--desktop)" :background-size "2px 2px" {:opacity 0.4 :background "var(--desktop)" :background-size "2px 2px"
:min-height "24px" :display "flex" :align-items "center" :min-height "24px" :display "flex" :align-items "center"
:justify-content "space-between" :padding "2px 6px"}) :justify-content "space-between" :padding "2px 6px"})
:on-mouse-enter #(reset! hov? true) :on-mouse-enter #(reset! hov? true)
:on-mouse-leave #(reset! hov? false)} :on-mouse-leave #(reset! hov? false)})
[:span.ann-title {:style {:font-size 10}} [:span.ann-title {:style {:font-size 10}}
[:span.ann-swatch {:style {:background (:color a)}}] [:span.ann-swatch {:style {:background (:color a)}}]
(:name a)] (:name a)]
[:div {:style {:display "flex" :gap "2px" :visibility (if @hov? "visible" "hidden")}} [:div {:style {:display "flex" :gap "2px" :visibility (if @hov? "visible" "hidden")}}
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} "⤢"] [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} "⤢"]
[:button.edit-btn {:title "Edit" :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"]]]) [:button.edit-btn {:title "Edit" :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"]]]
[:div.ann {:id (str "ann-" (name (:id a))) [:div.ann (merge drag-props
:class (when active? "active") {:id (str "ann-" (name (:id a)))
:style {"--ann-color" (:color a)}} :style {"--ann-color" (:color a)}})
[:div.ann-head [:div.ann-head
[:div.ann-title [:div.ann-title
[:span.ann-swatch {:style {:background (:color a)}}] [:span.ann-swatch {:style {:background (:color a)}}]
@ -951,7 +985,7 @@
(when (seq (:notes a)) (when (seq (:notes a))
[:div.ann-notes [:div.ann-notes
(for [ng (:notes a) :let [n (nmap ng)] :when n] (for [ng (:notes a) :let [n (nmap ng)] :when n]
^{:key (name ng)} [note-live-chip ng n])])]))) ^{:key (name ng)} [note-live-chip ng n])])]))))
;; Renders nothing: owns the active-annotation subscription and scrolls the active ;; Renders nothing: owns the active-annotation subscription and scrolls the active
;; card into view, so `commentary` itself no longer re-renders on every crossing. ;; card into view, so `commentary` itself no longer re-renders on every crossing.
@ -993,14 +1027,17 @@
(let [n (js/parseInt v 10)] (-> (if (js/isNaN n) 0 n) (max 0) (min len)))) (let [n (js/parseInt v 10)] (-> (if (js/isNaN n) 0 n) (max 0) (min len))))
(defn- frame-chip (defn- frame-chip
"A filled endpoint: clip name + a mark-time frame input (edits `put` the group)." "A filled endpoint: clip name + a mark-time frame input (edits `put` the group).
The ✕ unsets just this endpoint so you can re-pick it (the other end is kept)."
[scene segs put d i k {:keys [seg f]}] [scene segs put d i k {:keys [seg f]}]
(let [len (scene/seg-length segs seg)] (let [len (scene/seg-length segs seg)]
[:div.pt-chip [:div.pt-chip
[:span.pt-chip-name (clip-label scene segs seg)] [:span.pt-chip-name (clip-label scene segs seg)]
[:input.pt-frame {:type "number" :min 0 :max len :value f [:input.pt-frame {:type "number" :min 0 :max len :value f
:on-change #(put (assoc-in d [:marks i k :at] (to-frame (.. % -target -value) len)))}] :on-change #(put (assoc-in d [:marks i k :at] (to-frame (.. % -target -value) len)))}]
[:span.pt-dur (str "/" len)]])) [:span.pt-dur (str "/" len)]
[:button.pt-chip-x {:type "button" :title "Re-pick this end"
:on-click #(rf/dispatch [::events/unset-endpoint (:gid d) i k])} "✕"]]))
;; --- script-note bindings (annotation form) ------------------------------ ;; --- script-note bindings (annotation form) ------------------------------
;; A note is bound by dragging its chip from the source list onto a drop target ;; A note is bound by dragging its chip from the source list onto a drop target
@ -1036,6 +1073,31 @@
[:button.row-x {:type "button" :title "Unbind" :on-click #(on-unbind ng)} "✕"]]) [:button.row-x {:type "button" :title "Unbind" :on-click #(on-unbind ng)} "✕"]])
[:span.note-drop-hint "drop a note"])]) [:span.note-drop-hint "drop a note"])])
;; drag-to-reorder marks: one live drag at a time, so a single module atom holds
;; {:src i :over j}. Deref'd in the form so the drop line follows the cursor.
(defonce ^:private mark-drag (r/atom nil))
(defn- vec-move [v si di]
(if (= si di) v
(let [x (nth v si)
without (vec (concat (subvec v 0 si) (subvec v (inc si))))
di* (if (< si di) (dec di) di)]
(vec (concat (subvec without 0 di*) [x] (subvec without di*))))))
;; DnD props for mark row `i`: a drag source (the ⠿ handle sets draggable) and a
;; drop target that moves the dragged mark to before this row on drop.
(defn- mark-drag-props [put d i]
{:on-drag-over (fn [e]
(when (:src @mark-drag)
(.preventDefault e)
(when (not= (:over @mark-drag) i) (swap! mark-drag assoc :over i))))
:on-drop (fn [e]
(.preventDefault e)
(when-let [si (:src @mark-drag)]
(put (update d :marks vec-move si i)))
(reset! mark-drag nil))
:on-drag-end (fn [_] (reset! mark-drag nil))})
(defn annotation-form [] (defn annotation-form []
(r/with-let [orig (dissoc @(rf/subscribe [::subs/draft-group]) :draft :gid)] (r/with-let [orig (dissoc @(rf/subscribe [::subs/draft-group]) :draft :gid)]
(let [d @(rf/subscribe [::subs/draft-group]) (let [d @(rf/subscribe [::subs/draft-group])
@ -1093,10 +1155,19 @@
(doall (doall
(for [[i row] (map-indexed vector rows) (for [[i row] (map-indexed vector rows)
:let [mark (get (:marks d) i) :let [mark (get (:marks d) i)
mark-id (:id mark)]] mark-id (:id mark)
drag @mark-drag]]
^{:key i} ^{:key i}
[:div.mark-block [:div.mark-block (merge (mark-drag-props put d i)
{:class (str (when (= i (:src drag)) "dragging ")
(when (and (:src drag) (= i (:over drag))
(not= i (:src drag))) "drop-before"))})
[:div.mark-row [:div.mark-row
[:span.mark-grip {:draggable true :title "Drag to reorder"
:on-drag-start (fn [e]
(.. e -dataTransfer (setData "text/mark-idx" (str i)))
(set! (.. e -dataTransfer -effectAllowed) "move")
(reset! mark-drag {:src i}))} "⠿"]
[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)]
@ -1153,18 +1224,39 @@
;; --- chrome --------------------------------------------------------------- ;; --- chrome ---------------------------------------------------------------
;; A breadcrumb is a drop target too: drop an annotation onto an ancestor context
;; (root or any timeline on the path) to move it up out of the current level.
(defn- crumb-drop-props [id over?]
{:on-drag-over (fn [e]
(when (has-type? e "text/ann")
(.preventDefault e) (reset! over? true)))
:on-drag-leave (fn [_] (reset! over? false))
:on-drop (fn [e]
(let [src (.. e -dataTransfer (getData "text/ann"))]
(when (seq src)
(.preventDefault e) (reset! over? false)
(rf/dispatch [::events/reparent (keyword src) id]))))})
(defn- crumb [i {:keys [id name]} last?]
(r/with-let [over? (r/atom false)]
(let [dragging @(rf/subscribe [::subs/dragging-ann])]
[:span.crumb-item
(when (pos? i) [:span.crumb-sep "›"])
[:button.crumb (merge (crumb-drop-props id over?)
{:class (str (when last? "current ")
(when dragging "drop-ready ")
(when (and dragging @over?) "drop-over"))
:disabled (and last? (not dragging))
:on-click #(rf/dispatch [::events/pop-to id])})
name]])))
(defn breadcrumbs [] (defn breadcrumbs []
(let [crumbs @(rf/subscribe [::subs/breadcrumbs])] (let [crumbs @(rf/subscribe [::subs/breadcrumbs])]
(when (> (count crumbs) 1) (when (> (count crumbs) 1)
[:div.crumbs [:div.crumbs
[:button.back-btn {:on-click #(rf/dispatch [::events/collapse])} "← back"] [:button.back-btn {:on-click #(rf/dispatch [::events/collapse])} "← back"]
(for [[i {:keys [id name]}] (map-indexed vector crumbs)] (for [[i c] (map-indexed vector crumbs)]
(let [last? (= i (dec (count crumbs)))] ^{:key (:id c)} [crumb i c (= i (dec (count crumbs)))])])))
^{:key id}
[:span.crumb-item
(when (pos? i) [:span.crumb-sep "›"])
[:button.crumb {:class (when last? "current") :disabled last?
:on-click #(rf/dispatch [::events/pop-to id])} name]]))])))
(defn toolbar [] (defn toolbar []
;; NB: subscribe the coarse ::at-start?/::at-end? edges, not the raw playhead — ;; NB: subscribe the coarse ::at-start?/::at-end? edges, not the raw playhead —
@ -1669,7 +1761,7 @@
[login-form]])])) [login-form]])]))
(defn create-page [] (defn create-page []
(let [pname (r/atom "") otio (atom nil) clip (atom nil)] (let [pname (r/atom "") otio (r/atom nil) clip (r/atom nil)]
(fn [] (fn []
(let [err @(rf/subscribe [::subs/create-error]) (let [err @(rf/subscribe [::subs/create-error])
prog @(rf/subscribe [::subs/create-progress]) prog @(rf/subscribe [::subs/create-progress])