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:
parent
89533a13cd
commit
b4924aa992
5 changed files with 680 additions and 145 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)))))
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue