feat: script notes — named PDF-region annotations bound to marks

Replace the old per-annotation :script rects with a first-class
:script-note entity (name, color, id'd regions), persisted/synced
through the same generic scene channel as every other group.

- Author regions by selecting text on the pdf.js text layer; a region
  stores char offsets + per-line rects + per-range commentary.
- Bind notes to an annotation or a specific mark (drag-drop); a bound
  note shows »»» while the playhead is within its span.
- Script pane: note rail with per-note eye toggles, fit-to-width + zoom
  controls, plain-highlighter rendering (multiply blend, no borders).
- Click a highlight to open + focus its note; click a note chip on an
  annotation card to jump to its first passage.
- Schema v2 migration drops the legacy :script rects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-01 16:34:29 -04:00
parent 89533a13cd
commit b4924aa992
5 changed files with 680 additions and 145 deletions

View file

@ -16,8 +16,8 @@
--chicago:"Chicago","ChicagoFLF","Charcoal","Geneva",system-ui,sans-serif; --chicago:"Chicago","ChicagoFLF","Charcoal","Geneva",system-ui,sans-serif;
--geneva:"Geneva","Verdana","Tahoma","Segoe UI",sans-serif; --geneva:"Geneva","Verdana","Tahoma","Segoe UI",sans-serif;
--mono:"Monaco","Courier New",monospace; --mono:"Monaco","Courier New",monospace;
/* 50% checkerboard — the classic Mac desktop dither */ /* 50% checkerboard — the classic Mac desktop dither (4x4px for better visibility) */
--desktop:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='2' height='2' fill='%23ffffff'/%3E%3Crect width='1' height='1' fill='%23000000'/%3E%3Crect x='1' y='1' width='1' height='1' fill='%23000000'/%3E%3C/svg%3E"); --desktop:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='4' height='4' fill='%23ffffff'/%3E%3Crect width='2' height='2' fill='%23000000'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23000000'/%3E%3C/svg%3E");
} }
/* dark mode: flip paper/ink (the 50% desktop dither reads the same either way). /* dark mode: flip paper/ink (the 50% desktop dither reads the same either way).
@ -27,13 +27,13 @@ html.dark {
} }
html, body, #app { height: 100%; margin: 0; } html, body, #app { height: 100%; margin: 0; }
body { overflow: hidden; background: var(--desktop); background-size: 2px 2px; body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
font-family: var(--geneva); color: var(--ink); -webkit-font-smoothing: none; } font-family: var(--geneva); color: var(--ink); -webkit-font-smoothing: none; }
/* classic Mac scrollbars: white track, dithered thumb, hard black edges */ /* classic Mac scrollbars: white track, dithered thumb, hard black edges */
::-webkit-scrollbar { width: 15px; height: 15px; } ::-webkit-scrollbar { width: 15px; height: 15px; }
::-webkit-scrollbar-track { background: var(--paper); border: 1px solid var(--ink); } ::-webkit-scrollbar-track { background: var(--paper); border: 1px solid var(--ink); }
::-webkit-scrollbar-thumb { background: var(--desktop); background-size: 2px 2px; ::-webkit-scrollbar-thumb { background: var(--desktop); background-size: 4px 4px;
border: 1px solid var(--ink); } border: 1px solid var(--ink); }
.app { .app {
@ -99,7 +99,7 @@ body { overflow: hidden; background: var(--desktop); background-size: 2px 2px;
.divider-v { flex-shrink: 0; width: 6px; cursor: col-resize; background: var(--ink); touch-action: none; } .divider-v { flex-shrink: 0; width: 6px; cursor: col-resize; background: var(--ink); touch-action: none; }
.divider-h { flex-shrink: 0; height: 6px; cursor: row-resize; background: var(--ink); touch-action: none; } .divider-h { flex-shrink: 0; height: 6px; cursor: row-resize; background: var(--ink); touch-action: none; }
.divider-v:hover, .divider-h:hover, .divider-v:active, .divider-h:active { .divider-v:hover, .divider-h:hover, .divider-v:active, .divider-h:active {
background: var(--desktop); background-size: 2px 2px; background: var(--desktop); background-size: 4px 4px;
} }
/* --- commentary --------------------------------------------------------- */ /* --- commentary --------------------------------------------------------- */
@ -155,6 +155,21 @@ body { overflow: hidden; background: var(--desktop); background-size: 2px 2px;
.ctx-content .muted { font-style: italic; } .ctx-content .muted { font-style: italic; }
.ctx-content .edit-btn { flex-shrink: 0; } .ctx-content .edit-btn { flex-shrink: 0; }
.commentary-head { margin-bottom: 8px; } .commentary-head { margin-bottom: 8px; }
.search-pane { padding: 6px 10px; margin-bottom: 8px; border-bottom: 1px solid var(--ink); }
.search-input { width: 100%; padding: 4px 6px; border: 1px solid var(--ink); border-radius: 0;
font-family: var(--geneva); font-size: 12px; background: var(--paper); color: var(--ink);
box-sizing: border-box; }
.search-input::placeholder { color: var(--mute); }
.hidden-anns-section { border-top: 2px solid var(--ink); margin-top: 12px; padding-top: 8px; }
.ann-hidden { border-radius: 0; border: 1px solid var(--mute);
background: var(--paper); overflow: hidden; }
.ann-hidden:hover button { visibility: visible; }
.hidden-anns { border-top: 2px solid var(--ink); margin-top: 12px; padding-top: 8px; }
.ann-hidden { border-radius: 0; border: 1px solid var(--mute); border-left: none;
background: var(--paper); margin-bottom: 4px; overflow: hidden; }
.ann-hidden:hover .hidden-buttons { visibility: visible; }
.hidden-buttons { display: flex; gap: 2px; visibility: hidden; }
.hidden-buttons button { padding: 1px 5px; font-size: 10px; flex-shrink: 0; }
.add-btn { width: 100%; padding: 6px 8px; font-size: 12px; box-shadow: 1px 1px 0 var(--ink); } .add-btn { width: 100%; padding: 6px 8px; font-size: 12px; box-shadow: 1px 1px 0 var(--ink); }
.ann-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .ann-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.edit-btn, .expand-btn, .del-btn { padding: 1px 6px; font-size: 11px; } .edit-btn, .expand-btn, .del-btn { padding: 1px 6px; font-size: 11px; }
@ -524,19 +539,94 @@ html.dark .timeline-head {
/* --- script pane (pdf) ------------------------------------------------- */ /* --- script pane (pdf) ------------------------------------------------- */
.script-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--paper); } .script-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--paper); }
.script-hint { padding: 6px 10px; font-size: 11px; color: var(--ink); border-bottom: 1px solid var(--ink); } .script-hint { padding: 6px 10px; font-size: 11px; color: var(--ink); border-bottom: 1px solid var(--ink); }
.script-pages { flex: 1; overflow: auto; padding: 12px; .script-body { flex: 1; min-height: 0; display: flex; }
background: var(--desktop); background-size: 2px 2px; .script-pages { position: relative; flex: 1; overflow: auto; padding: 12px;
background: var(--desktop); background-size: 4px 4px;
display: flex; flex-direction: column; align-items: center; gap: 12px; } display: flex; flex-direction: column; align-items: center; gap: 12px; }
.pdf-page { position: relative; box-shadow: 2px 2px 0 var(--ink); background: #fff; .pdf-page { position: relative; box-shadow: 2px 2px 0 var(--ink); background: #fff;
border: 1px solid var(--ink); } border: 1px solid var(--ink); }
.pdf-page.armed { cursor: crosshair; } .pdf-page.armed { cursor: crosshair; }
.pdf-page canvas { display: block; } .pdf-page canvas { display: block; }
.script-hl { position: absolute; border-radius: 0; cursor: pointer; } .script-hl { position: absolute; border-radius: 0; z-index: 1; pointer-events: none;
.script-hl.drafting { background: rgba(0,0,0,.12); border: 1.5px dashed var(--ink); pointer-events: none; } mix-blend-mode: multiply; }
/* selectable pdf.js text layer, laid transparently over the canvas */
.text-layer { position: absolute; inset: 0; overflow: hidden; line-height: 1; z-index: 2; }
.text-layer span { position: absolute; color: transparent; white-space: pre;
transform-origin: 0 0; cursor: text; }
.text-layer ::selection { background: rgba(60,120,200,.35); }
/* "add highlight" button anchored to the selection end, inside the scroll
content (position:absolute) so it scrolls with the pages */
.sel-add { position: absolute; z-index: 20; transform: translateY(6px);
font-family: var(--chicago); font-size: 11px; background: var(--ink); color: var(--paper);
border: 1px solid var(--ink); border-radius: 0; padding: 2px 8px; cursor: pointer;
box-shadow: 2px 2px 0 rgba(0,0,0,.4); white-space: nowrap; }
/* bottom zoom controls */
.script-controls { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
border-top: 1px solid var(--ink); background: var(--paper); }
.script-controls button { font-family: var(--chicago); font-size: 11px; min-width: 26px;
background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 0;
padding: 1px 6px; cursor: pointer; }
.script-controls button:hover { background: var(--ink); color: var(--paper); }
.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; }
.script-badge { margin-left: 6px; color: var(--ink); font-size: 11px; }
.ann.selected { box-shadow: inset 3px 0 0 var(--ink); } .ann.selected { box-shadow: inset 3px 0 0 var(--ink); }
/* note rail (list of project script-notes) */
.note-rail { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 6px 10px; border-bottom: 1px solid var(--ink); }
.note-rail-list { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; flex: 1; }
.note-chip-wrap { display: inline-flex; align-items: center; font-family: var(--chicago);
font-size: 11px; background: var(--paper); color: var(--ink); border: 1px solid var(--ink); }
.note-chip-wrap.active { background: var(--ink); color: var(--paper); }
.note-chip-wrap.active .note-chip-count { color: var(--paper); }
.note-chip-wrap.off { opacity: 0.45; }
.note-eye { background: none; border: none; border-right: 1px solid var(--ink);
padding: 1px 4px; cursor: pointer; font-size: 10px; line-height: 1; color: inherit; }
.note-chip { display: inline-flex; align-items: center; gap: 4px; font-family: var(--chicago);
font-size: 11px; background: none; color: inherit; border: none; padding: 1px 6px; cursor: pointer; }
.note-chip-count { font-size: 10px; color: var(--mute); }
.note-new { font-family: var(--chicago); font-size: 11px; background: var(--paper); color: var(--ink);
border: 1px solid var(--ink); border-radius: 0; padding: 1px 8px; cursor: pointer; }
.note-reupload { font-family: var(--chicago); font-size: 11px; background: var(--paper); color: var(--ink);
border: 1px solid var(--ink); border-radius: 0; padding: 1px 8px; cursor: pointer; }
.note-reupload:hover { background: var(--ink); color: var(--paper); }
/* active-note editor (regions + per-range commentary), beside the pages */
.note-editor { width: 260px; flex-shrink: 0; overflow: auto; border-left: 1px solid var(--ink);
background: var(--paper); display: flex; flex-direction: column; }
.note-editor-head { display: flex; align-items: center; gap: 6px; padding: 6px;
border-bottom: 1px solid var(--ink); }
.note-name { flex: 1; min-width: 0; font-family: var(--chicago); font-size: 12px;
border: 1px solid var(--ink); border-radius: 0; padding: 2px 4px; background: var(--paper); color: var(--ink); }
.note-hint { padding: 4px 8px; font-size: 10px; color: var(--mute); }
.region-row { border-bottom: 1px solid var(--mute); padding: 6px 8px; }
.region-row-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.region-jump { font-family: var(--chicago); font-size: 10px; background: none; color: var(--ink);
border: 1px solid var(--ink); border-radius: 0; padding: 0 4px; cursor: pointer; }
.region-note { width: 100%; box-sizing: border-box; min-height: 40px; resize: vertical;
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; }
.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; }
.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; }
.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; }
.ann-notes { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.ann-notes .bound-note { cursor: pointer; }
/* dark-mode toggle button */ /* dark-mode toggle button */
.dark-toggle { font-family: var(--chicago); background: var(--paper); color: var(--ink); .dark-toggle { font-family: var(--chicago); background: var(--paper); color: var(--ink);
border: 1px solid var(--ink); border-radius: 8px; padding: 2px 8px; border: 1px solid var(--ink); border-radius: 8px; padding: 2px 8px;

View file

@ -1,5 +1,6 @@
(ns tl.events (ns tl.events
(:require (:require
[clojure.string :as str]
[re-frame.core :as rf] [re-frame.core :as rf]
[day8.re-frame.http-fx] ; registers the :http-xhrio effect [day8.re-frame.http-fx] ; registers the :http-xhrio effect
[tl.api :as api] [tl.api :as api]
@ -270,37 +271,160 @@
:http-xhrio (api/upload (str "/api/projects/" id "/script/") fd :http-xhrio (api/upload (str "/api/projects/" id "/script/") fd
{:on-success [::script-uploaded] {:on-success [::script-uploaded]
:on-failure [::script-upload-error]})}))) :on-failure [::script-upload-error]})})))
;; bump :script-rev so the pane reloads even when the replacement keeps the same
;; URL (a same-named file) — it's the cache-buster the PDF loader keys on.
(rf/reg-event-db ::script-uploaded (rf/reg-event-db ::script-uploaded
(fn [db [_ detail]] (assoc db :project detail :script-error nil))) (fn [db [_ detail]] (-> db (assoc :project detail :script-error nil)
(update :script-rev (fnil inc 0)))))
(rf/reg-event-db ::script-upload-error (rf/reg-event-db ::script-upload-error
(fn [db [_ failure]] (fn [db [_ failure]]
(assoc db :script-error (api/error-message failure "Script upload failed.")))) (assoc db :script-error (api/error-message failure "Script upload failed."))))
;; Script highlighting is an edit-mode sub-task: "Add script annotation" arms it ;; --- script notes ---------------------------------------------------------
;; for the annotation being edited (the draft) and jumps to the Script tab; the ;; A script-note is a first-class entity in the scene pool (:type :script-note):
;; rects land on the draft's :script and ride its save. ✓ keeps, ✕ restores the ;; a named bag of PDF regions (each an id'd rect + per-range commentary), authored
;; snapshot taken on entry. (No card-click selection anymore.) ;; in the Script pane and referenced by id from annotations and their marks. Notes
(rf/reg-event-db ::start-highlighting ;; persist through the same generic put-scene channel as every other group; unlike
(fn [db [_ gid]] ;; annotations they have no draft/save dance, so each structural edit persists at
(-> db ;; once (commentary text persists on blur — see ::persist-note).
(assoc-in [:view :highlighting]
{:gid gid :snapshot (get-in db [:scene :groups gid :script])})
(assoc-in [:view :pane] :script))))
(rf/reg-event-db ::finish-highlighting
(fn [db _] (-> db (assoc-in [:view :highlighting] nil)
(assoc-in [:view :pane] :annotations))))
(rf/reg-event-db ::cancel-highlighting
(fn [db _]
(let [{:keys [gid snapshot]} (get-in db [:view :highlighting])]
(-> db
(assoc-in [:scene :groups gid :script] snapshot)
(assoc-in [:view :highlighting] nil)
(assoc-in [:view :pane] :annotations)))))
;; rects update the draft locally; the draft's save (put-group) persists them. (defn- persist-note-fx
(rf/reg-event-db ::add-script-rect "Whole-send note `gid` to the backend (notes are small; no diffing). No-op with
(fn [db [_ gid page rect]] no project id (dev / unauthed) — the note still lives in the local scene."
(update-in db [:scene :groups gid :script] (fnil conj []) {:page page :rect rect}))) [db gid]
(let [g (get-in db [:scene :groups gid])
id (get-in db [:project :id])]
(when (and g id)
{:http-xhrio (api/put-scene id {:changed {gid g}}
{:on-success [::scene-saved] :on-failure [::save-error]})})))
(rf/reg-event-fx ::persist-note
(fn [{:keys [db]} [_ gid]] (or (persist-note-fx db gid) {})))
(rf/reg-event-fx ::create-note
(fn [{:keys [db]} [_ nm]]
(let [gid (keyword (str "note-" (random-uuid)))
note {:type :script-note :name (or (not-empty nm) "New note")
:color "#c2864e" :regions []}
db (-> db (assoc-in [:scene :groups gid] note)
(assoc-in [:view :active-note] gid)
(assoc :save-error nil))]
(merge {:db db} (persist-note-fx db gid)))))
(rf/reg-event-db ::set-active-note
(fn [db [_ gid]] (assoc-in db [:view :active-note] gid)))
;; jump from an annotation's note chip to the passage: show the Script pane with
;; the note active and flag it for scroll. The script pane clears :script-jump
;; once it has scrolled the note's first region into view (retrying across the
;; async PDF load). Until region-level links exist, "jump" = first highlight.
(rf/reg-event-db ::jump-to-note
(fn [db [_ gid]]
(-> db (assoc-in [:view :pane] :script)
(assoc-in [:view :active-note] gid)
(assoc-in [:view :script-jump] gid))))
(rf/reg-event-db ::clear-script-jump
(fn [db _] (assoc-in db [:view :script-jump] nil)))
;; per-note highlight visibility (view-only, ephemeral): eye toggles in the rail
(rf/reg-event-db ::toggle-note-hidden
(fn [db [_ gid]]
(let [s (get-in db [:view :hidden-notes] #{})]
(assoc-in db [:view :hidden-notes] (if (contains? s gid) (disj s gid) (conj s gid))))))
;; click a highlight on the page → activate its note and focus that region's row
;; in the right pane
(rf/reg-event-db ::open-region
(fn [db [_ gid rid]]
(-> db (assoc-in [:view :active-note] gid)
(assoc-in [:view :region-focus] rid))))
(rf/reg-event-db ::clear-region-focus
(fn [db _] (assoc-in db [:view :region-focus] nil)))
(rf/reg-event-db ::rename-note
(fn [db [_ gid nm]] (assoc-in db [:scene :groups gid :name] nm)))
(rf/reg-event-db ::set-note-color
(fn [db [_ gid color]] (assoc-in db [:scene :groups gid :color] color)))
(rf/reg-event-fx ::delete-note
(fn [{:keys [db]} [_ gid]]
(let [id (get-in db [:project :id])
db (cond-> (-> db (update-in [:scene :groups] dissoc gid)
(assoc :save-error nil))
(= gid (get-in db [:view :active-note]))
(assoc-in [:view :active-note] nil))]
;; dangling bindings on annotations/marks are dropped at render
;; time (the notes sub filters to existing gids), so no cascade.
(cond-> {:db db}
id (assoc :http-xhrio (api/put-scene id {:deleted [gid]}
{:on-success [::scene-saved]
:on-failure [::save-error]}))))))
;; select text on a page (with a note active) adds a :text region to that note.
;; `region` carries {:page :start :end :text :rects} — :start/:end are character
;; offsets into the page text (identity + future links), :rects the per-line
;; normalized boxes we render. See tl.views/capture-selection.
(rf/reg-event-fx ::add-region-text
(fn [{:keys [db]} [_ gid region]]
(let [rid (keyword (str "r-" (random-uuid)))
db (update-in db [:scene :groups gid :regions] (fnil conj [])
(merge {:id rid :kind :text :content ""} region))]
(merge {:db db} (persist-note-fx db gid)))))
;; 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
(fn [{:keys [db]} [_ region]]
(let [gid (keyword (str "note-" (random-uuid)))
rid (keyword (str "r-" (random-uuid)))
t (str/trim (or (:text region) ""))
nm (cond (empty? t) "New note"
(> (count t) 32) (str (subs t 0 32) "…")
:else t)
note {:type :script-note :name nm :color "#c2864e"
:regions [(merge {:id rid :kind :text :content ""} region)]}
db (-> db (assoc-in [:scene :groups gid] note)
(assoc-in [:view :active-note] gid)
(assoc :save-error nil))]
(merge {:db db} (persist-note-fx db gid)))))
;; commentary edits update the note locally (on-change); persist on blur so we
;; don't PUT per keystroke.
(rf/reg-event-db ::set-region-content
(fn [db [_ gid rid content]]
(update-in db [:scene :groups gid :regions]
(fn [rs] (mapv #(if (= rid (:id %)) (assoc % :content content) %) rs)))))
(rf/reg-event-fx ::delete-region
(fn [{:keys [db]} [_ gid rid]]
(let [db (update-in db [:scene :groups gid :regions]
(fn [rs] (vec (remove #(= rid (:id %)) rs))))]
(merge {:db db} (persist-note-fx db gid)))))
;; --- binding notes to annotations / marks ---------------------------------
;; 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))))
(rf/reg-event-db ::unbind-note-annotation
(fn [db [_ gid note-gid]]
(update-in db [:scene :groups gid :notes] #(rm-in % note-gid))))
(defn- update-mark [db gid mark-id f]
(update-in db [:scene :groups gid :marks]
(fn [ms] (mapv (fn [m] (if (= mark-id (:id m)) (f m) m)) ms))))
(rf/reg-event-db ::bind-note-mark
(fn [db [_ gid mark-id note-gid]]
(update-mark db gid mark-id #(update % :notes add-in note-gid))))
(rf/reg-event-db ::unbind-note-mark
(fn [db [_ gid mark-id note-gid]]
(update-mark db gid mark-id #(update % :notes rm-in note-gid))))
;; content edits go through their own event (not put-group) so the contenteditable ;; content edits go through their own event (not put-group) so the contenteditable
;; surface can update :content without re-reading a possibly-stale draft map. ;; surface can update :content without re-reading a possibly-stale draft map.

View file

@ -255,18 +255,28 @@
(:id m) (update :id keyword) (:id m) (update :id keyword)
(get-in m [:start :ref]) (update-in [:start :ref] keyword) (get-in m [:start :ref]) (update-in [:start :ref] keyword)
(get-in m [:end :ref]) (update-in [:end :ref] keyword) (get-in m [:end :ref]) (update-in [:end :ref] keyword)
(:track m) (update :track keyword))) (:track m) (update :track keyword)
(:notes m) (update :notes #(mapv keyword %)))) ; bound script-note gids
(defn- restore-region
"A script-note region loses keyword-ness through JSON: re-keyword :id and :kind."
[r]
(cond-> r
(:id r) (update :id keyword)
(:kind r) (update :kind keyword)))
;; --- schema versioning ---------------------------------------------------- ;; --- schema versioning ----------------------------------------------------
;; Every stored annotation carries :v, its schema version. When the data model ;; Every stored annotation carries :v, its schema version. When the data model
;; changes, bump schema-version and add a transformer that upgrades the previous ;; changes, bump schema-version and add a transformer that upgrades the previous
;; version's shape to the new one; old annotations migrate forward on load. ;; version's shape to the new one; old annotations migrate forward on load.
(def schema-version 1) ; current annotation schema — bump on any model change (def schema-version 2) ; current annotation schema — bump on any model change
;; transformers[n] upgrades a schema-v(n) annotation to v(n+1) (and must set ;; transformers[n] upgrades a schema-v(n) annotation to v(n+1) (and must set
;; :v (n+1)). Empty at v1; e.g. {1 (fn [g] (-> g (rename-key …) (assoc :v 2)))}. ;; :v (n+1)). v1→v2 drops the old per-annotation :script rects — script passages
(def ^:private transformers {}) ;; are now first-class :script-note entities bound by id (see [[markgroup-model]]).
(def ^:private transformers
{1 (fn [g] (-> g (dissoc :script) (assoc :v 2)))})
(defn migrate (defn migrate
"Upgrade annotation `g` to the current schema-version by chaining transformers. "Upgrade annotation `g` to the current schema-version by chaining transformers.
@ -283,11 +293,13 @@
schema version, before merging into the (keyword-keyed) scene." schema version, before merging into the (keyword-keyed) scene."
[anns] [anns]
(into {} (map (fn [[gid g]] (into {} (map (fn [[gid g]]
[gid (-> g (let [g (-> g (update :type keyword) (update :parent keyword))]
(update :type keyword) [gid (if (= :script-note (:type g))
(update :parent keyword) (update g :regions #(mapv restore-region (or % [])))
(update :marks #(mapv restore-mark (or % []))) (-> g
migrate)])) (update :marks #(mapv restore-mark (or % [])))
(update :notes #(when % (mapv keyword %))) ; annotation-level bindings
migrate))])))
anns)) anns))
(defn marks->rows (defn marks->rows

View file

@ -1,5 +1,6 @@
(ns tl.subs (ns tl.subs
(:require [re-frame.core :as rf] (:require [clojure.string :as str]
[re-frame.core :as rf]
[tl.scene :as scene])) [tl.scene :as scene]))
(rf/reg-sub ::status (fn [db] (get-in db [:load :status]))) (rf/reg-sub ::status (fn [db] (get-in db [:load :status])))
@ -12,8 +13,12 @@
(rf/reg-sub ::project (fn [db] (:project db))) (rf/reg-sub ::project (fn [db] (:project db)))
(rf/reg-sub ::clip-url (fn [db] (get-in db [:project :clip]))) (rf/reg-sub ::clip-url (fn [db] (get-in db [:project :clip])))
(rf/reg-sub ::script-url (fn [db] (get-in db [:project :script]))) (rf/reg-sub ::script-url (fn [db] (get-in db [:project :script])))
(rf/reg-sub ::script-rev (fn [db] (:script-rev db 0)))
(rf/reg-sub ::pane (fn [db] (get-in db [:view :pane] :annotations))) (rf/reg-sub ::pane (fn [db] (get-in db [:view :pane] :annotations)))
(rf/reg-sub ::highlighting (fn [db] (get-in db [:view :highlighting]))) (rf/reg-sub ::active-note (fn [db] (get-in db [:view :active-note])))
(rf/reg-sub ::script-jump (fn [db] (get-in db [:view :script-jump])))
(rf/reg-sub ::hidden-notes (fn [db] (get-in db [:view :hidden-notes] #{})))
(rf/reg-sub ::region-focus (fn [db] (get-in db [:view :region-focus])))
(rf/reg-sub ::linking (fn [db] (get-in db [:view :linking]))) (rf/reg-sub ::linking (fn [db] (get-in db [:view :linking])))
(rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails]))) (rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails])))
(rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status]))) (rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status])))
@ -92,7 +97,11 @@
:content (:content g) :children (count (:marks g)) :content (:content g) :children (count (:marks g))
:nested (get nested gid 0) :nested (get nested gid 0)
:draft (boolean (:draft g)) :draft (boolean (:draft g))
:script (:script g) ; pdf highlights linked to this group ;; every script-note bound anywhere in this annotation
;; (annotation-level + per-mark), deduped, still-existing only
:notes (->> (concat (:notes g) (mapcat :notes (:marks g)))
distinct
(filterv #(= :script-note (get-in scene [:groups % :type]))))
:broken (boolean reason) :reason reason :broken (boolean reason) :reason reason
:hidden (boolean hidden) :hidden (boolean hidden)
:start (or (ffirst bars) 0) :bars bars})))) :start (or (ffirst bars) 0) :bars bars}))))
@ -118,3 +127,47 @@
(into #{} (keep (fn [a] (when (some (fn [[lo hi]] (<= lo ph hi)) (:bars a)) (into #{} (keep (fn [a] (when (some (fn [[lo hi]] (<= lo ph hi)) (:bars a))
(:id a))) (:id a)))
anns)))) anns))))
;; --- script notes ---------------------------------------------------------
;; every script-note in the project (they're context-independent), for the
;; Script pane rail and the annotation-form drag source.
(rf/reg-sub
::notes
:<- [::scene]
(fn [scene _]
(->> (:groups scene)
(keep (fn [[gid g]]
(when (= :script-note (:type g))
{:id gid :name (:name g) :color (or (:color g) "#c2864e")
:regions (vec (:regions g)) :count (count (:regions g))})))
(sort-by (comp str/lower-case str :name))
vec)))
;; one note by id (for the active-note authoring panel)
(rf/reg-sub ::note :<- [::scene] (fn [scene [_ gid]] (get-in scene [:groups gid])))
;; the set of note gids whose bound span currently contains the playhead — drives
;; the >>> "we're in range" indicator. Annotation-level bindings light while the
;; playhead is in ANY of the annotation's bars; a mark-level binding lights only
;; while the playhead is in that specific mark's bars.
(rf/reg-sub
::active-note-set
:<- [::scene] :<- [::context] :<- [::segments] :<- [::playhead]
(fn [[scene ctx segs ph] _]
(let [in? (fn [bars] (some (fn [[lo hi]] (<= lo ph hi)) bars))
bars (fn [src-segs] (scene/merge-bars
(mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs)))]
(reduce
(fn [acc [gid g]]
(if (and (= :annotation (:type g)) (= ctx (:parent g)))
(let [src-segs (scene/resolve scene gid)
acc (if (and (seq (:notes g)) (in? (bars src-segs)))
(into acc (:notes g)) acc)]
(reduce (fn [acc m]
(if (and (seq (:notes m))
(in? (bars (filter #(= (:id m) (:mark %)) src-segs))))
(into acc (:notes m)) acc))
acc (:marks g)))
acc))
#{} (:groups scene)))))

View file

@ -778,7 +778,9 @@
authed? @(rf/subscribe [::subs/authed?]) authed? @(rf/subscribe [::subs/authed?])
scene @(rf/subscribe [::subs/scene]) scene @(rf/subscribe [::subs/scene])
ctx @(rf/subscribe [::subs/context]) ctx @(rf/subscribe [::subs/context])
segs @(rf/subscribe [::subs/segments])] segs @(rf/subscribe [::subs/segments])
nmap (into {} (map (juxt :id identity)) @(rf/subscribe [::subs/notes]))
live @(rf/subscribe [::subs/active-note-set])]
(r/after-render (r/after-render
(fn [] (when-let [c @el] (fn [] (when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))] (when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
@ -830,8 +832,7 @@
[:div.ann-head [:div.ann-head
[:div.ann-title [:div.ann-title
[:span.ann-swatch {:style {:background (:color a)}}] [:span.ann-swatch {:style {:background (:color a)}}]
(when (:broken a) [:span {:title (:reason a)} "△ "]) (:name a) (when (:broken a) [:span {:title (:reason a)} "△ "]) (:name a)]
(when (seq (:script a)) [:span.script-badge {:title "linked to script"} "¶"])]
[:div.ann-actions [:div.ann-actions
[jump-control open a scene segs] [jump-control open a scene segs]
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
@ -843,7 +844,17 @@
[:button.del-btn {:title "Delete" [:button.del-btn {:title "Delete"
:on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?")) :on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?"))
(rf/dispatch [::events/delete-annotation (:id a)]))} "✕"])]] (rf/dispatch [::events/delete-annotation (:id a)]))} "✕"])]]
(when (not-empty (:content a)) [content-display scene ctx (:content a)])])))) (when (not-empty (:content a)) [content-display scene ctx (:content a)])
(when (seq (:notes a))
[:div.ann-notes
(for [ng (:notes a) :let [n (nmap ng)] :when n]
^{:key (name ng)}
[: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)}}]
[:span.bound-note-name (:name n)]])])]))))
[:div.ann-empty "No annotations here."])])))) [:div.ann-empty "No annotations here."])]))))
(defn- to-frame [v len] (defn- to-frame [v len]
@ -859,6 +870,40 @@
: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)]]))
;; --- 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.
(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"}
[: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 "»»» "])
[: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 annotation-form [] (defn annotation-form []
(r/with-let [orig (dissoc @(rf/subscribe [::subs/draft-group]) :draft :gid)] (r/with-let [orig (dissoc @(rf/subscribe [::subs/draft-group]) :draft :gid)]
(let [d @(rf/subscribe [::subs/draft-group]) (let [d @(rf/subscribe [::subs/draft-group])
@ -874,6 +919,9 @@
new? (= :new (:draft d)) new? (= :new (:draft d))
root? (nil? (:parent d)) ; the root timeline: content only root? (nil? (:parent d)) ; the root timeline: content only
put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)])) put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)]))
notes @(rf/subscribe [::subs/notes])
nmap (into {} (map (juxt :id identity)) notes)
live @(rf/subscribe [::subs/active-note-set])
rows (scene/marks->rows scene (:marks d)) rows (scene/marks->rows scene (:marks d))
valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d)))) valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d))))
save #(when valid? save #(when valid?
@ -911,14 +959,20 @@
[:<> [:<>
[:div.form-marks-label "Marks"] [:div.form-marks-label "Marks"]
(doall (doall
(for [[i row] (map-indexed vector rows)] (for [[i row] (map-indexed vector rows)
:let [mark (get (:marks d) i)
mark-id (:id mark)]]
^{:key i} ^{:key i}
[:div.mark-row [:div.mark-block
[frame-chip scene segs put d i :start (:s row)] [:div.mark-row
[:span.mark-arrow "→"] [frame-chip scene segs put d i :start (:s row)]
[frame-chip scene segs put d i :end (:e row)] [:span.mark-arrow "→"]
[:button.row-x {:type "button" :title "Remove" [frame-chip scene segs put d i :end (:e row)]
:on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]])) [:button.row-x {:type "button" :title "Remove"
:on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]]
[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 (map? pt) (when (map? pt)
[:div.mark-row [:div.mark-row
[:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt)) [:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt))
@ -939,10 +993,15 @@
:on-pick #(when-let [p (local->draft-point segs (:local %))] :on-pick #(when-let [p (local->draft-point segs (:local %))]
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]]) (rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]])
[:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."] [:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."]
[:div.form-marks-label "Script"] [:div.form-marks-label "Script notes"]
[:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-highlighting gid])} [note-drop "Whole annotation" (:notes d) nmap live
(str "+ Add script annotation" #(rf/dispatch [::events/bind-note-annotation gid %])
(when-let [n (seq (:script d))] (str " (" (count n) ")")))]]) #(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-actions [:div.form-actions
[:button.save {:type "submit" :disabled (not valid?)} "Save"] [:button.save {:type "submit" :disabled (not valid?)} "Save"]
[:button.cancel {:type "button" [:button.cancel {:type "button"
@ -1005,111 +1064,308 @@
(.addEventListener js/document "pointerup" up))) (.addEventListener js/document "pointerup" up)))
;; --- script pane (PDF) ---------------------------------------------------- ;; --- script pane (PDF) ----------------------------------------------------
;; A highlight is a normalized rect on a page, stored on an annotation group's ;; A script-note is a named, project-scoped entity: a bag of id'd regions (rects
;; :script — so the group's timeline range and its script passage are linked by ;; on PDF pages) each with its own commentary. You pick an active note in the
;; living in the same mark-group. Drag on a page to add one to the selected ;; rail, then drag on a page to add a region to it; annotations bind notes by id
;; annotation; click a highlight to jump the timeline to that annotation. ;; (see the annotation form). Regions render coloured by their owning note.
(def ^:private pdf-scale 1.4) (defn- render-page! [^js pg ^js canvas scale]
(defonce ^:private pdf-drag (r/atom nil)) ; {:page :x0 :y0 :x1 :y1} (normalized) while dragging
(defn- render-page! [^js pg ^js canvas]
(when (and pg canvas) (when (and pg canvas)
(let [^js vp (.getViewport pg #js {:scale pdf-scale})] (let [^js vp (.getViewport pg #js {:scale scale})]
(set! (.-width canvas) (.-width vp)) (set! (.-width canvas) (.-width vp))
(set! (.-height canvas) (.-height vp)) (set! (.-height canvas) (.-height vp))
(.render pg #js {:canvasContext (.getContext canvas "2d") :viewport vp})))) (.render pg #js {:canvasContext (.getContext canvas "2d") :viewport vp}))))
(defn- begin-drag! [pnum selected ev] ;; Render pdf.js's selectable text layer over the canvas, then tag each span with
(when selected ;; its running character offset into the page text (dataset.off) so a selection
(.preventDefault ev) ;; maps back to [start end) char offsets. Runs once per (remounted) node.
(let [box (.getBoundingClientRect (.-currentTarget ev)) (defn- render-text-layer! [^js pg ^js container scale]
nx #(/ (- % (.-left box)) (.-width box)) (when (and pg container (not (.. container -dataset -tlDone)))
ny #(/ (- % (.-top box)) (.-height box)) (set! (.. container -dataset -tlDone) "1")
x0 (nx (.-clientX ev)) y0 (ny (.-clientY ev))] (let [^js vp (.getViewport pg #js {:scale scale})]
(reset! pdf-drag {:page pnum :x0 x0 :y0 y0 :x1 x0 :y1 y0}) (-> (.getTextContent pg)
(letfn [(move [e] (swap! pdf-drag assoc :x1 (nx (.-clientX e)) :y1 (ny (.-clientY e)))) (.then (fn [^js tc]
(up [_] (.removeEventListener js/document "mousemove" move) (let [items (.-items tc)
(.removeEventListener js/document "mouseup" up) divs (array)]
(let [{:keys [x0 y0 x1 y1]} @pdf-drag (-> ^js (.renderTextLayer js/pdfjsLib
rect [(min x0 x1) (min y0 y1) (max x0 x1) (max y0 y1)]] #js {:textContent tc :container container
(reset! pdf-drag nil) :viewport vp :textDivs divs})
(when (> (* (- (rect 2) (rect 0)) (- (rect 3) (rect 1))) 0.0004) (.-promise)
(rf/dispatch [::events/add-script-rect selected pnum rect]))))] (.then (fn []
(.addEventListener js/document "mousemove" move) (loop [i 0 off 0]
(.addEventListener js/document "mouseup" up))))) (when (< i (alength items))
(when-let [^js d (aget divs i)]
(set! (.. d -dataset -off) (str off)))
(let [^js it (aget items i)]
(recur (inc i) (+ off (.-length (.-str it)))))))))))))
(.catch (fn [e] (js/console.warn "text layer failed" e)))))))
(defn- pdf-page [pnum pg w h anns armed] ;; `armed` = the active note's gid; its regions get a heavier border. Highlights
[:div.pdf-page {:style {:width w :height h} ;; sit UNDER the text layer (pointer-events:none, see css) so text stays
:class (when armed "armed") ;; selectable; a :text region draws one box per line (:rects). w1/h1 are the
:on-mouse-down #(begin-drag! pnum armed %)} ;; page's native (scale-1) size; everything scales off `scale`. Keying canvas +
[:canvas {:ref (fn [c] (when c (render-page! pg c)))}] ;; text layer on `scale` remounts them so a zoom change re-renders both.
(for [a anns (defn- pdf-page [pnum pg w1 h1 scale notes armed live-set]
[i {:keys [page rect]}] (map-indexed vector (:script a)) (let [w (* w1 scale) h (* h1 scale)]
:when (= page pnum)] [:div.pdf-page {:style {:width w :height h "--scale-factor" scale} :data-page pnum}
^{:key (str (:id a) "-" i)} ^{:key (str "c" scale)} [:canvas {:ref (fn [c] (when c (render-page! pg c scale)))}]
(let [[x0 y0 x1 y1] rect] (for [{:keys [id color regions] nm :name} notes
[:div.script-hl {:title (:name a) :data-ann (name (:id a)) {rid :id :keys [page rects]} regions
:on-mouse-down #(.stopPropagation %) :when (= page pnum)
:on-click #(goto! (:start a) true) ; jump the timeline to this annotation [ri [x0 y0 x1 y1]] (map-indexed vector rects)]
:style {:left (* x0 w) :top (* y0 h) ^{:key (str (name id) "-" (name rid) "-" ri)}
:width (* (- x1 x0) w) :height (* (- y1 y0) h) (let [on? (contains? live-set id)]
:background (str (:color a) "44") :border (str "1.5px solid " (:color a))}}])) [:div.script-hl {:title nm
(when-let [{:keys [page x0 y0 x1 y1]} @pdf-drag] :data-region (name rid)
(when (= page pnum) :style {:left (* x0 w) :top (* y0 h)
[:div.script-hl.drafting {:style {:left (* (min x0 x1) w) :top (* (min y0 y1) h) :width (* (- x1 x0) w) :height (* (- y1 y0) h)
:width (* (js/Math.abs (- x1 x0)) w) :background color
:height (* (js/Math.abs (- y1 y0)) h)}}]))]) :opacity (cond on? 0.6 (= id armed) 0.42 :else 0.3)}}]))
^{:key (str "t" scale)} [:div.text-layer {:ref (fn [c] (when c (render-text-layer! pg c scale)))}]]))
;; --- text selection → region ---------------------------------------------
(defn- closest-with
"Nearest ancestor element (starting at `node`, hopping to its parent if `node`
is a text node) for which `pred` holds, or nil."
[^js node pred]
(loop [n (if (and node (= 3 (.-nodeType node))) (.-parentElement node) node)]
(cond (nil? n) nil (pred n) n :else (recur (.-parentElement n)))))
(defn- char-offset
"Global char offset for a selection boundary at (`container`,`offset`): the
owning text-layer span's dataset.off plus the offset within its text node."
[^js container offset]
(when-let [sp (closest-with container (fn [^js n] (some? (.. n -dataset -off))))]
(+ (js/parseInt (.. sp -dataset -off) 10)
(if (= 3 (.-nodeType container)) offset 0))))
(defn- rect-list->vec [^js page ^js rl]
(let [box (.getBoundingClientRect page) w (.-width box) h (.-height box)]
(->> (range (.-length rl))
(map (fn [i] (let [^js rc (.item rl i)]
[(/ (- (.-left rc) (.-left box)) w) (/ (- (.-top rc) (.-top box)) h)
(/ (- (.-right rc) (.-left box)) w) (/ (- (.-bottom rc) (.-top box)) h)])))
(filterv (fn [[x0 y0 x1 y1]] (> (* (- x1 x0) (- y1 y0)) 0.00002))))))
(defn- capture-selection
"The current PDF text selection as a region {:page :start :end :text :rects
:bx :by}, or nil when there's no non-empty single-page selection. :bx/:by are
the selection-end position in `container`'s scroll-content coordinates, so a
button placed there scrolls with the pages instead of pinning to the viewport."
[^js container]
(let [s (.getSelection js/window)]
(when (and s container (pos? (.-rangeCount s)) (not (.-isCollapsed s)))
(let [r (.getRangeAt s 0)
page (closest-with (.-commonAncestorContainer r) (fn [^js n] (some? (.. n -dataset -page))))
st (char-offset (.-startContainer r) (.-startOffset r))
en (char-offset (.-endContainer r) (.-endOffset r))
rl (.getClientRects r)]
(when (and page st en (pos? (.-length rl)))
(let [rects (rect-list->vec page rl)
last-rc (.item rl (dec (.-length rl)))
crect (.getBoundingClientRect container)]
(when (seq rects)
{:page (js/parseInt (.. page -dataset -page) 10)
:start (min st en) :end (max st en) :text (.toString s)
:rects rects
:bx (+ (- (.-right last-rc) (.-left crect)) (.-scrollLeft container))
:by (+ (- (.-bottom last-rc) (.-top crect)) (.-scrollTop container))})))))))
(defn- region-at-point
"Which {:note gid :region rid} sits under client point (cx,cy) in `container`,
or nil. Used for click-to-open, since highlights are pointer-events:none (so
text stays selectable) — we hit-test their normalized rects by hand."
[^js container cx cy notes]
(when-let [page-el (some (fn [^js el]
(let [b (.getBoundingClientRect el)]
(when (and (<= (.-left b) cx (.-right b)) (<= (.-top b) cy (.-bottom b))) el)))
(array-seq (.querySelectorAll container ".pdf-page")))]
(let [b (.getBoundingClientRect page-el)
px (/ (- cx (.-left b)) (.-width b)) py (/ (- cy (.-top b)) (.-height b))
pg (js/parseInt (.. page-el -dataset -page) 10)]
(some (fn [{:keys [id regions]}]
(some (fn [{rid :id :keys [page rects]}]
(when (and (= page pg)
(some (fn [[x0 y0 x1 y1]] (and (<= x0 px x1) (<= y0 py y1))) rects))
{:note id :region rid}))
regions))
notes))))
(defn- note-rail
"The list of project script-notes: pick one active, toggle its highlights,
create, rename, delete."
[notes active hidden authed?]
[:div.note-rail
[:div.note-rail-list
(for [{:keys [id name color count]} notes
:let [off? (contains? hidden id)]]
^{:key (clojure.core/name id)}
[:span.note-chip-wrap {:class (str (when (= id active) "active ") (when off? "off"))}
[:button.note-eye {:title (if off? "Show highlights" "Hide highlights")
:on-click #(rf/dispatch [::events/toggle-note-hidden id])}
(if off? "🚫" "👁")]
[:button.note-chip {:on-click #(rf/dispatch [::events/set-active-note id])}
[:span.ann-swatch {:style {:background color}}]
[:span.note-chip-name name]
[:span.note-chip-count count]]])
(when (empty? notes) [:span.muted "No script notes yet."])]
(when authed?
[:<>
[:button.note-new {:title "New script note"
:on-click #(rf/dispatch [::events/create-note nil])} "+ New note"]
[:label.note-reupload {:title "Replace the script PDF"} "⤒ Replace PDF"
[:input {:type "file" :accept ".pdf,application/pdf" :style {:display "none"}
:on-change #(when-let [f (-> % .-target .-files (aget 0))]
(rf/dispatch [::events/upload-script f]))}]]])])
(defn- note-editor
"The active note's rename/delete controls + its regions with per-range
commentary. Clicking a region scrolls the PDF to it (via `scroll-to!`)."
[note active scroll-to!]
[:div.note-editor
[:div.note-editor-head
[:input.note-name {:value (:name note) :placeholder "Note name"
:on-change #(rf/dispatch [::events/rename-note active (.. % -target -value)])
:on-blur #(rf/dispatch [::events/persist-note active])}]
[:input.form-color {:type "color" :title "Highlight color" :value (or (:color note) "#c2864e")
:on-change #(rf/dispatch [::events/set-note-color active (.. % -target -value)])
:on-blur #(rf/dispatch [::events/persist-note active])}]
[:button.del-btn {:title "Delete note"
:on-click #(when (js/confirm (str "Delete note \"" (:name note) "\"?"))
(rf/dispatch [::events/delete-note active]))} "✕"]]
[:div.note-hint "Select text on the script, then click “+ Highlight”."]
(if (seq (:regions note))
(doall
(for [{rid :id :keys [page content]} (:regions note)]
^{:key (name rid)}
[:div.region-row {:data-region-row (name rid)}
[:div.region-row-head
[:button.region-jump {:title "Show on page"
:on-click #(scroll-to! rid)} (str "¶ p" page)]
[:button.row-x {:title "Remove range"
:on-click #(rf/dispatch [::events/delete-region active rid])} "✕"]]
[:textarea.region-note {:value (or content "") :placeholder "Note on this passage…"
:on-change #(rf/dispatch [::events/set-region-content active rid (.. % -target -value)])
:on-blur #(rf/dispatch [::events/persist-note active])}]]))
[:div.muted {:style {:padding "4px 8px"}} "No ranges yet."])])
(def ^:private zoom-min 0.3)
(def ^:private zoom-max 3.0)
(defn script-pane [] (defn script-pane []
(let [pages (r/atom (sorted-map)) loaded (atom nil) el (atom nil) scrolled (atom nil)] (let [pages (r/atom (sorted-map)) loaded (atom nil) el (atom nil) scrolled (atom nil)
sel (r/atom nil) ; pending text selection → highlight
zoom (r/atom nil) ; display scale; nil ⇒ fit-to-width on first render
fit! (fn []
(when-let [c @el]
(when-let [{:keys [w1]} (second (first @pages))]
(when (pos? w1)
(reset! zoom (-> (/ (- (.-clientWidth c) 32) w1)
(max zoom-min) (min zoom-max)))))))
bump (fn [f] (swap! zoom #(-> (f (or % 1)) (max zoom-min) (min zoom-max))))
scroll-to-region!
(fn [rid]
(when-let [node (and @el (.querySelector @el (str "[data-region='" (name rid) "']")))]
(.scrollIntoView node #js {:block "center" :behavior "smooth"})))]
(fn [] (fn []
(let [url @(rf/subscribe [::subs/script-url]) (let [url0 @(rf/subscribe [::subs/script-url])
anns @(rf/subscribe [::subs/annotations]) rev @(rf/subscribe [::subs/script-rev])
hl @(rf/subscribe [::subs/highlighting]) ;; cache-buster so a same-named replacement PDF actually reloads
active @(rf/subscribe [::subs/active-annotation]) url (when url0 (str url0 (if (str/includes? url0 "?") "&" "?") "v=" rev))
notes @(rf/subscribe [::subs/notes])
active @(rf/subscribe [::subs/active-note])
note (when active @(rf/subscribe [::subs/note active]))
live @(rf/subscribe [::subs/active-note-set])
hidden @(rf/subscribe [::subs/hidden-notes])
authed? @(rf/subscribe [::subs/authed?]) authed? @(rf/subscribe [::subs/authed?])
script-err @(rf/subscribe [::subs/script-error]) script-err @(rf/subscribe [::subs/script-error])
armed (:gid hl) scale (or @zoom 1)
hl-name (some #(when (= (:id %) armed) (:name %)) anns)] ;; notes whose highlights are drawn on the page (eye toggle off)
shown (filterv #(not (contains? hidden (:id %))) notes)]
(when (and url (exists? js/pdfjsLib) (not= url @loaded)) (when (and url (exists? js/pdfjsLib) (not= url @loaded))
(reset! loaded url) (reset! pages (sorted-map)) (reset! loaded url) (reset! pages (sorted-map)) (reset! zoom nil)
(-> (.-promise ^js (.getDocument js/pdfjsLib url)) (-> (.-promise ^js (.getDocument js/pdfjsLib url))
(.then (fn [^js pdf] (.then (fn [^js pdf]
(doseq [n (range 1 (inc (.-numPages pdf)))] (doseq [n (range 1 (inc (.-numPages pdf)))]
(-> (.getPage pdf n) (-> (.getPage pdf n)
(.then (fn [^js pg] (.then (fn [^js pg]
(let [^js vp (.getViewport pg #js {:scale pdf-scale})] (let [^js vp (.getViewport pg #js {:scale 1})]
(swap! pages assoc n {:pg pg :w (.-width vp) :h (.-height vp)})))))))) (swap! pages assoc n {:pg pg :w1 (.-width vp) :h1 (.-height vp)}))))))))
(.catch (fn [e] (js/console.error "pdf load failed" e))))) (.catch (fn [e] (js/console.error "pdf load failed" e)))))
;; follow the playhead: scroll to the active annotation's first highlight ;; once pages are in, default the zoom to fit the pane width
(when (and (seq @pages) (nil? @zoom)) (r/after-render fit!))
;; a note-chip jump: scroll to the note's first region, retrying across the
;; async PDF load, then clear the flag
(when-let [jump @(rf/subscribe [::subs/script-jump])]
(r/after-render
(fn [] (let [rid (:id (first (:regions (some #(when (= jump (:id %)) %) notes))))]
(cond
(nil? rid) (rf/dispatch [::events/clear-script-jump])
(and @el (.querySelector @el (str "[data-region='" (name rid) "']")))
(do (scroll-to-region! rid) (rf/dispatch [::events/clear-script-jump])))))))
;; a highlight click: scroll the right-pane region row into view + focus it
(when-let [rfoc @(rf/subscribe [::subs/region-focus])]
(r/after-render
(fn [] (when-let [row (js/document.querySelector
(str ".note-editor [data-region-row='" (name rfoc) "']"))]
(.scrollIntoView row #js {:block "center" :behavior "smooth"})
(when-let [^js ta (.querySelector row "textarea")] (.focus ta))
(rf/dispatch [::events/clear-region-focus])))))
;; follow the playhead: scroll to a region of whichever note just went live
(r/after-render (r/after-render
(fn [] (when (and @el active (not= active @scrolled)) (fn [] (let [live-note (first live)]
(when-let [node (.querySelector @el (str "[data-ann='" (name active) "']"))] (when (and @el live-note (not= live-note @scrolled))
(reset! scrolled active) (when-let [rid (:id (first (:regions (some #(when (= live-note (:id %)) %) notes))))]
(.scrollIntoView node #js {:block "center" :behavior "smooth"}))))) (reset! scrolled live-note)
(scroll-to-region! rid))))))
[:div.script-pane [:div.script-pane
(if armed (if url
[:div.script-hint.arming [note-rail notes active hidden authed?]
[:span "Highlighting for “" hl-name "” — drag on a page to add."]
[:span.hl-actions
[:button.hl-ok {:title "Done" :on-click #(rf/dispatch [::events/finish-highlighting])} "✓"]
[:button.hl-cancel {:title "Cancel" :on-click #(rf/dispatch [::events/cancel-highlighting])} "✕"]]]
[:div.script-hint [:div.script-hint
(cond (if authed?
url "Playing follows the script to each note. Edit an annotation → “Add script annotation” to link a passage." [:span.script-upload
authed? [:span.script-upload [:label.file-row "Upload a script (PDF) "
[:label.file-row "Upload a script (PDF) " [:input {:type "file" :accept ".pdf,application/pdf"
[:input {:type "file" :accept ".pdf,application/pdf" :on-change #(when-let [f (-> % .-target .-files (aget 0))]
:on-change #(when-let [f (-> % .-target .-files (aget 0))] (rf/dispatch [::events/upload-script f]))}]]
(rf/dispatch [::events/upload-script f]))}]] (when script-err [:span.err " " script-err])]
(when script-err [:span.err " " script-err])] "No script uploaded for this project.")])
:else "No script uploaded for this project.")]) [:div.script-body
[:div.script-pages {:ref #(reset! el %)} [:div.script-pages
(if (seq @pages) {:ref #(reset! el %)
(doall (for [[n {:keys [pg w h]}] @pages] :on-mouse-up (fn [e]
^{:key n} [pdf-page n pg w h anns armed])) (if-let [s (capture-selection @el)]
(when url [:div.script-empty "Loading script…"]))]])))) (reset! sel s) ; a drag-selection → offer to highlight
(do (reset! sel nil) ; a plain click → open any highlight under it
(when-let [hit (region-at-point @el (.-clientX e) (.-clientY e) shown)]
(rf/dispatch [::events/open-region (:note hit) (:region hit)])))))}
(if (seq @pages)
[:<>
(doall (for [[n {:keys [pg w1 h1]}] @pages]
^{:key n} [pdf-page n pg w1 h1 scale shown active live]))
;; the "add highlight" button lives INSIDE the scroll content, at the
;; selection end in content coords, so it scrolls with the pages.
(when @sel
(let [region (select-keys @sel [:page :start :end :text :rects])]
[:button.sel-add {:style {:left (:bx @sel) :top (:by @sel)}
:on-mouse-down #(.preventDefault %) ; keep the selection alive
:on-click (fn []
(if active
(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")]))]
(when url [:div.script-empty "Loading script…"]))]
(when (and url active note)
[note-editor note active scroll-to-region!])]
(when (and url (seq @pages))
[:div.script-controls
[:button {:title "Zoom out" :on-click #(bump (fn [z] (* z 0.9)))} "−"]
[:span.zoom-read (str (js/Math.round (* scale 100)) "%")]
[:button {:title "Zoom in" :on-click #(bump (fn [z] (* z 1.1)))} "+"]
[:button {:title "Fit width" :on-click fit!} "Fit"]])]))))
(defn annot-pane [] (defn annot-pane []
(let [pane @(rf/subscribe [::subs/pane]) (let [pane @(rf/subscribe [::subs/pane])