feat: improve annotation mark editing

This commit is contained in:
Your Name 2026-07-05 18:10:44 -04:00
parent 7ac8f27b57
commit dfa92a478f
5 changed files with 382 additions and 180 deletions

View file

@ -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. */ 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, .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, .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); font-family: var(--chicago); background: var(--paper); color: var(--ink);
border: 1px solid var(--ink); border-radius: 8px; cursor: pointer; line-height: 1.3; 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, .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, .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, .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); background: var(--hover);
} }
.jump-btn:active, .add-btn:active, .edit-btn:active, .del-btn:active, .expand-btn:active, .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, .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, .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); background: var(--ink); color: var(--paper);
} }
@ -101,10 +101,13 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
background: none; cursor: pointer; } background: none; cursor: pointer; }
.draw-tools .draw-wid { width: 56px; } .draw-tools .draw-wid { width: 56px; }
.draw-done { font-weight: bold; } .draw-done { font-weight: bold; }
.mark-draw { background: none; border: 1px solid transparent; border-radius: 0; .mark-draw, .mark-script {
font-size: 12px; cursor: pointer; padding: 0 3px; line-height: 1; } width: 28px; height: 26px; flex: none;
.mark-draw:hover { border-color: var(--ink); } display: inline-flex; align-items: center; justify-content: center;
.mark-draw.has { border-color: var(--ink); background: var(--paper); } 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 { .frame-readout {
position: absolute; bottom: 6px; right: 8px; z-index: 4; position: absolute; bottom: 6px; right: 8px; z-index: 4;
@ -209,10 +212,13 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
.form { .form {
flex: 1; min-width: 0; overflow-y: auto; flex: 1; min-width: 0; overflow-y: auto;
background: var(--paper); border: 1px solid var(--ink); box-sizing: border-box; background: var(--paper); border: 1px solid var(--ink); box-sizing: border-box;
padding: 10px 12px; padding: 12px;
display: flex; flex-direction: column; gap: 10px; 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-row { display: flex; gap: 8px; align-items: center; }
.form-name { flex: 1; } .form-name { flex: 1; }
.form input[type=text], .form-name, .form-content { .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; .form-content { width: 100%; min-height: 70px; resize: vertical; box-sizing: border-box;
font-family: inherit; } font-family: inherit; }
.form-marks-label { font-family: var(--chicago); font-size: 11px; letter-spacing: .5px; .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 */ /* contenteditable content surface + inline link chips */
.content-editor { white-space: pre-wrap; word-break: break-word; outline: none; cursor: text; .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 .link-f { font-size: 11px; opacity: .7; }
.link-chip:hover .link-f { opacity: 1; } .link-chip:hover .link-f { opacity: 1; }
.mark-row { display: flex; flex-wrap: nowrap; align-items: center; gap: 6px; min-width: 0; } .mark-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
.mark-arrow { color: var(--ink); } .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; } .row-x, .add-mark { padding: 3px 8px; font-size: 12px; }
.add-mark { align-self: flex-start; } .add-mark { align-self: flex-start; }
@ -251,7 +260,7 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
/* point editor + autocomplete */ /* point editor + autocomplete */
.pt-input { position: relative; flex: 1 1 130px; display: flex; align-items: center; min-width: 0; } .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 { .pt-text {
flex: 1; min-width: 0; box-sizing: border-box; flex: 1; min-width: 0; box-sizing: border-box;
background: var(--paper); color: var(--ink); border: 1px solid var(--ink); 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 { display: flex; align-items: flex-start; gap: 6px; align-self: stretch; }
.link-insert .pt-input { flex: 1 1 auto; max-width: none; } .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; } .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; 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; .pt-chip-name { font-size: 12px; color: var(--ink); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; flex: 1; } overflow: hidden; text-overflow: ellipsis; flex: 1; }
.pt-frame { width: 56px; background: var(--paper); color: var(--ink); border: 1px solid var(--ink); .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; } .zoom-read { font-size: 11px; color: var(--ink); min-width: 42px; text-align: center; }
.script-empty { color: var(--mute); font-size: 12px; padding: 20px; } .script-empty { color: var(--mute); font-size: 12px; padding: 20px; }
.ann.selected { box-shadow: inset 3px 0 0 var(--ink); } .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 (list of project script-notes) */
.note-rail { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; .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); } 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) */ /* 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 /* the currently-selected/last-touched mark while authoring — so you know which
one a drawing binds to, and which you're about to delete */ 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)); .mark-block.active-mark {
border-radius: 2px; padding: 2px 0 2px 3px; margin-left: -3px; } 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 { opacity: 0.6; }
.mark-block.broken .pt-chip-name { text-decoration: line-through; } .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; .mark-note-list {
margin: 2px 0 2px 14px; padding: 2px 4px; border: 1px dashed var(--mute); border-radius: 0; } display: flex; flex-wrap: wrap; gap: 4px;
.note-drop-label { font-size: 10px; color: var(--mute); } margin: 5px 0 0 24px;
.note-drop-hint { font-size: 10px; color: var(--mute); font-style: italic; } }
.note-source { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0; } .mark-script-wrap { position: relative; display: inline-flex; flex: none; }
.note-src { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; cursor: grab; .mark-script-pop {
background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 0; padding: 1px 6px; } position: absolute; right: 0; top: calc(100% + 4px); z-index: 21;
.note-src:active { cursor: grabbing; } 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; .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.live { background: var(--ink); color: var(--paper); }
.bound-note-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bound-note-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-live { font-weight: bold; } .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; padding: 0 2px; font-size: 13px; line-height: 1; flex: none; align-self: center;
} }
.mark-grip:active { cursor: grabbing; } .mark-grip:active { cursor: grabbing; }
.mark-grip.disabled { cursor: default; opacity: .35; }
.mark-block.dragging { opacity: 0.4; } .mark-block.dragging { opacity: 0.4; }
.mark-block.drop-before { position: relative; } .mark-block.drop-before { position: relative; }
.mark-block.drop-before::before { .mark-block.drop-before::before {

View file

@ -378,6 +378,9 @@
(merge {:id rid :kind :text :content ""} region))] (merge {:id rid :kind :text :content ""} region))]
(merge {:db db} (persist-note-fx db gid))))) (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 ;; 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. ;; selected text) with the region already in it, and make it active.
(rf/reg-event-fx ::highlight-into-new-note (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 ;; 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 ;; 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. ;; 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 (rf/reg-event-db ::bind-note-annotation
(fn [db [_ gid note-gid]] (fn [db [_ gid note-gid]]
(update-in db [:scene :groups gid :notes] #(add-in % 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)))) (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) ;; click a mark row to make it the active one (drawings/edits target it)
(rf/reg-event-db ::set-active-mark (defn- drawing-state-for [db ann mark-id]
(fn [db [_ mark-id]] (assoc-in db [:view :active-mark] 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 ;; "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 ;; 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 (rf/reg-event-db ::new-note-for-mark
(fn [db [_ gid mark-id]] (fn [db [_ gid mark-id]]
(-> db (assoc-in [:view :note-target] {:gid gid :mark-id 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)))) (assoc-in [:view :pane] :script))))
(rf/reg-event-db ::cancel-note-target (rf/reg-event-db ::cancel-note-target
(fn [db _] (-> db (assoc-in [:view :note-target] nil) (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. ;; gid. The entity isn't written until ::save-drawing, so cancel is a clean no-op.
(rf/reg-event-db ::start-drawing (rf/reg-event-db ::start-drawing
(fn [db [_ ann mark-id]] (fn [db [_ ann mark-id]]
(let [existing (first (some (fn [m] (when (= mark-id (:id m)) (:drawings m))) (start-drawing-db db ann mark-id)))
(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)})))))
;; leave draw mode. Strokes autocommit as you draw, so this is just "done" — ;; 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). ;; 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 (rf/reg-event-fx ::set-playhead
(fn [{:keys [db]} [_ ctx lf]] (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)))) (sync-route {:db next-db} next-db))))
(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)))
;; reveal an annotation's immediate children into the current timeline lane ;; reveal an annotation's immediate children into the current timeline lane

View file

@ -18,20 +18,22 @@
;; Two link kinds share the [label](scheme…) form: ;; Two link kinds share the [label](scheme…) form:
;; frame [label](mark:ref@at) jump the playhead to a spot ;; frame [label](mark:ref@at) jump the playhead to a spot
;; timeline [label](timeline:gid) push that timeline onto the stack ;; timeline [label](timeline:gid) push that timeline onto the stack
;; note [label](note:gid) jump to a script note
(def ^:private link-re (def ^:private link-re
"\\[([^\\]]*)\\]\\((?:mark:([^@)]+)@(-?\\d+)|timeline:([^)]+))\\)") "\\[([^\\]]*)\\]\\((?:mark:([^@)]+)@(-?\\d+)|timeline:([^)]+)|note:([^)]+))\\)")
(defn link-token (defn link-token
"The token for a link map: {:kind :frame :label :ref :at} (the default) or "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]}] [{:keys [kind label ref at]}]
(if (= kind :timeline) (case kind
(str "[" label "](timeline:" (name ref) ")") :timeline (str "[" label "](timeline:" (name ref) ")")
:script-note (str "[" label "](note:" (name ref) ")")
(str "[" label "](mark:" (name ref) "@" at ")"))) (str "[" label "](mark:" (name ref) "@" at ")")))
(defn parse-content (defn parse-content
"Split content `s` into [:text str] / [:link {…}] segments. Each link carries "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] [s]
(if (empty? s) (if (empty? s)
[] []
@ -39,9 +41,10 @@
(loop [out [] last 0] (loop [out [] last 0]
(if-let [m (.exec re s)] (if-let [m (.exec re s)]
(let [idx (.-index m) pre (subs s last idx) (let [idx (.-index m) pre (subs s last idx)
link (if (aget m 4) link (cond
{:kind :timeline :label (aget m 1) :ref (keyword (aget m 4))} (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)) (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)})] :at (js/parseInt (aget m 3) 10)})]
(recur (cond-> out (recur (cond-> out
(seq pre) (conj [:text pre]) (seq pre) (conj [:text pre])
@ -57,8 +60,9 @@
(= 3 (.-nodeType n)) (.-textContent n) (= 3 (.-nodeType n)) (.-textContent n)
(= "BR" (.-tagName n)) "\n" (= "BR" (.-tagName n)) "\n"
(and (.-classList n) (.contains (.-classList n) "link-chip")) (and (.-classList n) (.contains (.-classList n) "link-chip"))
(link-token (if (= "timeline" (.. n -dataset -kind)) (link-token (case (.. n -dataset -kind)
{:kind :timeline :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref))} "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)) {:kind :frame :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref))
:at (js/parseInt (.. n -dataset -at) 10)})) :at (js/parseInt (.. n -dataset -at) 10)}))
;; A wrapper element (e.g. the <div> a browser inserts on Enter): recurse so ;; A wrapper element (e.g. the <div> a browser inserts on Enter): recurse so

View file

@ -22,6 +22,7 @@
;; :choosing (fresh draft — marks + title picker only) | :creating (full form). ;; :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 ::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 ::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-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 ::hidden-tags (fn [db] (get-in db [:view :hidden-tags] #{})))
(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])))

View file

@ -790,10 +790,10 @@
:border "1px solid rgba(78,143,194,0.85)" :border "1px solid rgba(78,143,194,0.85)"
:pointer-events "none" :z-index 5}}])]]]])))) :pointer-events "none" :z-index 5}}])]]]]))))
;; --- links: inline markdown chips that seek the timeline ------------------ ;; --- links: inline markdown chips -----------------------------------------
;; A link is a ref-point named in the content as `[label](mark:ref@at)` (see ;; Content links are named tokens like `[label](mark:ref@at)`,
;; tl.scene). The editor is a contenteditable surface where links live as atomic ;; `[label](timeline:gid)`, or `[label](note:gid)` (see tl.md). The editor is a
;; chips (delete with backspace, click to seek); everything else is plain text. ;; contenteditable surface where links live as atomic chips.
(defn- link-frame (defn- link-frame
"ctx-local frame for a chip's {:ref :at}, resolved against the live scene." "ctx-local frame for a chip's {:ref :at}, resolved against the live scene."
@ -804,18 +804,30 @@
(defn- goto-link! [local] (defn- goto-link! [local]
(when local (goto! local true))) (when local (goto! local true)))
(defn- note-link-ok? [scene ref]
(= :script-note (get-in scene [:groups ref :type])))
(defn content-display (defn content-display
"Read-only render of annotation `content`: markdown blocks (headings, lists, "Read-only render of annotation `content`: markdown blocks (headings, lists,
paragraphs) with inline formatting plus clickable link chips." paragraphs) with inline formatting plus clickable link chips."
[scene ctx content] [scene ctx content]
(md/render content (md/render content
(fn [{:keys [kind label ref at]}] (fn [{:keys [kind label ref at]}]
(if (= :timeline kind) (case kind
:timeline
(let [path (scene/path-to scene ref)] (let [path (scene/path-to scene ref)]
[:span.link-chip {:class (when-not path "broken") [:span.link-chip {:class (when-not path "broken")
:title (when-not path "timeline no longer here") :title (when-not path "timeline no longer here")
:on-click #(when path (rf/dispatch [::events/open-stack path]))} :on-click #(when path (rf/dispatch [::events/open-stack path]))}
"⤢ " label]) "⤢ " 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})] (let [lf (scene/link-local scene ctx {:ref ref :at at})]
[:span.link-chip {:class (when-not lf "broken") [:span.link-chip {:class (when-not lf "broken")
:title (when-not lf "linked clip no longer in this timeline") :title (when-not lf "linked clip no longer in this timeline")
@ -827,18 +839,29 @@
(let [span (js/document.createElement "span") (let [span (js/document.createElement "span")
scene @(rf/subscribe [::subs/scene]) scene @(rf/subscribe [::subs/scene])
timeline? (= :timeline kind) 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})) (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! (.-className span) (if ok "link-chip" "link-chip broken"))
(set! (.-contentEditable span) "false") (set! (.-contentEditable span) "false")
(when-not ok (set! (.-title span) (if timeline? "timeline no longer here" (when-not ok (set! (.-title span) (cond
"linked clip no longer in this timeline"))) timeline? "timeline no longer here"
(set! (.-textContent span) (str (cond timeline? "⤢ " (not ok) "△ ") label)) 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) "label" label)
(aset (.-dataset span) "ref" (name ref)) (aset (.-dataset span) "ref" (name ref))
(when kind (aset (.-dataset span) "kind" (name kind))) (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 (when lf
(let [f (js/document.createElement "span")] (let [f (js/document.createElement "span")]
(set! (.-className f) "link-f") (set! (.-className f) "link-f")
@ -891,10 +914,16 @@
(.removeAllRanges s) (.addRange s r)))) (.removeAllRanges s) (.addRange s r))))
:on-input emit :on-key-up save! :on-mouse-up save! :on-blur save! :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")] :on-click (fn [e] (when-let [chip (.closest (.-target e) ".link-chip")]
(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))] (when-let [lf (link-frame (.. chip -dataset -ref) (.. chip -dataset -at))]
(goto-link! lf))))}])) (goto-link! lf)))))}]))
(defn- point-candidates [scene ctx q timelines?] (defn- point-candidates [scene ctx notes q timelines?]
(let [needle (str/lower-case q) (let [needle (str/lower-case q)
n (when (re-matches #"-?\d+" q) (js/parseInt q 10))] n (when (re-matches #"-?\d+" q) (js/parseInt q 10))]
(vec (vec
@ -915,7 +944,13 @@
(keep (fn [{:keys [gid name in path]}] (keep (fn [{:keys [gid name in path]}]
(when (str/includes? (str/lower-case (str "timeline " name " " in)) needle) (when (str/includes? (str/lower-case (str "timeline " name " " in)) needle)
{:label name :kind :timeline :group (when in (str "in " in)) {: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] (defn- point-label [cand off local]
(str (:label cand) " @" (js/Math.round local) "f" (str (:label cand) " @" (js/Math.round local) "f"
@ -923,7 +958,7 @@
(str " +" off)))) (str " +" off))))
(defn- pick-point [cand 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) (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))))) (let [o (if (:abs cand) 0 (min (max 0 off) (max 0 (dec (or (:len cand) 1)))))
local (+ (:local cand) o)] local (+ (:local cand) o)]
@ -945,7 +980,8 @@
;; start CLOSED; only opens on focus (an auto-focused picker opens ;; start CLOSED; only opens on focus (an auto-focused picker opens
;; itself via :on-focus). Was defaulting open even when unfocused. ;; itself via :on-focus). Was defaulting open even when unfocused.
open? (r/atom (boolean auto-focus?))] 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)))) i (min @idx (max 0 (dec (count cands))))
cur (or (:candidate @picked) (get cands i)) cur (or (:candidate @picked) (get cands i))
maxo (max 0 (dec (or (:len cur) 1))) maxo (max 0 (dec (or (:len cur) 1)))
@ -956,6 +992,7 @@
(cond (cond
(not timelines?) (goto! (:local (pick-point cand o2))) (not timelines?) (goto! (:local (pick-point cand o2)))
(= :timeline (:kind cand)) (rf/dispatch [::events/open-stack (:path cand)]) (= :timeline (:kind cand)) (rf/dispatch [::events/open-stack (:path cand)])
(= :script-note (:kind cand)) nil
:else (rf/dispatch [::events/preview-frame :else (rf/dispatch [::events/preview-frame
(:local (pick-point cand o2))])))) (:local (pick-point cand o2))]))))
emit! (fn [cand o2] emit! (fn [cand o2]
@ -989,7 +1026,7 @@
(reset! idx 0) (reset! idx 0)
(reset! off 0) (reset! off 0)
(reset! open? true) (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 :on-key-down
(fn [e] (fn [e]
(case (.-key e) (case (.-key e)
@ -1022,10 +1059,13 @@
:on-mouse-down #(.preventDefault %) :on-mouse-down #(.preventDefault %)
:on-mouse-enter #(go! j) :on-mouse-enter #(go! j)
:on-click #(emit! c 0)} :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)] [:span.cand-label (:label c)]
(when (:group c) [:span.cand-group (str " " (:group 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")])])))]))) [:span.link-f (str " " (js/Math.round (:local c)) "f")])])))])))
(defn- local->draft-point [segs local] (defn- local->draft-point [segs local]
@ -1093,14 +1133,17 @@
(defn- link-picker [{:keys [scene ctx on-commit on-cancel]}] (defn- link-picker [{:keys [scene ctx on-commit on-cancel]}]
(r/with-let [picked (r/atom nil)] (r/with-let [picked (r/atom nil)]
[:div.link-insert [: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 [point-picker {:scene scene :ctx ctx :value @picked :auto-focus? true :timelines? true
:placeholder "clip / annotation / timeline / note..."
:on-pick #(reset! picked %) :on-pick #(reset! picked %)
:on-cancel on-cancel}] :on-cancel on-cancel}]
[:button.hl-ok {:type "button" :title "Insert link" :disabled (nil? @picked) [:button.hl-ok {:type "button" :title "Insert link" :disabled (nil? @picked)
:on-click #(when @picked (on-commit @picked))} :on-click #(when @picked (on-commit @picked))}
"✓"] "✓"]
[:button.hl-cancel {:type "button" :title "Cancel" :on-click on-cancel} "✕"]])) [:button.hl-cancel {:type "button" :title "Cancel" :on-click on-cancel} "✕"]]]))
(defn- commit-link! (defn- commit-link!
"Insert a link chip for ref-point `pt` (label `label`) at the editor caret." "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)))}] :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" [: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 (defn- proxy-frame-chip
"Editable endpoint for a proxy (synthetic-clip) mark: clip name + frame input "Editable endpoint for a proxy (synthetic-clip) mark: clip name + frame input
@ -1301,41 +1346,89 @@
(to-frame (.. % -target -value) len)])}] (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" [: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) ------------------------------ ;; --- script-note bindings (annotation form) ------------------------------
;; A note is bound by dragging its chip from the source list onto a drop target ;; The pointer lives on the mark and rides the annotation's Save. Existing notes
;; (a mark row, or the annotation-level target). The pointer lives on the referrer ;; bind from a compact autocomplete; Add new jumps to the script pane and binds
;; and rides the annotation's Save. ;; the highlighted note when it is created.
(defn- note-src-chip (defn- mark-note-chip [ng n live on-unbind]
"Draggable source chip for note `n`; click also toggles an annotation-level bind." [:span.bound-note {:class (when (contains? live ng) "live")
[gid n] :title "Jump to this passage in the script"
[:span.note-src {:draggable true :on-click #(rf/dispatch [::events/jump-to-note ng])}
: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"}
[:span.ann-swatch {:style {:background (:color n)}}]
(:name n)])
(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 "»»» "]) (when (contains? live ng) [:span.note-live "»»» "])
[:span.ann-swatch {:style {:background (:color n)}}] [:span.ann-swatch {:style {:background (:color n)}}]
[:span.bound-note-name (:name n)] [:span.bound-note-name (:name n)]
[:button.row-x {:type "button" :title "Unbind" :on-click #(on-unbind ng)} "✕"]]) [:button.row-x {:type "button" :title "Unbind"
[:span.note-drop-hint "drop a note"])]) :on-click #(on-unbind ng)} "✕"]])
(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 ;; 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. ;; {: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))}) :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)
last-scrolled (atom nil)]
(let [d @(rf/subscribe [::subs/draft-group]) (let [d @(rf/subscribe [::subs/draft-group])
scene @(rf/subscribe [::subs/scene]) scene @(rf/subscribe [::subs/scene])
;; anchor the form to the draft's home context, not the live stack top: ;; 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) (rf/dispatch [::events/save-group gid (dissoc d :draft :gid)
(when-not new? orig)]) (when-not new? orig)])
(rf/dispatch [::events/finish-edit]))] (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))} [:form.form {:on-submit (fn [e] (.preventDefault e) (save))}
[:div.form-head (cond root? "Edit description" new? "New annotation" :else "Edit annotation")] [:div.form-head (cond root? "Edit description" new? "New annotation" :else "Edit annotation")]
(when (and (not root?) (not choosing?)) (when (and (not root?) (not choosing?))
@ -1401,36 +1501,7 @@
[: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)))}]]])
[: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."])])
(when-not root? (when-not root?
[:<> [:<>
;; the title IS the create/associate control: type a new title to make a ;; 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 (= mark-id active) "active-mark ")
(when (contains? broken mark-id) "broken ") (when (contains? broken mark-id) "broken ")
(when (and (:src drag) (= i (:over drag)) (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 [:div.mark-row
[:span.mark-grip {:draggable true :title "Drag to reorder" [:span.mark-grip {:draggable true :title "Drag to reorder"
:on-drag-start (fn [e] :on-drag-start (fn [e]
@ -1508,43 +1581,52 @@
(goto! s true)) (goto! s true))
(rf/dispatch [::events/start-drawing gid mark-id]))} (rf/dispatch [::events/start-drawing gid mark-id]))}
(if (seq (:drawings mark)) "🖼" "🖼+")] (if (seq (:drawings mark)) "🖼" "🖼+")]
(when-not choosing?
[mark-script-picker gid mark-id notes (:notes mark)])
[:button.row-x {:type "button" :title "Remove" [:button.row-x {:type "button" :title "Remove"
:on-click #(rf/dispatch [::events/remove-mark gid i])} "✕"]] :on-click #(rf/dispatch [::events/remove-mark gid i])} "✕"]]
(when-not choosing? (when-not choosing?
[note-drop nil (:notes mark) nmap live (when (seq (:notes mark))
#(rf/dispatch [::events/bind-note-mark gid mark-id %]) (into [:div.mark-note-list]
#(rf/dispatch [::events/unbind-note-mark gid mark-id %])])])) (for [ng (:notes mark) :let [n (nmap ng)] :when n]
(when (and (map? pt) (not (:proxy pt))) ^{:key (name ng)}
[:div.mark-row [mark-note-chip ng n live #(rf/dispatch [::events/unbind-note-mark gid mark-id %])]))))]))
[:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt)) (when (or (= pt :new) (and (map? pt) (not (:proxy pt))))
" @" (js/Math.round (:f pt)) "f")]] [pending-mark-block {:scene scene :ctx ctx :segs segs :pt pt :choosing? choosing?}])
[:span.mark-arrow "→"] [:div.form-hint "Drag across the timeline to select a range (click to move the playhead)."]])
[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-not choosing?
[:<> [:<>
[:div.form-marks-label "Script notes"] (when-not root?
[note-drop "Whole annotation" (:notes d) nmap live [:<>
#(rf/dispatch [::events/bind-note-annotation gid %]) [:div.form-marks-label "Tags"]
#(rf/dispatch [::events/unbind-note-annotation gid %])] (let [tags (vec (get-in d [:meta :tags]))]
(if (seq notes) [:div.tag-editor
[:div.note-source (into [:div.tag-list]
(for [n notes] ^{:key (name (:id n))} [note-src-chip gid n])] (for [t tags]
[:div.form-hint "No script notes yet — create them in the Script tab."]) ^{:key t} [tag-chip t #(put (assoc-in d [:meta :tags] (vec (remove #{%} tags))))]))
[:div.form-hint "Drag a note onto a mark (or the whole-annotation target). It shows »»» while the playhead is in range."]])]) [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 [:div.form-actions
(when-not choosing? (when-not choosing?
[:button.save {:type "submit" :disabled (not valid?)} "Save"]) [:button.save {:type "submit" :disabled (not valid?)} "Save"])
@ -1875,6 +1957,7 @@
live @(rf/subscribe [::subs/active-note-set]) live @(rf/subscribe [::subs/active-note-set])
hidden @(rf/subscribe [::subs/hidden-notes]) hidden @(rf/subscribe [::subs/hidden-notes])
authed? @(rf/subscribe [::subs/authed?]) authed? @(rf/subscribe [::subs/authed?])
note-target @(rf/subscribe [::subs/note-target])
script-err @(rf/subscribe [::subs/script-error]) script-err @(rf/subscribe [::subs/script-error])
scale (or @zoom 1) scale (or @zoom 1)
;; notes whose highlights are drawn on the page (eye toggle off) ;; notes whose highlights are drawn on the page (eye toggle off)
@ -1916,6 +1999,13 @@
(reset! scrolled live-note) (reset! scrolled live-note)
(scroll-to-region! rid)))))) (scroll-to-region! rid))))))
[:div.script-pane [: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 (if url
[note-rail notes active hidden authed?] [note-rail notes active hidden authed?]
[:div.script-hint [:div.script-hint
@ -1947,12 +2037,15 @@
[:button.sel-add {:style {:left (:bx @sel) :top (:by @sel)} [:button.sel-add {:style {:left (:bx @sel) :top (:by @sel)}
:on-mouse-down #(.preventDefault %) ; keep the selection alive :on-mouse-down #(.preventDefault %) ; keep the selection alive
:on-click (fn [] :on-click (fn []
(if active (if (and active (not note-target))
(rf/dispatch [::events/add-region-text active region]) (rf/dispatch [::events/add-region-text active region])
(rf/dispatch [::events/highlight-into-new-note region])) (rf/dispatch [::events/highlight-into-new-note region]))
(.removeAllRanges (.getSelection js/window)) (.removeAllRanges (.getSelection js/window))
(reset! sel nil))} (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 url [:div.script-empty "Loading script…"]))]
(when (and url active note) (when (and url active note)
[note-editor note active scroll-to-region!])] [note-editor note active scroll-to-region!])]
@ -1967,12 +2060,15 @@
(let [pane @(rf/subscribe [::subs/pane]) (let [pane @(rf/subscribe [::subs/pane])
draft @(rf/subscribe [::subs/draft-group]) draft @(rf/subscribe [::subs/draft-group])
draft? (some? draft) draft? (some? draft)
note-target @(rf/subscribe [::subs/note-target])
save-err @(rf/subscribe [::subs/save-error])] save-err @(rf/subscribe [::subs/save-error])]
[:div.pane-wrap [:div.pane-wrap
(when save-err [:div.err {:style {:padding "4px 8px"}} "△ " save-err]) (when save-err [:div.err {:style {:padding "4px 8px"}} "△ " save-err])
[:div.pane-tabs [:div.pane-tabs
[:button {:class (when (= pane :annotations) "active") [: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") [:button {:class (when (= pane :script) "active")
:on-click #(rf/dispatch [::events/set-pane :script])} "Script"]] :on-click #(rf/dispatch [::events/set-pane :script])} "Script"]]
[:div.pane-body [:div.pane-body