From b4924aa9923e8997298ffc6f31a61f3e4ac9a656 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 1 Jul 2026 16:34:29 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20script=20notes=20=E2=80=94=20named=20PD?= =?UTF-8?q?F-region=20annotations=20bound=20to=20marks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tl/resources/public/css/app.css | 110 +++++++- tl/src/tl/events.cljs | 174 ++++++++++-- tl/src/tl/scene.cljs | 30 ++- tl/src/tl/subs.cljs | 59 ++++- tl/src/tl/views.cljs | 452 +++++++++++++++++++++++++------- 5 files changed, 680 insertions(+), 145 deletions(-) diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index f4060dd..4524a74 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -16,8 +16,8 @@ --chicago:"Chicago","ChicagoFLF","Charcoal","Geneva",system-ui,sans-serif; --geneva:"Geneva","Verdana","Tahoma","Segoe UI",sans-serif; --mono:"Monaco","Courier New",monospace; - /* 50% checkerboard — the classic Mac desktop dither */ - --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"); + /* 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='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). @@ -27,13 +27,13 @@ html.dark { } 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; } /* classic Mac scrollbars: white track, dithered thumb, hard black edges */ ::-webkit-scrollbar { width: 15px; height: 15px; } ::-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); } .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-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 { - background: var(--desktop); background-size: 2px 2px; + background: var(--desktop); background-size: 4px 4px; } /* --- commentary --------------------------------------------------------- */ @@ -155,6 +155,21 @@ body { overflow: hidden; background: var(--desktop); background-size: 2px 2px; .ctx-content .muted { font-style: italic; } .ctx-content .edit-btn { flex-shrink: 0; } .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); } .ann-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .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 { 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-pages { flex: 1; overflow: auto; padding: 12px; - background: var(--desktop); background-size: 2px 2px; +.script-body { flex: 1; min-height: 0; display: flex; } +.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; } .pdf-page { position: relative; box-shadow: 2px 2px 0 var(--ink); background: #fff; border: 1px solid var(--ink); } .pdf-page.armed { cursor: crosshair; } .pdf-page canvas { display: block; } -.script-hl { position: absolute; border-radius: 0; cursor: pointer; } -.script-hl.drafting { background: rgba(0,0,0,.12); border: 1.5px dashed var(--ink); pointer-events: none; } +.script-hl { position: absolute; border-radius: 0; z-index: 1; 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-badge { margin-left: 6px; color: var(--ink); font-size: 11px; } .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-toggle { font-family: var(--chicago); background: var(--paper); color: var(--ink); border: 1px solid var(--ink); border-radius: 8px; padding: 2px 8px; diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index 068c35a..6067a56 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -1,5 +1,6 @@ (ns tl.events (:require + [clojure.string :as str] [re-frame.core :as rf] [day8.re-frame.http-fx] ; registers the :http-xhrio effect [tl.api :as api] @@ -270,37 +271,160 @@ :http-xhrio (api/upload (str "/api/projects/" id "/script/") fd {:on-success [::script-uploaded] :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 - (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 (fn [db [_ failure]] (assoc db :script-error (api/error-message failure "Script upload failed.")))) -;; Script highlighting is an edit-mode sub-task: "Add script annotation" arms it -;; for the annotation being edited (the draft) and jumps to the Script tab; the -;; rects land on the draft's :script and ride its save. ✓ keeps, ✕ restores the -;; snapshot taken on entry. (No card-click selection anymore.) -(rf/reg-event-db ::start-highlighting - (fn [db [_ gid]] - (-> db - (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))))) +;; --- script notes --------------------------------------------------------- +;; A script-note is a first-class entity in the scene pool (:type :script-note): +;; a named bag of PDF regions (each an id'd rect + per-range commentary), authored +;; in the Script pane and referenced by id from annotations and their marks. Notes +;; persist through the same generic put-scene channel as every other group; unlike +;; annotations they have no draft/save dance, so each structural edit persists at +;; once (commentary text persists on blur — see ::persist-note). -;; rects update the draft locally; the draft's save (put-group) persists them. -(rf/reg-event-db ::add-script-rect - (fn [db [_ gid page rect]] - (update-in db [:scene :groups gid :script] (fnil conj []) {:page page :rect rect}))) +(defn- persist-note-fx + "Whole-send note `gid` to the backend (notes are small; no diffing). No-op with + no project id (dev / unauthed) — the note still lives in the local scene." + [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 ;; surface can update :content without re-reading a possibly-stale draft map. diff --git a/tl/src/tl/scene.cljs b/tl/src/tl/scene.cljs index a5107f5..9c7e785 100644 --- a/tl/src/tl/scene.cljs +++ b/tl/src/tl/scene.cljs @@ -255,18 +255,28 @@ (:id m) (update :id keyword) (get-in m [:start :ref]) (update-in [:start :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 ---------------------------------------------------- ;; Every stored annotation carries :v, its schema version. When the data model ;; 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. -(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 -;; :v (n+1)). Empty at v1; e.g. {1 (fn [g] (-> g (rename-key …) (assoc :v 2)))}. -(def ^:private transformers {}) +;; :v (n+1)). v1→v2 drops the old per-annotation :script rects — script passages +;; 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 "Upgrade annotation `g` to the current schema-version by chaining transformers. @@ -283,11 +293,13 @@ schema version, before merging into the (keyword-keyed) scene." [anns] (into {} (map (fn [[gid g]] - [gid (-> g - (update :type keyword) - (update :parent keyword) - (update :marks #(mapv restore-mark (or % []))) - migrate)])) + (let [g (-> g (update :type keyword) (update :parent keyword))] + [gid (if (= :script-note (:type g)) + (update g :regions #(mapv restore-region (or % []))) + (-> g + (update :marks #(mapv restore-mark (or % []))) + (update :notes #(when % (mapv keyword %))) ; annotation-level bindings + migrate))]))) anns)) (defn marks->rows diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index d7ba369..8a58470 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -1,5 +1,6 @@ (ns tl.subs - (:require [re-frame.core :as rf] + (:require [clojure.string :as str] + [re-frame.core :as rf] [tl.scene :as scene])) (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 ::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-rev (fn [db] (:script-rev db 0))) (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 ::thumbnails (fn [db] (get-in db [:project :thumbnails]))) (rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status]))) @@ -92,7 +97,11 @@ :content (:content g) :children (count (:marks g)) :nested (get nested gid 0) :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 :hidden (boolean hidden) :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)) (:id a))) 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))))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index aaf61e9..516ae68 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -778,7 +778,9 @@ authed? @(rf/subscribe [::subs/authed?]) scene @(rf/subscribe [::subs/scene]) 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 (fn [] (when-let [c @el] (when-let [node (and active (.querySelector c (str "#ann-" (name active))))] @@ -830,8 +832,7 @@ [:div.ann-head [:div.ann-title [:span.ann-swatch {:style {:background (:color a)}}] - (when (:broken a) [:span {:title (:reason a)} "△ "]) (:name a) - (when (seq (:script a)) [:span.script-badge {:title "linked to script"} "¶"])] + (when (:broken a) [:span {:title (:reason a)} "△ "]) (:name a)] [:div.ann-actions [jump-control open a scene segs] [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} @@ -843,7 +844,17 @@ [:button.del-btn {:title "Delete" :on-click #(when (js/confirm (str "Delete \"" (:name 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."])])))) (defn- to-frame [v len] @@ -859,6 +870,40 @@ :on-change #(put (assoc-in d [:marks i k :at] (to-frame (.. % -target -value) 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 [] (r/with-let [orig (dissoc @(rf/subscribe [::subs/draft-group]) :draft :gid)] (let [d @(rf/subscribe [::subs/draft-group]) @@ -874,6 +919,9 @@ new? (= :new (:draft d)) root? (nil? (:parent d)) ; the root timeline: content only 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)) valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d)))) save #(when valid? @@ -911,14 +959,20 @@ [:<> [:div.form-marks-label "Marks"] (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} - [:div.mark-row - [frame-chip scene segs put d i :start (:s row)] - [:span.mark-arrow "→"] - [frame-chip scene segs put d i :end (:e row)] - [: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)))))))} "✕"]])) + [:div.mark-block + [:div.mark-row + [frame-chip scene segs put d i :start (:s row)] + [:span.mark-arrow "→"] + [frame-chip scene segs put d i :end (:e row)] + [: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) [:div.mark-row [: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 %))] (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-marks-label "Script"] - [:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-highlighting gid])} - (str "+ Add script annotation" - (when-let [n (seq (:script d))] (str " (" (count n) ")")))]]) + [:div.form-marks-label "Script notes"] + [note-drop "Whole annotation" (:notes d) nmap live + #(rf/dispatch [::events/bind-note-annotation gid %]) + #(rf/dispatch [::events/unbind-note-annotation gid %])] + (if (seq notes) + [:div.note-source + (for [n notes] ^{:key (name (:id n))} [note-src-chip gid n])] + [:div.form-hint "No script notes yet — create them in the Script tab."]) + [:div.form-hint "Drag a note onto a mark (or the whole-annotation target). It shows »»» while the playhead is in range."]]) [:div.form-actions [:button.save {:type "submit" :disabled (not valid?)} "Save"] [:button.cancel {:type "button" @@ -1005,111 +1064,308 @@ (.addEventListener js/document "pointerup" up))) ;; --- script pane (PDF) ---------------------------------------------------- -;; A highlight is a normalized rect on a page, stored on an annotation group's -;; :script — so the group's timeline range and its script passage are linked by -;; living in the same mark-group. Drag on a page to add one to the selected -;; annotation; click a highlight to jump the timeline to that annotation. +;; A script-note is a named, project-scoped entity: a bag of id'd regions (rects +;; on PDF pages) each with its own commentary. You pick an active note in the +;; rail, then drag on a page to add a region to it; annotations bind notes by id +;; (see the annotation form). Regions render coloured by their owning note. -(def ^:private pdf-scale 1.4) -(defonce ^:private pdf-drag (r/atom nil)) ; {:page :x0 :y0 :x1 :y1} (normalized) while dragging - -(defn- render-page! [^js pg ^js canvas] +(defn- render-page! [^js pg ^js canvas scale] (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! (.-height canvas) (.-height vp)) (.render pg #js {:canvasContext (.getContext canvas "2d") :viewport vp})))) -(defn- begin-drag! [pnum selected ev] - (when selected - (.preventDefault ev) - (let [box (.getBoundingClientRect (.-currentTarget ev)) - nx #(/ (- % (.-left box)) (.-width box)) - ny #(/ (- % (.-top box)) (.-height box)) - x0 (nx (.-clientX ev)) y0 (ny (.-clientY ev))] - (reset! pdf-drag {:page pnum :x0 x0 :y0 y0 :x1 x0 :y1 y0}) - (letfn [(move [e] (swap! pdf-drag assoc :x1 (nx (.-clientX e)) :y1 (ny (.-clientY e)))) - (up [_] (.removeEventListener js/document "mousemove" move) - (.removeEventListener js/document "mouseup" up) - (let [{:keys [x0 y0 x1 y1]} @pdf-drag - rect [(min x0 x1) (min y0 y1) (max x0 x1) (max y0 y1)]] - (reset! pdf-drag nil) - (when (> (* (- (rect 2) (rect 0)) (- (rect 3) (rect 1))) 0.0004) - (rf/dispatch [::events/add-script-rect selected pnum rect]))))] - (.addEventListener js/document "mousemove" move) - (.addEventListener js/document "mouseup" up))))) +;; Render pdf.js's selectable text layer over the canvas, then tag each span with +;; its running character offset into the page text (dataset.off) so a selection +;; maps back to [start end) char offsets. Runs once per (remounted) node. +(defn- render-text-layer! [^js pg ^js container scale] + (when (and pg container (not (.. container -dataset -tlDone))) + (set! (.. container -dataset -tlDone) "1") + (let [^js vp (.getViewport pg #js {:scale scale})] + (-> (.getTextContent pg) + (.then (fn [^js tc] + (let [items (.-items tc) + divs (array)] + (-> ^js (.renderTextLayer js/pdfjsLib + #js {:textContent tc :container container + :viewport vp :textDivs divs}) + (.-promise) + (.then (fn [] + (loop [i 0 off 0] + (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] - [:div.pdf-page {:style {:width w :height h} - :class (when armed "armed") - :on-mouse-down #(begin-drag! pnum armed %)} - [:canvas {:ref (fn [c] (when c (render-page! pg c)))}] - (for [a anns - [i {:keys [page rect]}] (map-indexed vector (:script a)) - :when (= page pnum)] - ^{:key (str (:id a) "-" i)} - (let [[x0 y0 x1 y1] rect] - [:div.script-hl {:title (:name a) :data-ann (name (:id a)) - :on-mouse-down #(.stopPropagation %) - :on-click #(goto! (:start a) true) ; jump the timeline to this annotation - :style {:left (* x0 w) :top (* y0 h) - :width (* (- x1 x0) w) :height (* (- y1 y0) h) - :background (str (:color a) "44") :border (str "1.5px solid " (:color a))}}])) - (when-let [{:keys [page x0 y0 x1 y1]} @pdf-drag] - (when (= page pnum) - [:div.script-hl.drafting {:style {:left (* (min x0 x1) w) :top (* (min y0 y1) h) - :width (* (js/Math.abs (- x1 x0)) w) - :height (* (js/Math.abs (- y1 y0)) h)}}]))]) +;; `armed` = the active note's gid; its regions get a heavier border. Highlights +;; sit UNDER the text layer (pointer-events:none, see css) so text stays +;; selectable; a :text region draws one box per line (:rects). w1/h1 are the +;; page's native (scale-1) size; everything scales off `scale`. Keying canvas + +;; text layer on `scale` remounts them so a zoom change re-renders both. +(defn- pdf-page [pnum pg w1 h1 scale notes armed live-set] + (let [w (* w1 scale) h (* h1 scale)] + [:div.pdf-page {:style {:width w :height h "--scale-factor" scale} :data-page pnum} + ^{:key (str "c" scale)} [:canvas {:ref (fn [c] (when c (render-page! pg c scale)))}] + (for [{:keys [id color regions] nm :name} notes + {rid :id :keys [page rects]} regions + :when (= page pnum) + [ri [x0 y0 x1 y1]] (map-indexed vector rects)] + ^{:key (str (name id) "-" (name rid) "-" ri)} + (let [on? (contains? live-set id)] + [:div.script-hl {:title nm + :data-region (name rid) + :style {:left (* x0 w) :top (* y0 h) + :width (* (- x1 x0) w) :height (* (- y1 y0) h) + :background color + :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 [] - (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 [] - (let [url @(rf/subscribe [::subs/script-url]) - anns @(rf/subscribe [::subs/annotations]) - hl @(rf/subscribe [::subs/highlighting]) - active @(rf/subscribe [::subs/active-annotation]) + (let [url0 @(rf/subscribe [::subs/script-url]) + rev @(rf/subscribe [::subs/script-rev]) + ;; cache-buster so a same-named replacement PDF actually reloads + 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?]) script-err @(rf/subscribe [::subs/script-error]) - armed (:gid hl) - hl-name (some #(when (= (:id %) armed) (:name %)) anns)] + scale (or @zoom 1) + ;; 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)) - (reset! loaded url) (reset! pages (sorted-map)) + (reset! loaded url) (reset! pages (sorted-map)) (reset! zoom nil) (-> (.-promise ^js (.getDocument js/pdfjsLib url)) (.then (fn [^js pdf] (doseq [n (range 1 (inc (.-numPages pdf)))] (-> (.getPage pdf n) (.then (fn [^js pg] - (let [^js vp (.getViewport pg #js {:scale pdf-scale})] - (swap! pages assoc n {:pg pg :w (.-width vp) :h (.-height vp)})))))))) + (let [^js vp (.getViewport pg #js {:scale 1})] + (swap! pages assoc n {:pg pg :w1 (.-width vp) :h1 (.-height vp)})))))))) (.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 - (fn [] (when (and @el active (not= active @scrolled)) - (when-let [node (.querySelector @el (str "[data-ann='" (name active) "']"))] - (reset! scrolled active) - (.scrollIntoView node #js {:block "center" :behavior "smooth"}))))) + (fn [] (let [live-note (first live)] + (when (and @el live-note (not= live-note @scrolled)) + (when-let [rid (:id (first (:regions (some #(when (= live-note (:id %)) %) notes))))] + (reset! scrolled live-note) + (scroll-to-region! rid)))))) [:div.script-pane - (if armed - [:div.script-hint.arming - [: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])} "✕"]]] + (if url + [note-rail notes active hidden authed?] [:div.script-hint - (cond - url "Playing follows the script to each note. Edit an annotation → “Add script annotation” to link a passage." - authed? [:span.script-upload - [:label.file-row "Upload a script (PDF) " - [:input {:type "file" :accept ".pdf,application/pdf" - :on-change #(when-let [f (-> % .-target .-files (aget 0))] - (rf/dispatch [::events/upload-script f]))}]] - (when script-err [:span.err " " script-err])] - :else "No script uploaded for this project.")]) - [:div.script-pages {:ref #(reset! el %)} - (if (seq @pages) - (doall (for [[n {:keys [pg w h]}] @pages] - ^{:key n} [pdf-page n pg w h anns armed])) - (when url [:div.script-empty "Loading script…"]))]])))) + (if authed? + [:span.script-upload + [:label.file-row "Upload a script (PDF) " + [:input {:type "file" :accept ".pdf,application/pdf" + :on-change #(when-let [f (-> % .-target .-files (aget 0))] + (rf/dispatch [::events/upload-script f]))}]] + (when script-err [:span.err " " script-err])] + "No script uploaded for this project.")]) + [:div.script-body + [:div.script-pages + {:ref #(reset! el %) + :on-mouse-up (fn [e] + (if-let [s (capture-selection @el)] + (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 [] (let [pane @(rf/subscribe [::subs/pane])