Script pane, realtime peer sync, and logout button
- Script pane: PDF tab beside annotations; "Add script annotation" arms highlight mode on the draft, ✓/✕ to confirm/cancel, auto-scrolls to the active annotation's highlights during playback. Adds Project.script. - Realtime: Django Channels websocket per project broadcasts attributed scene deltas after each save; peers merge them in (keeping local drafts). - Logout link in the editor project-bar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
595ac5ba72
commit
d3c8dcbdf2
13 changed files with 307 additions and 11 deletions
22
scenes/consumers.py
Normal file
22
scenes/consumers.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import json
|
||||
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
|
||||
class SceneConsumer(AsyncWebsocketConsumer):
|
||||
"""One connection per open project. Joins the project's group and relays
|
||||
scene deltas the server broadcasts (see scenes.views.scene). Read-only:
|
||||
edits still go through the PUT endpoint, which is the broadcast source."""
|
||||
|
||||
async def connect(self):
|
||||
self.pk = self.scope['url_route']['kwargs']['pk']
|
||||
self.group = f'project_{self.pk}'
|
||||
await self.channel_layer.group_add(self.group, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, code):
|
||||
await self.channel_layer.group_discard(self.group, self.channel_name)
|
||||
|
||||
# broadcast handler: {type: "scene.delta", delta: {...}}
|
||||
async def scene_delta(self, event):
|
||||
await self.send(text_data=json.dumps(event['delta']))
|
||||
18
scenes/migrations/0006_project_script.py
Normal file
18
scenes/migrations/0006_project_script.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 6.0.6 on 2026-06-29 08:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('scenes', '0005_project_thumbnail_error_project_thumbnail_manifest_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='script',
|
||||
field=models.FileField(blank=True, null=True, upload_to='scripts/'),
|
||||
),
|
||||
]
|
||||
|
|
@ -16,6 +16,7 @@ class Project(models.Model):
|
|||
name = models.CharField(max_length=200)
|
||||
otio = models.FileField(upload_to="otio/", blank=True, null=True)
|
||||
clip = models.FileField(upload_to="clips/", blank=True, null=True)
|
||||
script = models.FileField(upload_to="scripts/", blank=True, null=True)
|
||||
fps = models.FloatField(default=NTSC_24)
|
||||
thumbnail_status = models.CharField(max_length=20, default="missing")
|
||||
thumbnail_manifest = models.JSONField(default=dict, blank=True)
|
||||
|
|
|
|||
7
scenes/routing.py
Normal file
7
scenes/routing.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from .consumers import SceneConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path('ws/projects/<int:pk>/', SceneConsumer.as_asgi()),
|
||||
]
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import json
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
|
@ -53,6 +55,7 @@ def _meta(request, project):
|
|||
"owner": project.owner.get_username(),
|
||||
"otio": request.build_absolute_uri(project.otio.url) if project.otio else None,
|
||||
"clip": request.build_absolute_uri(project.clip.url) if project.clip else None,
|
||||
"script": request.build_absolute_uri(project.script.url) if project.script else None,
|
||||
"thumbnail_status": project.thumbnail_status,
|
||||
"thumbnail_error": project.thumbnail_error,
|
||||
"thumbnails": thumbnails or None,
|
||||
|
|
@ -150,6 +153,15 @@ def scene(request, pk):
|
|||
if any(counts.values()): # don't log a no-op save
|
||||
Revision.objects.create(project=p, user=request.user, summary=summary,
|
||||
annotations=annotation_layer(p.scene))
|
||||
# push the attributed delta to peers viewing this project
|
||||
scene_groups = (p.scene or {}).get("groups", {})
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f"project_{p.pk}",
|
||||
{"type": "scene.delta",
|
||||
"delta": {"changed": {gid: scene_groups[gid]
|
||||
for gid in changed if gid in scene_groups},
|
||||
"deleted": list(deleted),
|
||||
"by": request.user.get_username()}})
|
||||
if "fps" in data:
|
||||
p.fps = data["fps"]
|
||||
p.save(update_fields=["scene", "fps", "updated"])
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
"""
|
||||
ASGI config for server project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
HTTP is handled by Django as usual; websocket connections are routed to the
|
||||
scene consumers so peers receive live scene deltas.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -13,4 +11,15 @@ from django.core.asgi import get_asgi_application
|
|||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
# Initialise Django before importing anything that touches the app registry.
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
|
||||
import scenes.routing
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
'http': django_asgi_app,
|
||||
'websocket': AuthMiddlewareStack(URLRouter(scenes.routing.websocket_urlpatterns)),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'testserver']
|
|||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'daphne', # ASGI server; must precede staticfiles so runserver uses it
|
||||
'channels',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
|
|
@ -54,6 +56,11 @@ MIDDLEWARE = [
|
|||
|
||||
ROOT_URLCONF = 'server.urls'
|
||||
|
||||
# Realtime: push scene deltas to peers over websockets. In-memory layer is fine
|
||||
# for a single process; swap to channels_redis when you run more than one worker.
|
||||
ASGI_APPLICATION = 'server.asgi.application'
|
||||
CHANNEL_LAYERS = {'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}}
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
|
|
|
|||
|
|
@ -308,3 +308,28 @@ body { overflow: hidden; }
|
|||
.project-bar { display: flex; gap: 10px; align-items: center; padding: 4px 8px;
|
||||
background: #111; border-bottom: 1px solid #222; font-size: 12px; }
|
||||
.project-bar .project-name { color: #cfe3f5; font-weight: 600; }
|
||||
.project-bar .logout-link { margin-left: auto; color: #8aa; text-decoration: none; }
|
||||
.project-bar .logout-link:hover { color: #cfe3f5; text-decoration: underline; }
|
||||
|
||||
/* --- annotation / script pane tabs ------------------------------------- */
|
||||
.pane-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.pane-tabs { display: flex; gap: 2px; background: #111; border-bottom: 1px solid #222; }
|
||||
.pane-tabs button { flex: 1; background: none; color: #889; border: none; padding: 7px 10px;
|
||||
cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; }
|
||||
.pane-tabs button:hover { color: #cfe3f5; }
|
||||
.pane-tabs button.active { color: #cfe3f5; border-bottom-color: #3a6ea5; background: #152333; }
|
||||
.pane-body { flex: 1; min-height: 0; display: flex; }
|
||||
|
||||
/* --- script pane (pdf) ------------------------------------------------- */
|
||||
.script-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: #111; }
|
||||
.script-hint { padding: 6px 10px; font-size: 11px; color: #8aa; border-bottom: 1px solid #222; }
|
||||
.script-pages { flex: 1; overflow: auto; padding: 12px; background: #1a1a1a;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.pdf-page { position: relative; box-shadow: 0 2px 10px rgba(0,0,0,.5); background: #fff; }
|
||||
.pdf-page.armed { cursor: crosshair; }
|
||||
.pdf-page canvas { display: block; }
|
||||
.script-hl { position: absolute; border-radius: 2px; cursor: pointer; }
|
||||
.script-hl.drafting { background: rgba(90,176,122,.25); border: 1.5px dashed #5ab07a; pointer-events: none; }
|
||||
.script-empty { color: #888; font-size: 12px; padding: 20px; }
|
||||
.script-badge { margin-left: 6px; color: #7aa6d6; font-size: 11px; }
|
||||
.ann.selected { box-shadow: inset 3px 0 0 #5ab07a; }
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@
|
|||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="tl">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
|
||||
<!-- pdf.js (script pane) -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
if (window.pdfjsLib) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,23 @@
|
|||
(seq changed) (assoc :changed changed)
|
||||
(seq deleted) (assoc :deleted deleted))))})))
|
||||
|
||||
;; --- realtime: live peer deltas over a websocket --------------------------
|
||||
|
||||
(defonce ^:private socket (atom nil))
|
||||
|
||||
(defn connect-scene!
|
||||
"Open (or replace) the websocket that streams peer scene deltas for project
|
||||
`id`. Incoming messages dispatch ::peer-delta, which merges them in."
|
||||
[id]
|
||||
(when-let [s @socket] (.close s))
|
||||
(when id
|
||||
(let [proto (if (= "https:" (.. js/window -location -protocol)) "wss:" "ws:")
|
||||
url (str proto "//" (.. js/window -location -hostname) ":9001/ws/projects/" id "/")
|
||||
s (js/WebSocket. url)]
|
||||
(reset! socket s)
|
||||
(set! (.-onmessage s)
|
||||
(fn [e] (rf/dispatch [:tl.events/peer-delta (->clj (js/JSON.parse (.-data e)))]))))))
|
||||
|
||||
(defn create-project! [name otio clip]
|
||||
(let [fd (js/FormData.)]
|
||||
(.append fd "name" name)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
(rf/reg-fx :refresh-project-detail (fn [id] (api/fetch-project-detail! id)))
|
||||
(rf/reg-fx :put-scene (fn [{:keys [id changed deleted]}] (api/put-scene! id changed deleted)))
|
||||
(rf/reg-fx :create-project (fn [{:keys [name otio clip]}] (api/create-project! name otio clip)))
|
||||
(rf/reg-fx :connect-scene (fn [id] (api/connect-scene! id)))
|
||||
(rf/reg-fx :goto (fn [hash] (set! (.. js/window -location -hash) hash)))
|
||||
(rf/reg-fx :login (fn [{:keys [username password]}] (api/login! username password)))
|
||||
(rf/reg-fx :logout (fn [_] (api/logout!)))
|
||||
|
|
@ -61,9 +62,23 @@
|
|||
(assoc :scene (update base :groups merge anns))
|
||||
(assoc :view (:view db/default-db))
|
||||
(assoc-in [:load :status] :ready))]
|
||||
(cond-> {:db next-db}
|
||||
(cond-> {:db next-db :connect-scene (:id detail)}
|
||||
(thumbnails-pending? detail) (assoc :poll-thumbnails (:id detail))))))
|
||||
|
||||
;; A peer saved: merge their attributed groups in (and drop deletions). We keep
|
||||
;; any group we're currently editing (flagged :draft) so a peer save can't yank
|
||||
;; an in-progress edit out from under us — our own save is authoritative for it.
|
||||
(rf/reg-event-db
|
||||
::peer-delta
|
||||
(fn [db [_ {:keys [changed deleted]}]]
|
||||
(let [restored (scene/restore-annotations changed)
|
||||
drafts (into #{} (keep (fn [[gid g]] (when (:draft g) gid))
|
||||
(get-in db [:scene :groups])))]
|
||||
(update-in db [:scene :groups]
|
||||
(fn [groups]
|
||||
(-> (apply dissoc groups (map keyword deleted))
|
||||
(into (remove (fn [[gid _]] (contains? drafts gid)) restored))))))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::refresh-project-detail
|
||||
(fn [{:keys [db]} _]
|
||||
|
|
@ -92,6 +107,34 @@
|
|||
|
||||
;; --- view -----------------------------------------------------------------
|
||||
|
||||
(rf/reg-event-db ::set-pane (fn [db [_ p]] (assoc-in db [:view :pane] p)))
|
||||
|
||||
;; 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)))))
|
||||
|
||||
;; 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})))
|
||||
|
||||
(rf/reg-event-db ::set-playhead
|
||||
(fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf)))
|
||||
(rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p)))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
(rf/reg-sub ::projects (fn [db] (:projects db)))
|
||||
(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 ::pane (fn [db] (get-in db [:view :pane] :annotations)))
|
||||
(rf/reg-sub ::highlighting (fn [db] (get-in db [:view :highlighting])))
|
||||
(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 ::create-error (fn [db] (:create-error db)))
|
||||
|
|
@ -81,6 +84,7 @@
|
|||
: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
|
||||
:broken (boolean reason) :reason reason
|
||||
:start (or (ffirst bars) 0) :bars bars}))))
|
||||
(sort-by (juxt :broken :start)) ; broken annotations sink to the bottom
|
||||
|
|
|
|||
|
|
@ -290,9 +290,11 @@
|
|||
(doall
|
||||
(for [a anns]
|
||||
^{:key (:id a)}
|
||||
[:div.ann {:id (str "ann-" (name (:id a))) :class (when (= (:id a) active) "active")}
|
||||
[:div.ann {:id (str "ann-" (name (:id a)))
|
||||
:class (when (= (:id a) active) "active")}
|
||||
[:div.ann-head
|
||||
[:div.ann-title (when (:broken a) [:span {:title (:reason a)} "⚠ "]) (:name a)]
|
||||
[:div.ann-title (when (:broken a) [:span {:title (:reason a)} "⚠ "]) (:name a)
|
||||
(when (seq (:script a)) [:span.script-badge {:title "linked to script"} "¶"])]
|
||||
[:div.ann-actions
|
||||
[jump-control open a]
|
||||
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
|
||||
|
|
@ -356,6 +358,10 @@
|
|||
(when (= pt :new)
|
||||
[:div.mark-row [:div.pt-input.active [:div.pt-text "click a clip…"]]])
|
||||
[: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 {:on-click #(rf/dispatch [::events/start-highlighting gid])}
|
||||
(str "+ Add script annotation"
|
||||
(when-let [n (seq (:script d))] (str " (" (count n) ")")))]
|
||||
[:div.form-actions
|
||||
[:button.save {:disabled (or (str/blank? (:name d)) (empty? (:marks d)))
|
||||
:on-click #(put (dissoc d :draft))} "Save"]
|
||||
|
|
@ -399,15 +405,127 @@
|
|||
(.addEventListener js/document "pointermove" move)
|
||||
(.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.
|
||||
|
||||
(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]
|
||||
(when (and pg canvas)
|
||||
(let [^js vp (.getViewport pg #js {:scale pdf-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)))))
|
||||
|
||||
(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)) ; 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)}}]))])
|
||||
|
||||
(defn script-pane []
|
||||
(let [pages (r/atom (sorted-map)) loaded (atom nil) el (atom nil) scrolled (atom nil)]
|
||||
(fn []
|
||||
(let [url @(rf/subscribe [::subs/script-url])
|
||||
anns @(rf/subscribe [::subs/annotations])
|
||||
hl @(rf/subscribe [::subs/highlighting])
|
||||
active @(rf/subscribe [::subs/active-annotation])
|
||||
armed (:gid hl)
|
||||
hl-name (some #(when (= (:id %) armed) (:name %)) anns)]
|
||||
(when (and url (exists? js/pdfjsLib) (not= url @loaded))
|
||||
(reset! loaded url) (reset! pages (sorted-map))
|
||||
(-> (.-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)}))))))))
|
||||
(.catch (fn [e] (js/console.error "pdf load failed" e)))))
|
||||
;; follow the playhead: scroll to the active annotation's first highlight
|
||||
(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"})))))
|
||||
[: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])} "✕"]]]
|
||||
[:div.script-hint
|
||||
(if url "Playing follows the script to each note. Edit an annotation → “Add script annotation” to link a passage."
|
||||
"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…"]))]]))))
|
||||
|
||||
(defn annot-pane []
|
||||
(let [pane @(rf/subscribe [::subs/pane])
|
||||
draft? (some? @(rf/subscribe [::subs/draft-group]))]
|
||||
[:div.pane-wrap
|
||||
[:div.pane-tabs
|
||||
[:button {:class (when (= pane :annotations) "active")
|
||||
:on-click #(rf/dispatch [::events/set-pane :annotations])} "Annotations"]
|
||||
[:button {:class (when (= pane :script) "active")
|
||||
:on-click #(rf/dispatch [::events/set-pane :script])} "Script"]]
|
||||
[:div.pane-body
|
||||
(if (= pane :script)
|
||||
[script-pane]
|
||||
(if draft? [annotation-form] [commentary]))]]))
|
||||
|
||||
(defn editor []
|
||||
(let [status @(rf/subscribe [::subs/status])
|
||||
draft? (some? @(rf/subscribe [::subs/draft-group]))
|
||||
proj @(rf/subscribe [::subs/project])
|
||||
authed? @(rf/subscribe [::subs/authed?])
|
||||
thumb-status @(rf/subscribe [::subs/thumbnail-status])]
|
||||
[:div.app
|
||||
[:div.top
|
||||
[:div.video-pane [video-monitor] [frame-readout]]
|
||||
[:div.annot-pane (if draft? [annotation-form] [commentary])]]
|
||||
[:div.annot-pane [annot-pane]]]
|
||||
[:div.divider-h {:on-pointer-down drag-top!}]
|
||||
[:div.timeline-pane
|
||||
[:div.project-bar
|
||||
|
|
@ -416,7 +534,11 @@
|
|||
(when (#{"pending" "running"} thumb-status)
|
||||
[:span.muted "thumbnails processing"])
|
||||
(when (= "failed" thumb-status)
|
||||
[:span.err "thumbnail generation failed"])]
|
||||
[:span.err "thumbnail generation failed"])
|
||||
(when authed?
|
||||
[:a.logout-link {:href "#" :on-click #(do (.preventDefault %)
|
||||
(rf/dispatch [::events/logout]))}
|
||||
"log out"])]
|
||||
[breadcrumbs]
|
||||
[toolbar]
|
||||
[:div.timeline-scroll
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue