From dfa92a478fc922fb50638d9f190e32a78a58a785 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 5 Jul 2026 18:10:44 -0400 Subject: [PATCH] feat: improve annotation mark editing --- tl/resources/public/css/app.css | 103 +++++++--- tl/src/tl/events.cljs | 82 ++++++-- tl/src/tl/md.cljs | 26 ++- tl/src/tl/subs.cljs | 1 + tl/src/tl/views.cljs | 350 ++++++++++++++++++++------------ 5 files changed, 382 insertions(+), 180 deletions(-) diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index 8443a74..c9334ee 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -55,20 +55,20 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; press. The default action (.save) gets the heavy Mac "default button" ring. */ .jump-btn, .add-btn, .edit-btn, .del-btn, .expand-btn, .back-btn, .crumb, .play-btn, .step-btn, .row-x, .add-mark, .save, .cancel, .hl-ok, .hl-cancel, -.pane-tabs button, .track-toggle { +.pane-tabs button, .track-toggle, .mark-script, .mark-script-new { font-family: var(--chicago); background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 8px; cursor: pointer; line-height: 1.3; } .jump-btn:hover, .add-btn:hover, .edit-btn:hover, .del-btn:hover, .expand-btn:hover, .back-btn:hover, .crumb:hover, .play-btn:hover, .step-btn:hover, .row-x:hover, .add-mark:hover, .save:hover, .cancel:hover, .hl-ok:hover, .hl-cancel:hover, -.pane-tabs button:hover, .track-toggle:hover { +.pane-tabs button:hover, .track-toggle:hover, .mark-script:hover, .mark-script-new:hover { background: var(--hover); } .jump-btn:active, .add-btn:active, .edit-btn:active, .del-btn:active, .expand-btn:active, .back-btn:active, .crumb:active, .play-btn:active, .step-btn:active, .row-x:active, .add-mark:active, .save:active, .cancel:active, .hl-ok:active, .hl-cancel:active, -.pane-tabs button:active, .track-toggle:active { +.pane-tabs button:active, .track-toggle:active, .mark-script:active, .mark-script-new:active { background: var(--ink); color: var(--paper); } @@ -101,10 +101,13 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; background: none; cursor: pointer; } .draw-tools .draw-wid { width: 56px; } .draw-done { font-weight: bold; } -.mark-draw { background: none; border: 1px solid transparent; border-radius: 0; - font-size: 12px; cursor: pointer; padding: 0 3px; line-height: 1; } -.mark-draw:hover { border-color: var(--ink); } -.mark-draw.has { border-color: var(--ink); background: var(--paper); } +.mark-draw, .mark-script { + width: 28px; height: 26px; flex: none; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 0; font-size: 13px; cursor: pointer; padding: 0; line-height: 1; +} +.mark-draw { background: var(--paper); color: var(--ink); border: 1px solid var(--ink); } +.mark-draw.has, .mark-script.has { background: var(--desktop); background-size: 4px 4px; } .frame-readout { position: absolute; bottom: 6px; right: 8px; z-index: 4; @@ -209,10 +212,13 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; .form { flex: 1; min-width: 0; overflow-y: auto; background: var(--paper); border: 1px solid var(--ink); box-sizing: border-box; - padding: 10px 12px; - display: flex; flex-direction: column; gap: 10px; + padding: 12px; + display: flex; flex-direction: column; gap: 12px; +} +.form-head { + font-family: var(--chicago); font-size: 14px; color: var(--ink); + padding-bottom: 6px; border-bottom: 2px solid var(--ink); } -.form-head { font-family: var(--chicago); font-size: 14px; color: var(--ink); } .form-row { display: flex; gap: 8px; align-items: center; } .form-name { flex: 1; } .form input[type=text], .form-name, .form-content { @@ -224,7 +230,7 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; .form-content { width: 100%; min-height: 70px; resize: vertical; box-sizing: border-box; font-family: inherit; } .form-marks-label { font-family: var(--chicago); font-size: 11px; letter-spacing: .5px; - color: var(--ink); margin-top: 4px; } + color: var(--ink); margin-top: 2px; padding-top: 2px; } /* contenteditable content surface + inline link chips */ .content-editor { white-space: pre-wrap; word-break: break-word; outline: none; cursor: text; @@ -239,8 +245,11 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; .link-chip .link-f { font-size: 11px; opacity: .7; } .link-chip:hover .link-f { opacity: 1; } -.mark-row { display: flex; flex-wrap: nowrap; align-items: center; gap: 6px; min-width: 0; } -.mark-arrow { color: var(--ink); } +.mark-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; } +.mark-arrow { + color: var(--ink); flex: none; font-family: var(--chicago); + min-width: 16px; text-align: center; +} .row-x, .add-mark { padding: 3px 8px; font-size: 12px; } .add-mark { align-self: flex-start; } @@ -251,7 +260,7 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; /* point editor + autocomplete */ .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; } +.mark-row .pt-input { flex: 1 1 128px; max-width: 190px; } .pt-text { flex: 1; min-width: 0; box-sizing: border-box; background: var(--paper); color: var(--ink); border: 1px solid var(--ink); @@ -277,11 +286,18 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 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; } +.link-insert-block .pt-input { flex: 1 1 220px; max-width: none; } .hl-ok, .hl-cancel { padding: 2px 9px; font-size: 12px; } -.pt-chip { flex: 1 1 0; min-width: 0; display: flex; align-items: center; gap: 4px; +.pt-chip { flex: 1 1 128px; min-width: 0; display: flex; align-items: center; gap: 4px; background: var(--paper); border: 1px solid var(--ink); border-radius: 0; - padding: 2px 4px; } + padding: 3px 5px; min-height: 20px; box-shadow: inset -1px -1px 0 var(--shade); } +.pt-chip.empty { + border-style: dashed; color: var(--mute); background: var(--shade); + box-shadow: none; +} +.pt-chip.pending { background: var(--paper); } +.pt-chip.pending .pt-frame { color: var(--ink); opacity: 1; } .pt-chip-name { font-size: 12px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } .pt-frame { width: 56px; background: var(--paper); color: var(--ink); border: 1px solid var(--ink); @@ -658,6 +674,17 @@ html.dark .timeline-head { .zoom-read { font-size: 11px; color: var(--ink); min-width: 42px; text-align: center; } .script-empty { color: var(--mute); font-size: 12px; padding: 20px; } .ann.selected { box-shadow: inset 3px 0 0 var(--ink); } +.script-return { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 6px 10px; border-bottom: 2px solid var(--ink); + background: var(--desktop); background-size: 4px 4px; + font-family: var(--chicago); font-size: 11px; +} +.script-return .hl-cancel { border-radius: 0; padding: 2px 8px; } +.script-return-text { + display: inline-block; background: var(--paper); border: 1px solid var(--ink); + padding: 2px 6px; box-shadow: 1px 1px 0 var(--ink); +} /* note rail (list of project script-notes) */ .note-rail { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; @@ -695,23 +722,42 @@ html.dark .timeline-head { font-size: 11px; border: 1px solid var(--mute); border-radius: 0; padding: 3px; background: var(--paper); color: var(--ink); } /* binding notes to annotations/marks (annotation form + cards) */ -.mark-block { margin-bottom: 4px; } +.mark-block { + margin-bottom: 6px; padding: 5px 6px; + border: 1px solid var(--ink); background: var(--paper); + box-shadow: 2px 2px 0 var(--ink); + cursor: pointer; +} +.mark-block:hover { background: var(--shade); } +.mark-block.pending { cursor: default; } +.mark-block.pending:hover { background: var(--paper); } /* the currently-selected/last-touched mark while authoring — so you know which one a drawing binds to, and which you're about to delete */ -.mark-block.active-mark { box-shadow: inset 3px 0 0 var(--ink); background: var(--shade, rgba(128,128,128,.14)); - border-radius: 2px; padding: 2px 0 2px 3px; margin-left: -3px; } +.mark-block.active-mark { + background: var(--desktop); background-size: 4px 4px; + box-shadow: inset 4px 0 0 var(--ink), 2px 2px 0 var(--ink); +} .mark-block.broken { opacity: 0.6; } .mark-block.broken .pt-chip-name { text-decoration: line-through; } -.note-drop { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-height: 20px; - margin: 2px 0 2px 14px; padding: 2px 4px; border: 1px dashed var(--mute); border-radius: 0; } -.note-drop-label { font-size: 10px; color: var(--mute); } -.note-drop-hint { font-size: 10px; color: var(--mute); font-style: italic; } -.note-source { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0; } -.note-src { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; cursor: grab; - background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 0; padding: 1px 6px; } -.note-src:active { cursor: grabbing; } +.mark-note-list { + display: flex; flex-wrap: wrap; gap: 4px; + margin: 5px 0 0 24px; +} +.mark-script-wrap { position: relative; display: inline-flex; flex: none; } +.mark-script-pop { + position: absolute; right: 0; top: calc(100% + 4px); z-index: 21; + width: 210px; padding: 6px; + background: var(--paper); border: 1px solid var(--ink); box-shadow: 2px 2px 0 var(--ink); +} +.mark-script-pop .pt-input { display: block; max-width: none; } +.mark-script-pop .pt-dropdown { position: static; margin-top: 4px; box-shadow: none; } +.mark-script-new { + width: 100%; margin-top: 6px; padding: 5px 8px; + border-radius: 0; text-align: left; font-size: 11px; +} .bound-note { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; - background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 0; padding: 0 4px; } + background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 0; padding: 1px 4px; + cursor: pointer; } .bound-note.live { background: var(--ink); color: var(--paper); } .bound-note-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .note-live { font-weight: bold; } @@ -783,6 +829,7 @@ html.dark .timeline-head { padding: 0 2px; font-size: 13px; line-height: 1; flex: none; align-self: center; } .mark-grip:active { cursor: grabbing; } +.mark-grip.disabled { cursor: default; opacity: .35; } .mark-block.dragging { opacity: 0.4; } .mark-block.drop-before { position: relative; } .mark-block.drop-before::before { diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index d0f6700..7a0c9ac 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -378,6 +378,9 @@ (merge {:id rid :kind :text :content ""} region))] (merge {:db db} (persist-note-fx db gid))))) +(defn- add-in [coll x] (vec (distinct (conj (vec coll) x)))) +(defn- rm-in [coll x] (vec (remove #(= x %) coll))) + ;; select-then-highlight with no active note: spin up a note (named after the ;; selected text) with the region already in it, and make it active. (rf/reg-event-fx ::highlight-into-new-note @@ -421,9 +424,6 @@ ;; The pointer lives on the referrer (annotation or mark), so a shared note stays ;; pure and freely reusable. These mutate the draft in-place (db only) and ride ;; the annotation's Save, exactly like ::set-content and the marks editor. -(defn- add-in [coll x] (vec (distinct (conj (vec coll) x)))) -(defn- rm-in [coll x] (vec (remove #(= x %) coll))) - (rf/reg-event-db ::bind-note-annotation (fn [db [_ gid note-gid]] (update-in db [:scene :groups gid :notes] #(add-in % note-gid)))) @@ -443,8 +443,66 @@ (update-mark db gid mark-id #(update % :notes rm-in note-gid)))) ;; click a mark row to make it the active one (drawings/edits target it) -(rf/reg-event-db ::set-active-mark - (fn [db [_ mark-id]] (assoc-in db [:view :active-mark] mark-id))) +(defn- drawing-state-for [db ann mark-id] + (let [existing (first (some (fn [m] (when (= mark-id (:id m)) (:drawings m))) + (get-in db [:scene :groups ann :marks])))] + {:ann ann :mark-id mark-id + :gid (or existing (keyword (str "draw-" (random-uuid)))) + :new? (nil? existing)})) + +(defn- start-drawing-db [db ann mark-id] + (-> db + (assoc-in [:view :active-mark] mark-id) + (assoc-in [:view :draw] (drawing-state-for db ann mark-id)))) + +(defn- draft-in-ctx [db ctx] + (some (fn [[gid g]] + (when (and (:draft g) (= ctx (:parent g))) [gid g])) + (get-in db [:scene :groups]))) + +(defn- draft-mark-at [db ctx lf] + (when-let [[gid g] (draft-in-ctx db ctx)] + (let [scene (:scene db) + segs (scene/content-segments scene ctx)] + (some (fn [m] + (when (some (fn [[lo hi]] (and (<= lo lf) (< lf hi))) + (scene/mark-bars scene gid (:id m) segs)) + {:ann gid :mark-id (:id m)})) + (:marks g))))) + +(defn- sync-draft-mark-for-playhead [db ctx lf] + (if-let [{:keys [ann mark-id]} (draft-mark-at db ctx lf)] + (if (= mark-id (get-in db [:view :active-mark])) + db + (start-drawing-db db ann mark-id)) + (if (draft-in-ctx db ctx) + (-> db + (assoc-in [:view :active-mark] nil) + (assoc-in [:view :draw] nil)) + db))) + +(defn- mark-start-local [db ann mark-id] + (let [scene (:scene db) + ctx (:parent (get-in scene [:groups ann])) + segs (scene/content-segments scene ctx)] + (ffirst (scene/mark-bars scene ann mark-id segs)))) + +;; click/select a mark row: make it the drawing target and move the playhead to it. +(rf/reg-event-fx ::set-active-mark + (fn [{:keys [db]} [_ mark-id]] + (let [[ann _] (or (draft-in-ctx db (peek (get-in db [:view :stack]))) + (some (fn [[gid g]] (when (:draft g) [gid g])) + (get-in db [:scene :groups]))) + ctx (:parent (get-in db [:scene :groups ann])) + local (when ann (mark-start-local db ann mark-id)) + db (cond-> db + ann (start-drawing-db ann mark-id) + (and ctx local) (assoc-in [:view :playheads ctx] local)) + sf (when (and ctx local) + (scene/local->source + (scene/content-segments (:scene db) ctx) local))] + (cond-> (sync-route {:db db} db) + sf (assoc :player/seek (/ sf (:fps db))))))) ;; "add a new script note for this mark": remember the target mark + hop to the ;; Script pane. When a note is created there (highlight-into-new-note) it binds to @@ -452,6 +510,7 @@ (rf/reg-event-db ::new-note-for-mark (fn [db [_ gid mark-id]] (-> db (assoc-in [:view :note-target] {:gid gid :mark-id mark-id}) + (assoc-in [:view :active-note] nil) (assoc-in [:view :pane] :script)))) (rf/reg-event-db ::cancel-note-target (fn [db _] (-> db (assoc-in [:view :note-target] nil) @@ -468,14 +527,7 @@ ;; gid. The entity isn't written until ::save-drawing, so cancel is a clean no-op. (rf/reg-event-db ::start-drawing (fn [db [_ ann mark-id]] - (let [existing (first (some (fn [m] (when (= mark-id (:id m)) (:drawings m))) - (get-in db [:scene :groups ann :marks])))] - (-> db - (assoc-in [:view :active-mark] mark-id) ; drawing a mark makes it the active one - (assoc-in [:view :draw] - {:ann ann :mark-id mark-id - :gid (or existing (keyword (str "draw-" (random-uuid)))) - :new? (nil? existing)}))))) + (start-drawing-db db ann mark-id))) ;; leave draw mode. Strokes autocommit as you draw, so this is just "done" — ;; nothing to save or discard here (the form's Save/Cancel is the rollback net). @@ -553,7 +605,9 @@ (rf/reg-event-fx ::set-playhead (fn [{:keys [db]} [_ ctx lf]] - (let [next-db (assoc-in db [:view :playheads ctx] lf)] + (let [next-db (-> db + (assoc-in [:view :playheads ctx] lf) + (sync-draft-mark-for-playhead ctx lf))] (sync-route {:db next-db} next-db)))) (rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p))) ;; reveal an annotation's immediate children into the current timeline lane diff --git a/tl/src/tl/md.cljs b/tl/src/tl/md.cljs index 169b9e4..e9b4d6f 100644 --- a/tl/src/tl/md.cljs +++ b/tl/src/tl/md.cljs @@ -18,20 +18,22 @@ ;; Two link kinds share the [label](scheme…) form: ;; frame [label](mark:ref@at) jump the playhead to a spot ;; timeline [label](timeline:gid) push that timeline onto the stack +;; note [label](note:gid) jump to a script note (def ^:private link-re - "\\[([^\\]]*)\\]\\((?:mark:([^@)]+)@(-?\\d+)|timeline:([^)]+))\\)") + "\\[([^\\]]*)\\]\\((?:mark:([^@)]+)@(-?\\d+)|timeline:([^)]+)|note:([^)]+))\\)") (defn link-token "The token for a link map: {:kind :frame :label :ref :at} (the default) or - {:kind :timeline :label :ref}." + {:kind :timeline :label :ref} or {:kind :script-note :label :ref}." [{:keys [kind label ref at]}] - (if (= kind :timeline) - (str "[" label "](timeline:" (name ref) ")") + (case kind + :timeline (str "[" label "](timeline:" (name ref) ")") + :script-note (str "[" label "](note:" (name ref) ")") (str "[" label "](mark:" (name ref) "@" at ")"))) (defn parse-content "Split content `s` into [:text str] / [:link {…}] segments. Each link carries - :kind — :frame (with :ref/:at) or :timeline (with :ref)." + :kind — :frame (with :ref/:at), :timeline (with :ref), or :script-note." [s] (if (empty? s) [] @@ -39,10 +41,11 @@ (loop [out [] last 0] (if-let [m (.exec re s)] (let [idx (.-index m) pre (subs s last idx) - link (if (aget m 4) - {:kind :timeline :label (aget m 1) :ref (keyword (aget m 4))} - {:kind :frame :label (aget m 1) :ref (keyword (aget m 2)) - :at (js/parseInt (aget m 3) 10)})] + link (cond + (aget m 4) {:kind :timeline :label (aget m 1) :ref (keyword (aget m 4))} + (aget m 5) {:kind :script-note :label (aget m 1) :ref (keyword (aget m 5))} + :else {:kind :frame :label (aget m 1) :ref (keyword (aget m 2)) + :at (js/parseInt (aget m 3) 10)})] (recur (cond-> out (seq pre) (conj [:text pre]) :always (conj [:link link])) @@ -57,8 +60,9 @@ (= 3 (.-nodeType n)) (.-textContent n) (= "BR" (.-tagName n)) "\n" (and (.-classList n) (.contains (.-classList n) "link-chip")) - (link-token (if (= "timeline" (.. n -dataset -kind)) - {:kind :timeline :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref))} + (link-token (case (.. n -dataset -kind) + "timeline" {:kind :timeline :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref))} + "script-note" {:kind :script-note :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref))} {:kind :frame :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref)) :at (js/parseInt (.. n -dataset -at) 10)})) ;; A wrapper element (e.g. the
a browser inserts on Enter): recurse so diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 5153578..4e798e2 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -22,6 +22,7 @@ ;; :choosing (fresh draft — marks + title picker only) | :creating (full form). (rf/reg-sub ::draft-stage (fn [db] (get-in db [:view :draft-stage]))) (rf/reg-sub ::script-jump (fn [db] (get-in db [:view :script-jump]))) +(rf/reg-sub ::note-target (fn [db] (get-in db [:view :note-target]))) (rf/reg-sub ::hidden-notes (fn [db] (get-in db [:view :hidden-notes] #{}))) (rf/reg-sub ::hidden-tags (fn [db] (get-in db [:view :hidden-tags] #{}))) (rf/reg-sub ::region-focus (fn [db] (get-in db [:view :region-focus]))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 9000ff9..2cad17e 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -790,10 +790,10 @@ :border "1px solid rgba(78,143,194,0.85)" :pointer-events "none" :z-index 5}}])]]]])))) -;; --- 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. +;; --- links: inline markdown chips ----------------------------------------- +;; Content links are named tokens like `[label](mark:ref@at)`, +;; `[label](timeline:gid)`, or `[label](note:gid)` (see tl.md). The editor is a +;; contenteditable surface where links live as atomic chips. (defn- link-frame "ctx-local frame for a chip's {:ref :at}, resolved against the live scene." @@ -804,18 +804,30 @@ (defn- goto-link! [local] (when local (goto! local true))) +(defn- note-link-ok? [scene ref] + (= :script-note (get-in scene [:groups ref :type]))) + (defn content-display "Read-only render of annotation `content`: markdown blocks (headings, lists, paragraphs) with inline formatting plus clickable link chips." [scene ctx content] (md/render content (fn [{:keys [kind label ref at]}] - (if (= :timeline kind) + (case kind + :timeline (let [path (scene/path-to scene ref)] [:span.link-chip {:class (when-not path "broken") :title (when-not path "timeline no longer here") :on-click #(when path (rf/dispatch [::events/open-stack path]))} "⤢ " label]) + + :script-note + (let [ok? (note-link-ok? scene ref)] + [:span.link-chip {:class (when-not ok? "broken") + :title (when-not ok? "script note no longer here") + :on-click #(when ok? (rf/dispatch [::events/jump-to-note ref]))} + (if ok? "📄 " "△ ") label]) + (let [lf (scene/link-local scene ctx {:ref ref :at at})] [:span.link-chip {:class (when-not lf "broken") :title (when-not lf "linked clip no longer in this timeline") @@ -827,18 +839,29 @@ (let [span (js/document.createElement "span") scene @(rf/subscribe [::subs/scene]) timeline? (= :timeline kind) - lf (when-not timeline? + note? (= :script-note kind) + lf (when-not (or timeline? note?) (scene/link-local scene @(rf/subscribe [::subs/context]) {:ref ref :at at})) - ok (if timeline? (some? (scene/path-to scene ref)) lf)] + ok (cond + timeline? (some? (scene/path-to scene ref)) + note? (note-link-ok? scene ref) + :else lf)] (set! (.-className span) (if ok "link-chip" "link-chip broken")) (set! (.-contentEditable span) "false") - (when-not ok (set! (.-title span) (if timeline? "timeline no longer here" - "linked clip no longer in this timeline"))) - (set! (.-textContent span) (str (cond timeline? "⤢ " (not ok) "△ ") label)) + (when-not ok (set! (.-title span) (cond + timeline? "timeline no longer here" + note? "script note no longer here" + :else "linked clip no longer in this timeline"))) + (set! (.-textContent span) (str (cond + timeline? "⤢ " + note? "📄 " + (not ok) "△ " + :else "") + label)) (aset (.-dataset span) "label" label) (aset (.-dataset span) "ref" (name ref)) (when kind (aset (.-dataset span) "kind" (name kind))) - (when-not timeline? (aset (.-dataset span) "at" (str at))) + (when-not (or timeline? note?) (aset (.-dataset span) "at" (str at))) (when lf (let [f (js/document.createElement "span")] (set! (.-className f) "link-f") @@ -891,10 +914,16 @@ (.removeAllRanges s) (.addRange s r)))) :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-link! lf))))}])) + (case (.. chip -dataset -kind) + "timeline" (when-let [path (scene/path-to @(rf/subscribe [::subs/scene]) + (keyword (.. chip -dataset -ref)))] + (rf/dispatch [::events/open-stack path])) + "script-note" (rf/dispatch [::events/jump-to-note + (keyword (.. chip -dataset -ref))]) + (when-let [lf (link-frame (.. chip -dataset -ref) (.. chip -dataset -at))] + (goto-link! lf)))))}])) -(defn- point-candidates [scene ctx q timelines?] +(defn- point-candidates [scene ctx notes q timelines?] (let [needle (str/lower-case q) n (when (re-matches #"-?\d+" q) (js/parseInt q 10))] (vec @@ -915,7 +944,13 @@ (keep (fn [{:keys [gid name in path]}] (when (str/includes? (str/lower-case (str "timeline " name " " in)) needle) {:label name :kind :timeline :group (when in (str "in " in)) - :point {:kind :timeline :ref gid} :path path}))))))))) + :point {:kind :timeline :ref gid} :path path}))))) + (when timelines? + (->> notes + (keep (fn [{:keys [id name]}] + (when (str/includes? (str/lower-case (str "note script " name)) needle) + {:label name :kind :script-note :group "script note" + :point {:kind :script-note :ref id}}))))))))) (defn- point-label [cand off local] (str (:label cand) " @" (js/Math.round local) "f" @@ -923,7 +958,7 @@ (str " +" off)))) (defn- pick-point [cand off] - (if (= :timeline (:kind cand)) + (if (contains? #{:timeline :script-note} (:kind cand)) (assoc (:point cand) :label (:label cand) :local (:local cand) :candidate cand :offset 0) (let [o (if (:abs cand) 0 (min (max 0 off) (max 0 (dec (or (:len cand) 1))))) local (+ (:local cand) o)] @@ -945,7 +980,8 @@ ;; start CLOSED; only opens on focus (an auto-focused picker opens ;; itself via :on-focus). Was defaulting open even when unfocused. open? (r/atom (boolean auto-focus?))] - (let [cands (point-candidates scene ctx @text timelines?) + (let [notes @(rf/subscribe [::subs/notes]) + cands (point-candidates scene ctx notes @text timelines?) i (min @idx (max 0 (dec (count cands)))) cur (or (:candidate @picked) (get cands i)) maxo (max 0 (dec (or (:len cur) 1))) @@ -956,6 +992,7 @@ (cond (not timelines?) (goto! (:local (pick-point cand o2))) (= :timeline (:kind cand)) (rf/dispatch [::events/open-stack (:path cand)]) + (= :script-note (:kind cand)) nil :else (rf/dispatch [::events/preview-frame (:local (pick-point cand o2))])))) emit! (fn [cand o2] @@ -989,7 +1026,7 @@ (reset! idx 0) (reset! off 0) (reset! open? true) - (preview! (first (point-candidates scene ctx (.. % -target -value) timelines?)) 0)) + (preview! (first (point-candidates scene ctx notes (.. % -target -value) timelines?)) 0)) :on-key-down (fn [e] (case (.-key e) @@ -1022,10 +1059,13 @@ :on-mouse-down #(.preventDefault %) :on-mouse-enter #(go! j) :on-click #(emit! c 0)} - (when timelines? [:span.cand-kind (if (= :timeline (:kind c)) "⤢ " "↪ ")]) + (when timelines? [:span.cand-kind (case (:kind c) + :timeline "⤢ " + :script-note "📄 " + "↪ ")]) [:span.cand-label (:label c)] (when (:group c) [:span.cand-group (str " " (:group c))]) - (when-not (= :timeline (:kind c)) + (when-not (contains? #{:timeline :script-note} (:kind c)) [:span.link-f (str " " (js/Math.round (:local c)) "f")])])))]))) (defn- local->draft-point [segs local] @@ -1093,14 +1133,17 @@ (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 :timelines? 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} "✕"]])) + [:div.mark-block.pending.link-insert-block + [:div.mark-row + [:span.mark-grip.disabled {:title "Insert link"} "↪"] + [point-picker {:scene scene :ctx ctx :value @picked :auto-focus? true :timelines? true + :placeholder "clip / annotation / timeline / note..." + :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." @@ -1284,7 +1327,9 @@ :on-change #(put (assoc-in d [:marks i k :at] (to-frame (.. % -target -value) 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])} "✕"]])) + :on-click (fn [e] + (.stopPropagation e) + (rf/dispatch [::events/unset-endpoint (:gid d) i k]))} "✕"]])) (defn- proxy-frame-chip "Editable endpoint for a proxy (synthetic-clip) mark: clip name + frame input @@ -1301,41 +1346,89 @@ (to-frame (.. % -target -value) len)])}] [:span.pt-dur (str "/" len)] [:button.pt-chip-x {:type "button" :title "Re-pick this end" - :on-click #(rf/dispatch [::events/unset-proxy-endpoint gid mark-id pid which])} "✕"]])) + :on-click (fn [e] + (.stopPropagation e) + (rf/dispatch [::events/unset-proxy-endpoint gid mark-id pid which]))} "✕"]])) + +(defn- pending-frame-chip [scene segs {:keys [seg f]}] + (let [len (scene/seg-length segs seg)] + [:div.pt-chip.pending + [:span.pt-chip-name (clip-label scene segs seg)] + [:input.pt-frame {:type "number" :value (js/Math.round f) :disabled true}] + [:span.pt-dur (str "/" len)]])) + +(defn- empty-frame-chip [label] + [:div.pt-chip.empty + [:span.pt-chip-name label] + [:span.pt-dur "empty"]]) + +(defn- pending-mark-block [{:keys [scene ctx segs pt choosing?]}] + (let [picked? (map? pt)] + [:div.mark-block.pending + [:div.mark-row + [:span.mark-grip.disabled {:title "New mark"} "⠿"] + (if picked? + [pending-frame-chip scene segs pt] + [point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true + :placeholder "click start clip..." + :on-pick #(when-let [p (local->draft-point segs (:local %))] + (rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]) + [:span.mark-arrow "→"] + (if picked? + [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)])))}] + [empty-frame-chip "End point"]) + [:button.mark-draw {:type "button" :title "Finish the mark before drawing" :disabled true} "🖼+"] + (when-not choosing? + [:button.mark-script {:type "button" :title "Finish the mark before adding a script note" :disabled true} "📄+"]) + [:button.row-x {:type "button" :title "Finish or cancel from the timeline" :disabled true} "✕"]]])) ;; --- script-note bindings (annotation form) ------------------------------ -;; A note is bound by dragging its chip from the source list onto a drop target -;; (a mark row, or the annotation-level target). The pointer lives on the referrer -;; and rides the annotation's Save. +;; The pointer lives on the mark and rides the annotation's Save. Existing notes +;; bind from a compact autocomplete; Add new jumps to the script pane and binds +;; the highlighted note when it is created. -(defn- note-src-chip - "Draggable source chip for note `n`; click also toggles an annotation-level bind." - [gid n] - [:span.note-src {:draggable true - :on-drag-start (fn [e] (.. e -dataTransfer (setData "text/note" (name (:id n))))) - :on-click #(rf/dispatch [::events/bind-note-annotation gid (:id n)]) - :title "Drag onto a mark, or click to bind to the whole annotation"} +(defn- mark-note-chip [ng n live on-unbind] + [:span.bound-note {:class (when (contains? live ng) "live") + :title "Jump to this passage in the script" + :on-click #(rf/dispatch [::events/jump-to-note ng])} + (when (contains? live ng) [:span.note-live "»»» "]) [:span.ann-swatch {:style {:background (:color n)}}] - (:name n)]) + [:span.bound-note-name (:name n)] + [:button.row-x {:type "button" :title "Unbind" + :on-click #(on-unbind ng)} "✕"]]) -(defn- note-drop - "Drop zone rendering `bound` note-gids as removable chips; a dropped note calls - (on-bind note-gid), a chip's ✕ calls (on-unbind note-gid)." - [label bound nmap live on-bind on-unbind] - [:div.note-drop {:on-drag-over #(.preventDefault %) - :on-drop (fn [e] (.preventDefault e) - (let [g (.. e -dataTransfer (getData "text/note"))] - (when (seq g) (on-bind (keyword g)))))} - (when label [:span.note-drop-label label]) - (if (seq bound) - (for [ng bound :let [n (nmap ng)] :when n] - ^{:key (name ng)} - [:span.bound-note {:class (when (contains? live ng) "live")} - (when (contains? live ng) [:span.note-live "»»» "]) - [:span.ann-swatch {:style {:background (:color n)}}] - [:span.bound-note-name (:name n)] - [:button.row-x {:type "button" :title "Unbind" :on-click #(on-unbind ng)} "✕"]]) - [:span.note-drop-hint "drop a note"])]) +(defn- mark-script-picker [gid mark-id notes bound] + (r/with-let [open? (r/atom false)] + (let [bound? (set bound) + choices (remove #(contains? bound? (:id %)) notes) + by-name (into {} (map (juxt :name :id)) choices)] + [:span.mark-script-wrap + [:button.mark-script {:type "button" + :class (when (seq bound) "has") + :title "Attach script note" + :on-click #(swap! open? not)} + (if (seq bound) "📄" "📄+")] + (when @open? + [:div.mark-script-pop {:on-click #(.stopPropagation %)} + [autocomplete {:items (mapv :name choices) + :placeholder "script note…" + :auto-focus? true + :on-choose (fn [choice] + (when-let [note-gid (by-name choice)] + (rf/dispatch [::events/bind-note-mark gid mark-id note-gid]) + (reset! open? false)))}] + [:button.mark-script-new {:type "button" + :on-click (fn [] + (reset! open? false) + (rf/dispatch [::events/new-note-for-mark gid mark-id]))} + "+ Add new"]])]))) ;; 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. @@ -1363,7 +1456,8 @@ :on-drag-end (fn [_] (reset! mark-drag nil))}) (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) + last-scrolled (atom nil)] (let [d @(rf/subscribe [::subs/draft-group]) scene @(rf/subscribe [::subs/scene]) ;; anchor the form to the draft's home context, not the live stack top: @@ -1393,6 +1487,12 @@ (rf/dispatch [::events/save-group gid (dissoc d :draft :gid) (when-not new? orig)]) (rf/dispatch [::events/finish-edit]))] + (when (and active (not= active @last-scrolled)) + (reset! last-scrolled active) + (r/after-render + #(when-let [node (js/document.querySelector + (str "[data-mark-row='" active "']"))] + (.scrollIntoView node #js {:block "nearest" :behavior "smooth"})))) [:form.form {:on-submit (fn [e] (.preventDefault e) (save))} [:div.form-head (cond root? "Edit description" new? "New annotation" :else "Edit annotation")] (when (and (not root?) (not choosing?)) @@ -1401,36 +1501,7 @@ [: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)))}]] - [:label.form-check {:title "During playback, scroll each of this annotation's clips into view as the playhead reaches it"} - [:input {:type "checkbox" :checked (boolean (get-in d [:meta :scroll-to])) - :on-change #(put (assoc-in d [:meta :scroll-to] (.. % -target -checked)))}] - [:span "Follow clips during playback"]] - [:label.form-check {:title "Hide this annotation from the timeline lane (still usable in links)"} - [:input {:type "checkbox" :checked (boolean (get-in d [:meta :hidden])) - :on-change #(put (assoc-in d [:meta :hidden] (.. % -target -checked)))}] - [:span "Hide from timeline"]] - [:div.form-marks-label "Tags"] - (let [tags (vec (get-in d [:meta :tags]))] - [:div.tag-editor - (into [:div.tag-list] - (for [t tags] - ^{:key t} [tag-chip t #(put (assoc-in d [:meta :tags] (vec (remove #{%} tags))))])) - [autocomplete {:items (filterv (complement (set tags)) @(rf/subscribe [::subs/project-tags])) - :placeholder "add tag…" :allow-new? true - :on-choose #(put (assoc-in d [:meta :tags] (conj tags %)))}]])]) - (when-not choosing? - [:<> - [: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 :kind]) (: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."])]) + :on-change #(put (assoc d :color (.. % -target -value)))}]]]) (when-not root? [:<> ;; the title IS the create/associate control: type a new title to make a @@ -1463,7 +1534,9 @@ (when (= mark-id active) "active-mark ") (when (contains? broken mark-id) "broken ") (when (and (:src drag) (= i (:over drag)) - (not= i (:src drag))) "drop-before"))}) + (not= i (:src drag))) "drop-before")) + :data-mark-row mark-id + :on-click #(rf/dispatch [::events/set-active-mark mark-id])}) [:div.mark-row [:span.mark-grip {:draggable true :title "Drag to reorder" :on-drag-start (fn [e] @@ -1508,43 +1581,52 @@ (goto! s true)) (rf/dispatch [::events/start-drawing gid mark-id]))} (if (seq (:drawings mark)) "🖼" "🖼+")] + (when-not choosing? + [mark-script-picker gid mark-id notes (:notes mark)]) [:button.row-x {:type "button" :title "Remove" :on-click #(rf/dispatch [::events/remove-mark gid i])} "✕"]] (when-not choosing? - [note-drop nil (:notes mark) nmap live - #(rf/dispatch [::events/bind-note-mark gid mark-id %]) - #(rf/dispatch [::events/unbind-note-mark gid mark-id %])])])) - (when (and (map? pt) (not (:proxy pt))) - [:div.mark-row - [:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt)) - " @" (js/Math.round (: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 - [point-picker {:scene scene :ctx ctx :class "active" - :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 "Drag across the timeline to select a range (click to move the playhead)."] - (when-not choosing? + (when (seq (:notes mark)) + (into [:div.mark-note-list] + (for [ng (:notes mark) :let [n (nmap ng)] :when n] + ^{:key (name ng)} + [mark-note-chip ng n live #(rf/dispatch [::events/unbind-note-mark gid mark-id %])]))))])) + (when (or (= pt :new) (and (map? pt) (not (:proxy pt)))) + [pending-mark-block {:scene scene :ctx ctx :segs segs :pt pt :choosing? choosing?}]) + [:div.form-hint "Drag across the timeline to select a range (click to move the playhead)."]]) + (when-not choosing? + [:<> + (when-not root? [:<> - [:div.form-marks-label "Script notes"] - [note-drop "Whole annotation" (:notes d) nmap live - #(rf/dispatch [::events/bind-note-annotation gid %]) - #(rf/dispatch [::events/unbind-note-annotation gid %])] - (if (seq notes) - [:div.note-source - (for [n notes] ^{:key (name (:id n))} [note-src-chip gid n])] - [:div.form-hint "No script notes yet — create them in the Script tab."]) - [:div.form-hint "Drag a note onto a mark (or the whole-annotation target). It shows »»» while the playhead is in range."]])]) + [:div.form-marks-label "Tags"] + (let [tags (vec (get-in d [:meta :tags]))] + [:div.tag-editor + (into [:div.tag-list] + (for [t tags] + ^{:key t} [tag-chip t #(put (assoc-in d [:meta :tags] (vec (remove #{%} tags))))])) + [autocomplete {:items (filterv (complement (set tags)) @(rf/subscribe [::subs/project-tags])) + :placeholder "add tag…" :allow-new? true + :on-choose #(put (assoc-in d [:meta :tags] (conj tags %)))}]])]) + [: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 :kind]) (: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."]) + (when-not root? + [:<> + [:label.form-check {:title "During playback, scroll each of this annotation's clips into view as the playhead reaches it"} + [:input {:type "checkbox" :checked (boolean (get-in d [:meta :scroll-to])) + :on-change #(put (assoc-in d [:meta :scroll-to] (.. % -target -checked)))}] + [:span "Follow clips during playback"]] + [:label.form-check {:title "Hide this annotation from the timeline lane (still usable in links)"} + [:input {:type "checkbox" :checked (boolean (get-in d [:meta :hidden])) + :on-change #(put (assoc-in d [:meta :hidden] (.. % -target -checked)))}] + [:span "Hide from timeline"]]])]) [:div.form-actions (when-not choosing? [:button.save {:type "submit" :disabled (not valid?)} "Save"]) @@ -1875,6 +1957,7 @@ live @(rf/subscribe [::subs/active-note-set]) hidden @(rf/subscribe [::subs/hidden-notes]) authed? @(rf/subscribe [::subs/authed?]) + note-target @(rf/subscribe [::subs/note-target]) script-err @(rf/subscribe [::subs/script-error]) scale (or @zoom 1) ;; notes whose highlights are drawn on the page (eye toggle off) @@ -1916,6 +1999,13 @@ (reset! scrolled live-note) (scroll-to-region! rid)))))) [:div.script-pane + (when note-target + [:div.script-return + [:button.hl-cancel {:type "button" + :title "Back to annotation" + :on-click #(rf/dispatch [::events/cancel-note-target])} + "‹ Back"] + [:span.script-return-text "Highlight script text to make a note for this mark."]]) (if url [note-rail notes active hidden authed?] [:div.script-hint @@ -1947,12 +2037,15 @@ [:button.sel-add {:style {:left (:bx @sel) :top (:by @sel)} :on-mouse-down #(.preventDefault %) ; keep the selection alive :on-click (fn [] - (if active + (if (and active (not note-target)) (rf/dispatch [::events/add-region-text active region]) (rf/dispatch [::events/highlight-into-new-note region])) (.removeAllRanges (.getSelection js/window)) (reset! sel nil))} - (if active (str "+ Highlight → " (:name note)) "+ New note from selection")]))] + (cond + note-target "+ Create note for mark" + active (str "+ Highlight → " (:name note)) + :else "+ New note from selection")]))] (when url [:div.script-empty "Loading script…"]))] (when (and url active note) [note-editor note active scroll-to-region!])] @@ -1967,12 +2060,15 @@ (let [pane @(rf/subscribe [::subs/pane]) draft @(rf/subscribe [::subs/draft-group]) draft? (some? draft) + note-target @(rf/subscribe [::subs/note-target]) save-err @(rf/subscribe [::subs/save-error])] [:div.pane-wrap (when save-err [:div.err {:style {:padding "4px 8px"}} "△ " save-err]) [:div.pane-tabs [:button {:class (when (= pane :annotations) "active") - :on-click #(rf/dispatch [::events/set-pane :annotations])} "Annotations"] + :on-click #(rf/dispatch [(if note-target + ::events/cancel-note-target + ::events/set-pane) :annotations])} "Annotations"] [:button {:class (when (= pane :script) "active") :on-click #(rf/dispatch [::events/set-pane :script])} "Script"]] [:div.pane-body