feat: timeline links, network error handling, Tailscale access, mobile fixes
Backend / dev: - Bind dev server to 0.0.0.0 and allow Tailscale MagicDNS + tailnet-IP origins in ALLOWED_HOSTS / CORS / CSRF so the app is reachable over Tailscale - Unified, idempotent dev launcher (Django + media + shadow watch), hot reload, no manual compiles Frontend: - Migrate fetches to re-frame :http-xhrio with failure handling; surface network / load / save errors where the user expects them - Extract markdown + link formatting into tl.md: parse/serialize round-trip (recursive chip serialization fix), block markdown (headings/lists/paragraphs) - Timeline-opening links: navigate the stack to any reachable timeline; autocomplete lists all timelines (root included, orphans dropped) with a live preview that pushes the stack and reverts on commit/cancel - Annotation pane shows the current context's description with edit-in-context (drops into the parent timeline for marks, pops back when done) + root content - Fixes: deep-link playhead seek, replay-from-end, mobile input zoom, notch safe-area insets, autofocus the content field, freeze form to snapshot context Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3d3b031332
commit
fc25dfc016
12 changed files with 548 additions and 224 deletions
|
|
@ -17,7 +17,7 @@ python -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
.venv/bin/python manage.py migrate
|
.venv/bin/python manage.py migrate
|
||||||
DJANGO_SUPERUSER_PASSWORD=admin .venv/bin/python manage.py createsuperuser --noinput --username admin --email admin@example.com
|
DJANGO_SUPERUSER_PASSWORD=admin .venv/bin/python manage.py createsuperuser --noinput --username admin --email admin@example.com
|
||||||
.venv/bin/python manage.py seed_demo # demo project from tl/.../one_two_three.otio
|
.venv/bin/python manage.py seed_demo # demo project from tl/.../one_two_three.otio
|
||||||
.venv/bin/python manage.py runserver 9001
|
.venv/bin/python manage.py runserver 0.0.0.0:9001 # 0.0.0.0 so it's reachable over Tailscale/LAN
|
||||||
```
|
```
|
||||||
|
|
||||||
Admin: <http://127.0.0.1:9001/admin/> (admin / admin). Upload OTIO and inspect
|
Admin: <http://127.0.0.1:9001/admin/> (admin / admin). Upload OTIO and inspect
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ SECRET_KEY = 'django-insecure-it#u2wp)_qjx@cq61o*thw#*4_1*hk!%%lz#d+fww6^*l3j+zj
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'testserver']
|
# '.ts.net' matches any Tailscale MagicDNS name; the literal is the tailnet IP.
|
||||||
|
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'testserver', '.ts.net', '100.82.84.50']
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
@ -134,8 +135,18 @@ MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024
|
||||||
|
|
||||||
# The shadow-cljs dev frontend talks to this API cross-origin, with the session
|
# The shadow-cljs dev frontend talks to this API cross-origin, with the session
|
||||||
# cookie, so allow its origin + credentials.
|
# cookie, so allow its origin + credentials. Over Tailscale the frontend may be
|
||||||
|
# reached either by MagicDNS name (*.ts.net) or by the raw tailnet IP
|
||||||
|
# (100.64.0.0/10), both on :8280 — match both forms.
|
||||||
CORS_ALLOWED_ORIGINS = ['http://localhost:8280', 'http://127.0.0.1:8280']
|
CORS_ALLOWED_ORIGINS = ['http://localhost:8280', 'http://127.0.0.1:8280']
|
||||||
|
CORS_ALLOWED_ORIGIN_REGEXES = [r'^http://[\w.-]+\.ts\.net:8280$',
|
||||||
|
r'^http://100\.\d{1,3}\.\d{1,3}\.\d{1,3}:8280$']
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
# Cross-origin POSTs carry the session cookie, so Django's CSRF check needs the
|
||||||
|
# frontend origins trusted (Origin header must match for unsafe methods).
|
||||||
|
CSRF_TRUSTED_ORIGINS = ['http://localhost:8280', 'http://127.0.0.1:8280',
|
||||||
|
'https://*.ts.net', 'http://*.ts.net',
|
||||||
|
'http://100.*']
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,43 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Dev launcher: runs the shadow-cljs watch AND the range-capable media server
|
# Dev launcher: backend + frontend + media, all hot-reloading, all bound to
|
||||||
# together, and tears both down on exit (Ctrl-C kills the whole group).
|
# 0.0.0.0 so they're reachable over Tailscale/LAN (e.g. a phone). One Ctrl-C
|
||||||
|
# tears the whole process group down.
|
||||||
#
|
#
|
||||||
# npm run dev
|
# npm run dev (from tl/)
|
||||||
#
|
#
|
||||||
|
# Nothing here needs a manual `shadow-cljs compile` — `watch` recompiles and
|
||||||
|
# hot-reloads the frontend on every save. Django's runserver and the media
|
||||||
|
# server reload themselves too. Override ports with API_PORT / MEDIA_PORT.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.." # -> tl/
|
||||||
|
ROOT="$(cd .. && pwd)" # -> repo root, where Django's manage.py + .venv live
|
||||||
|
|
||||||
|
API_PORT="${API_PORT:-9001}"
|
||||||
|
PY="$ROOT/.venv/bin/python"
|
||||||
|
|
||||||
cleanup() { kill 0 2>/dev/null || true; }
|
cleanup() { kill 0 2>/dev/null || true; }
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
echo "[dev] starting media server (port ${MEDIA_PORT:-8281}, range-capable, autoreload)"
|
# Free our dev ports from any stale listeners a previous run left behind, so the
|
||||||
|
# launcher is safe to re-run without "Address already in use".
|
||||||
|
free_port() {
|
||||||
|
local p="$1" pids
|
||||||
|
pids=$(ss -tlnpH "sport = :$p" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort -u) || true
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
echo "[dev] freeing port $p (killing $pids)"
|
||||||
|
kill $pids 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
for p in "$API_PORT" 8280 "${MEDIA_PORT:-8281}"; do free_port "$p"; done
|
||||||
|
|
||||||
|
echo "[dev] Django API on 0.0.0.0:${API_PORT} (autoreload)"
|
||||||
|
( cd "$ROOT" && exec "$PY" manage.py runserver "0.0.0.0:${API_PORT}" ) &
|
||||||
|
|
||||||
|
echo "[dev] media server on 0.0.0.0:${MEDIA_PORT:-8281} (range-capable, autoreload)"
|
||||||
python3 dev/media_server.py &
|
python3 dev/media_server.py &
|
||||||
|
|
||||||
echo "[dev] starting shadow-cljs watch app (port 8280)"
|
echo "[dev] shadow-cljs watch app on 0.0.0.0:8280 (hot reload)"
|
||||||
npx shadow-cljs watch app &
|
npx shadow-cljs watch app &
|
||||||
|
|
||||||
wait
|
wait
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ body { overflow: hidden; }
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
background: #0c0c0c;
|
background: #0c0c0c;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
|
/* viewport-fit=cover lets content slide under the notch / rounded corners /
|
||||||
|
home indicator; inset it back so the video isn't covered. box-sizing keeps
|
||||||
|
total height at 100dvh (timeline-pane is flex:1 and absorbs the padding). */
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||||
|
env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- top region: video | annotation ------------------------------------ */
|
/* --- top region: video | annotation ------------------------------------ */
|
||||||
|
|
@ -83,6 +89,12 @@ body { overflow: hidden; }
|
||||||
.jump-pop button:hover { background: #2a4a6a; }
|
.jump-pop button:hover { background: #2a4a6a; }
|
||||||
|
|
||||||
/* --- commentary head + actions ----------------------------------------- */
|
/* --- commentary head + actions ----------------------------------------- */
|
||||||
|
/* the current context's own description, atop the commentary */
|
||||||
|
.ctx-content { display: flex; align-items: flex-start; gap: 8px;
|
||||||
|
padding: 8px 10px; margin-bottom: 8px; border-bottom: 1px solid #222; }
|
||||||
|
.ctx-content .md, .ctx-content .muted { flex: 1; min-width: 0; }
|
||||||
|
.ctx-content .muted { font-style: italic; }
|
||||||
|
.ctx-content .edit-btn { flex-shrink: 0; }
|
||||||
.commentary-head { margin-bottom: 8px; }
|
.commentary-head { margin-bottom: 8px; }
|
||||||
.add-btn {
|
.add-btn {
|
||||||
width: 100%; background: #1c2a38; color: #cfe3f5;
|
width: 100%; background: #1c2a38; color: #cfe3f5;
|
||||||
|
|
@ -137,7 +149,10 @@ body { overflow: hidden; }
|
||||||
color: #7aa6d6; margin-top: 4px; }
|
color: #7aa6d6; margin-top: 4px; }
|
||||||
|
|
||||||
/* contenteditable content surface + inline link chips */
|
/* contenteditable content surface + inline link chips */
|
||||||
.content-editor { white-space: pre-wrap; word-break: break-word; outline: none; cursor: text; }
|
/* flex-shrink:0 so the surface grows with its content (and the .form scrolls)
|
||||||
|
instead of the flex column squashing it down to min-height. */
|
||||||
|
.content-editor { white-space: pre-wrap; word-break: break-word; outline: none; cursor: text;
|
||||||
|
flex-shrink: 0; height: auto; }
|
||||||
.content-editor:empty:before { content: attr(data-placeholder); color: #666; }
|
.content-editor:empty:before { content: attr(data-placeholder); color: #666; }
|
||||||
.link-chip { display: inline-flex; align-items: center; gap: 3px; vertical-align: baseline;
|
.link-chip { display: inline-flex; align-items: center; gap: 3px; vertical-align: baseline;
|
||||||
background: #173049; color: #9fd0ff; border: 1px solid #2f5d86; border-radius: 4px;
|
background: #173049; color: #9fd0ff; border: 1px solid #2f5d86; border-radius: 4px;
|
||||||
|
|
@ -182,6 +197,7 @@ body { overflow: hidden; }
|
||||||
overflow: hidden; text-overflow: ellipsis; }
|
overflow: hidden; text-overflow: ellipsis; }
|
||||||
.pt-dropdown li.hi { background: #2a4a6a; }
|
.pt-dropdown li.hi { background: #2a4a6a; }
|
||||||
.pt-dropdown .cand-group { color: #789; font-size: 11px; }
|
.pt-dropdown .cand-group { color: #789; font-size: 11px; }
|
||||||
|
.pt-dropdown .cand-kind { color: #7aa6d6; }
|
||||||
.pt-dropdown .link-f { color: #6f9; opacity: .7; }
|
.pt-dropdown .link-f { color: #6f9; opacity: .7; }
|
||||||
|
|
||||||
.link-insert { display: flex; align-items: flex-start; gap: 6px; align-self: stretch; }
|
.link-insert { display: flex; align-items: flex-start; gap: 6px; align-self: stretch; }
|
||||||
|
|
@ -307,7 +323,12 @@ body { overflow: hidden; }
|
||||||
|
|
||||||
/* --- landing pages (project list / create) ----------------------------- */
|
/* --- landing pages (project list / create) ----------------------------- */
|
||||||
.home { min-height: 100vh; box-sizing: border-box; max-width: 680px; margin: 0 auto;
|
.home { min-height: 100vh; box-sizing: border-box; max-width: 680px; margin: 0 auto;
|
||||||
padding: 32px 20px; color: #ddd; font-family: sans-serif; background: #0c0c0c; }
|
padding: 32px 20px; color: #ddd; font-family: sans-serif; background: #0c0c0c;
|
||||||
|
/* keep clear of the notch / rounded corners / home indicator */
|
||||||
|
padding-top: calc(32px + env(safe-area-inset-top));
|
||||||
|
padding-right: calc(20px + env(safe-area-inset-right));
|
||||||
|
padding-bottom: calc(32px + env(safe-area-inset-bottom));
|
||||||
|
padding-left: calc(20px + env(safe-area-inset-left)); }
|
||||||
.home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20px; }
|
.home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20px; }
|
||||||
.home-head h1 { font-size: 22px; margin: 0; color: #cfe3f5; letter-spacing: 1px; }
|
.home-head h1 { font-size: 22px; margin: 0; color: #cfe3f5; letter-spacing: 1px; }
|
||||||
.home a { color: #7aa6d6; text-decoration: none; }
|
.home a { color: #7aa6d6; text-decoration: none; }
|
||||||
|
|
@ -351,3 +372,11 @@ body { overflow: hidden; }
|
||||||
.script-empty { color: #888; font-size: 12px; padding: 20px; }
|
.script-empty { color: #888; font-size: 12px; padding: 20px; }
|
||||||
.script-badge { margin-left: 6px; color: #7aa6d6; font-size: 11px; }
|
.script-badge { margin-left: 6px; color: #7aa6d6; font-size: 11px; }
|
||||||
.ann.selected { box-shadow: inset 3px 0 0 #5ab07a; }
|
.ann.selected { box-shadow: inset 3px 0 0 #5ab07a; }
|
||||||
|
|
||||||
|
/* iOS Safari zooms the whole page when you focus a control whose font-size is
|
||||||
|
< 16px. On touch devices bump every form control to 16px so tapping an input
|
||||||
|
never zooms; desktop keeps its denser sizing. !important to beat the various
|
||||||
|
per-control font-size rules above without re-listing each selector. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
input, textarea, select, .content-editor { font-size: 16px !important; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,70 @@
|
||||||
(ns tl.api
|
(ns tl.api
|
||||||
"Thin fetch wrapper around the Django backend. Functions dispatch re-frame
|
"Request builders for the Django backend, shaped as data for re-frame's
|
||||||
events on completion (qualified keywords, to avoid a require cycle with
|
:http-xhrio effect (day8.re-frame.http-fx). Same host as the page, port 9001,
|
||||||
tl.events). Same host as the page, port 9001, with the session cookie."
|
with the session cookie. Event handlers add :on-success / :on-failure and
|
||||||
(:require [re-frame.core :as rf]))
|
return these maps — nothing here performs the request. The websocket below is
|
||||||
|
the one piece that can't ride xhrio, so it still dispatches directly."
|
||||||
|
(:require [ajax.core :as ajax]
|
||||||
|
[re-frame.core :as rf]))
|
||||||
|
|
||||||
(def base
|
(def base
|
||||||
(str (.. js/window -location -protocol) "//" (.. js/window -location -hostname) ":9001"))
|
(str (.. js/window -location -protocol) "//" (.. js/window -location -hostname) ":9001"))
|
||||||
|
|
||||||
(defn- ->clj [x] (js->clj x :keywordize-keys true))
|
(def ^:private json-resp (ajax/json-response-format {:keywords? true}))
|
||||||
|
|
||||||
(defn- GET [path]
|
(defn GET [path opts]
|
||||||
(-> (js/fetch (str base path) #js {:credentials "include"})
|
(merge {:method :get :uri (str base path)
|
||||||
(.then (fn [r] (when (.-ok r) (.json r))))
|
:with-credentials true :response-format json-resp}
|
||||||
(.then ->clj)))
|
opts))
|
||||||
|
|
||||||
(defn- POST [path body on-done]
|
(defn GET-url
|
||||||
(-> (js/fetch (str base path)
|
"GET an absolute URL (a media file, e.g. the OTIO json). No session cookie —
|
||||||
#js {:method "POST" :credentials "include"
|
it's public media, and credentialed cross-origin would need extra CORS."
|
||||||
:headers #js {"Content-Type" "application/json"}
|
[url opts]
|
||||||
:body (js/JSON.stringify (clj->js body))})
|
(merge {:method :get :uri url :response-format json-resp} opts))
|
||||||
(.then (fn [r] (.then (.json r) #(on-done (.-ok r) (->clj %)))))))
|
|
||||||
|
|
||||||
(defn fetch-me! []
|
(defn POST [path body opts]
|
||||||
(-> (GET "/api/me/") (.then #(rf/dispatch [:tl.events/set-auth %]))))
|
(merge {:method :post :uri (str base path)
|
||||||
|
:with-credentials true :params body
|
||||||
|
:format (ajax/json-request-format) :response-format json-resp}
|
||||||
|
opts))
|
||||||
|
|
||||||
(defn login! [username password]
|
(defn upload
|
||||||
(POST "/api/login/" {:username username :password password}
|
"Multipart POST: the FormData rides as the raw body so cljs-ajax skips :format."
|
||||||
(fn [ok body] (rf/dispatch (if ok [:tl.events/login-ok body]
|
[path form-data opts]
|
||||||
[:tl.events/login-error (:detail body)])))))
|
(merge {:method :post :uri (str base path)
|
||||||
|
:with-credentials true :body form-data :response-format json-resp}
|
||||||
|
opts))
|
||||||
|
|
||||||
(defn logout! []
|
(defn put-scene
|
||||||
(POST "/api/logout/" {} (fn [_ body] (rf/dispatch [:tl.events/set-auth body]))))
|
"PUT a scene delta — {:changed _ :deleted _}, empty keys dropped — for project `id`."
|
||||||
|
[id {:keys [changed deleted]} opts]
|
||||||
|
(merge {:method :put :uri (str base "/api/projects/" id "/scene/")
|
||||||
|
:with-credentials true
|
||||||
|
:params (cond-> {}
|
||||||
|
(seq changed) (assoc :changed changed)
|
||||||
|
(seq deleted) (assoc :deleted deleted))
|
||||||
|
:format (ajax/json-request-format) :response-format json-resp}
|
||||||
|
opts))
|
||||||
|
|
||||||
(defn fetch-projects! []
|
(defn error-message
|
||||||
(-> (GET "/api/projects/") (.then #(rf/dispatch [:tl.events/set-projects %]))))
|
"Human message for an :http-xhrio failure map. Prefer the backend's {:detail};
|
||||||
|
status 0 means the request never reached the server (down / network / CORS) —
|
||||||
(defn fetch-project-detail! [id]
|
the common case behind an unreachable backend."
|
||||||
(-> (GET (str "/api/projects/" id "/"))
|
[{:keys [status response]} fallback]
|
||||||
(.then #(rf/dispatch [:tl.events/project-detail-refreshed %]))))
|
(or (:detail response)
|
||||||
|
(case status
|
||||||
(defn load-project!
|
0 "Can't reach the server."
|
||||||
"Detail → OTIO file → scene, then hand it all to ::project-ready in one shot."
|
403 "Not allowed."
|
||||||
[id]
|
404 "Not found."
|
||||||
(-> (GET (str "/api/projects/" id "/"))
|
500 "Server error."
|
||||||
(.then (fn [detail]
|
nil)
|
||||||
(-> (js/fetch (:otio detail))
|
fallback))
|
||||||
(.then #(.json %))
|
|
||||||
(.then (fn [otio]
|
|
||||||
(-> (GET (str "/api/projects/" id "/scene/"))
|
|
||||||
(.then (fn [scene]
|
|
||||||
(rf/dispatch [:tl.events/project-ready
|
|
||||||
detail (->clj otio) scene])))))))))))
|
|
||||||
|
|
||||||
(defn put-scene!
|
|
||||||
"Persist a delta — {changed deleted} — for the current project (fire & forget)."
|
|
||||||
[id changed deleted]
|
|
||||||
(when id
|
|
||||||
(js/fetch (str base "/api/projects/" id "/scene/")
|
|
||||||
#js {:method "PUT" :credentials "include"
|
|
||||||
:headers #js {"Content-Type" "application/json"}
|
|
||||||
:body (js/JSON.stringify
|
|
||||||
(clj->js (cond-> {}
|
|
||||||
(seq changed) (assoc :changed changed)
|
|
||||||
(seq deleted) (assoc :deleted deleted))))})))
|
|
||||||
|
|
||||||
;; --- realtime: live peer deltas over a websocket --------------------------
|
;; --- realtime: live peer deltas over a websocket --------------------------
|
||||||
|
;; Not a fetch, so it can't ride :http-xhrio; it dispatches ::peer-delta itself.
|
||||||
|
|
||||||
|
(defn- ->clj [x] (js->clj x :keywordize-keys true))
|
||||||
|
|
||||||
(defonce ^:private socket (atom nil))
|
(defonce ^:private socket (atom nil))
|
||||||
|
|
||||||
|
|
@ -78,17 +78,6 @@
|
||||||
url (str proto "//" (.. js/window -location -hostname) ":9001/ws/projects/" id "/")
|
url (str proto "//" (.. js/window -location -hostname) ":9001/ws/projects/" id "/")
|
||||||
s (js/WebSocket. url)]
|
s (js/WebSocket. url)]
|
||||||
(reset! socket s)
|
(reset! socket s)
|
||||||
|
(set! (.-onerror s) (fn [_] (js/console.warn "scene sync socket error; peer updates paused")))
|
||||||
(set! (.-onmessage s)
|
(set! (.-onmessage s)
|
||||||
(fn [e] (rf/dispatch [:tl.events/peer-delta (->clj (js/JSON.parse (.-data e)))]))))))
|
(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)
|
|
||||||
(.append fd "otio" otio)
|
|
||||||
(.append fd "clip" clip)
|
|
||||||
(-> (js/fetch (str base "/api/projects/") #js {:method "POST" :credentials "include" :body fd})
|
|
||||||
(.then (fn [r] (.then (.json r) #(vector (.-ok r) (->clj %)))))
|
|
||||||
(.then (fn [[ok body]]
|
|
||||||
(if ok
|
|
||||||
(rf/dispatch [:tl.events/project-created body])
|
|
||||||
(rf/dispatch [:tl.events/project-create-error (:detail body)])))))))
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
(ns tl.db)
|
(ns tl.db)
|
||||||
|
|
||||||
(def default-db
|
(def default-db
|
||||||
{:load {:status :idle} ; :idle | :loading | :ready | :error
|
{:load {:status :idle :error nil} ; :idle | :loading | :ready | :error
|
||||||
:page :list ; :list | :create | :editor
|
:page :list ; :list | :create | :editor
|
||||||
:auth {:authenticated false} ; from /api/me/
|
:auth {:authenticated false} ; from /api/me/
|
||||||
:projects nil ; project list (when signed in)
|
:projects nil ; project list (when signed in)
|
||||||
:project nil ; current project detail {:id :name :clip :otio}
|
:project nil ; current project detail {:id :name :clip :otio}
|
||||||
:create-error nil
|
:create-error nil ; new-project upload failure
|
||||||
|
:login-error nil ; sign-in failure
|
||||||
|
:net-error nil ; backend unreachable (fetch-me / logout)
|
||||||
|
:projects-error nil ; project-list fetch failure
|
||||||
|
:save-error nil ; annotation save/delete failure
|
||||||
:fps (/ 24000 1001)
|
:fps (/ 24000 1001)
|
||||||
|
|
||||||
;; the whole scene graph (see tl.scene). Seeded from OTIO at load.
|
;; the whole scene graph (see tl.scene). Seeded from OTIO at load.
|
||||||
|
|
|
||||||
|
|
@ -264,22 +264,49 @@
|
||||||
(fn [db [_ gid content]] (assoc-in db [:scene :groups gid :content] content)))
|
(fn [db [_ gid content]] (assoc-in db [:scene :groups gid :content] content)))
|
||||||
|
|
||||||
;; Linking is an edit-mode sub-task, like script highlighting: "Insert link"
|
;; Linking is an edit-mode sub-task, like script highlighting: "Insert link"
|
||||||
;; remembers the playhead and arms the mode; while armed, clicking the timeline
|
;; snapshots the playhead AND the stack, then arms the mode. While armed, the
|
||||||
;; or picking from the autocomplete inserts a link chip at the caret. Esc/Cancel
|
;; autocomplete previews each candidate — frame candidates move the playhead,
|
||||||
;; restores the playhead (the arrow-key previews were just seeks).
|
;; timeline candidates navigate the stack to that timeline. Esc/Cancel and commit
|
||||||
|
;; both restore the snapshot (you return to where you were editing).
|
||||||
(rf/reg-event-db ::start-linking
|
(rf/reg-event-db ::start-linking
|
||||||
(fn [db [_ gid]]
|
(fn [db [_ gid]]
|
||||||
(let [ctx (peek (get-in db [:view :stack]))]
|
(let [ctx (peek (get-in db [:view :stack]))]
|
||||||
(assoc-in db [:view :linking]
|
(assoc-in db [:view :linking]
|
||||||
{:gid gid :ctx ctx :playhead (scene/playhead (:view db) ctx)}))))
|
{:gid gid :ctx ctx :playhead (scene/playhead (:view db) ctx)
|
||||||
(rf/reg-event-db ::stop-linking (fn [db _] (assoc-in db [:view :linking] nil)))
|
:stack (get-in db [:view :stack])}))))
|
||||||
(rf/reg-event-fx ::cancel-linking
|
|
||||||
(fn [{:keys [db]} _]
|
(declare enter-ctx) ; defined below with the stack-nav events
|
||||||
(let [{:keys [ctx playhead]} (get-in db [:view :linking])
|
|
||||||
sf (scene/local->source (scene/content-segments (:scene db) ctx) playhead)]
|
(defn- restore-linking
|
||||||
{:db (-> db (assoc-in [:view :linking] nil)
|
"Drop link mode and return to the snapshotted stack + playhead, reseeking."
|
||||||
(assoc-in [:view :playheads ctx] playhead))
|
[{:keys [db]}]
|
||||||
:player/seek (when sf (/ sf (:fps db)))})))
|
(let [{:keys [ctx playhead stack]} (get-in db [:view :linking])
|
||||||
|
db (cond-> (assoc-in db [:view :linking] nil)
|
||||||
|
stack (assoc-in [:view :stack] stack)
|
||||||
|
playhead (assoc-in [:view :playheads ctx] playhead))
|
||||||
|
segs (scene/content-segments (:scene db) ctx)
|
||||||
|
sf (scene/local->source segs playhead)]
|
||||||
|
(sync-route {:db db :player/pause true :player/seek (when sf (/ sf (:fps db)))} db)))
|
||||||
|
|
||||||
|
(rf/reg-event-fx ::stop-linking (fn [cofx _] (restore-linking cofx)))
|
||||||
|
(rf/reg-event-fx ::cancel-linking (fn [cofx _] (restore-linking cofx)))
|
||||||
|
|
||||||
|
;; Open a timeline by its full stack path (root → … → it) — chip clicks and
|
||||||
|
;; timeline-candidate previews both go through here.
|
||||||
|
(rf/reg-event-fx ::open-stack
|
||||||
|
(fn [{:keys [db]} [_ path]] (enter-ctx db (constantly (vec path)))))
|
||||||
|
|
||||||
|
;; Preview a frame candidate while link mode is armed: a timeline preview may have
|
||||||
|
;; moved the stack, so snap back to the editing context before seeking the frame.
|
||||||
|
(rf/reg-event-fx ::preview-frame
|
||||||
|
(fn [{:keys [db]} [_ local]]
|
||||||
|
(let [stack (get-in db [:view :linking :stack])
|
||||||
|
ctx (peek stack)
|
||||||
|
db (-> db (assoc-in [:view :stack] stack)
|
||||||
|
(assoc-in [:view :playheads ctx] local))
|
||||||
|
segs (scene/content-segments (:scene db) ctx)
|
||||||
|
sf (scene/local->source segs local)]
|
||||||
|
{:db db :player/seek (when sf (/ sf (:fps db)))})))
|
||||||
|
|
||||||
(rf/reg-event-fx ::set-playhead
|
(rf/reg-event-fx ::set-playhead
|
||||||
(fn [{:keys [db]} [_ ctx lf]]
|
(fn [{:keys [db]} [_ ctx lf]]
|
||||||
|
|
@ -325,6 +352,21 @@
|
||||||
|
|
||||||
(rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit)
|
(rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit)
|
||||||
(assoc-in [:view :pt] :new))))
|
(assoc-in [:view :pt] :new))))
|
||||||
|
;; Edit the annotation you're currently inside: drop into its parent timeline so
|
||||||
|
;; its marks are editable there, remembering to pop back when done. Root has no
|
||||||
|
;; parent (and no marks) — edit it in place.
|
||||||
|
(rf/reg-event-fx ::edit-here
|
||||||
|
(fn [{:keys [db]} [_ gid]]
|
||||||
|
(let [root? (nil? (get-in db [:scene :groups gid :parent]))
|
||||||
|
fx (if root? {:db db} (enter-ctx db pop))]
|
||||||
|
(update fx :db #(-> % (assoc-in [:scene :groups gid :draft] :edit)
|
||||||
|
(assoc-in [:view :pt] :new)
|
||||||
|
(assoc-in [:view :edit-return] (when-not root? gid)))))))
|
||||||
|
(rf/reg-event-fx ::finish-edit
|
||||||
|
(fn [{:keys [db]} _]
|
||||||
|
(if-let [g (get-in db [:view :edit-return])]
|
||||||
|
(enter-ctx (assoc-in db [:view :edit-return] nil) #(conj % g))
|
||||||
|
{:db db})))
|
||||||
(rf/reg-event-db ::draft-focus (fn [db [_ pt]] (assoc-in db [:view :pt] pt)))
|
(rf/reg-event-db ::draft-focus (fn [db [_ pt]] (assoc-in db [:view :pt] pt)))
|
||||||
;; cancelling a draft is local only; saving a real annotation / deleting one
|
;; cancelling a draft is local only; saving a real annotation / deleting one
|
||||||
;; pushes a delta to the backend (which merges + attributes it).
|
;; pushes a delta to the backend (which merges + attributes it).
|
||||||
|
|
@ -338,10 +380,11 @@
|
||||||
(fn [{:keys [db]} [_ gid g orig]]
|
(fn [{:keys [db]} [_ gid g orig]]
|
||||||
(let [g (editable-group g)
|
(let [g (editable-group g)
|
||||||
patch (group-patch orig g)
|
patch (group-patch orig g)
|
||||||
id (get-in db [:project :id])]
|
root? (nil? (:parent g)) ; the root timeline persists whole
|
||||||
|
id (get-in db [:project :id])] ; (no diff: it'd lose :type/:marks)
|
||||||
(cond-> {:db (-> db (assoc-in [:scene :groups gid] g) (assoc :save-error nil))}
|
(cond-> {:db (-> db (assoc-in [:scene :groups gid] g) (assoc :save-error nil))}
|
||||||
(and id (= :annotation (:type g)) (seq patch))
|
(and id (seq patch))
|
||||||
(assoc :http-xhrio (api/put-scene id {:changed {gid patch}}
|
(assoc :http-xhrio (api/put-scene id {:changed {gid (if root? g patch)}}
|
||||||
{:on-success [::scene-saved]
|
{:on-success [::scene-saved]
|
||||||
:on-failure [::save-error]}))))))
|
:on-failure [::save-error]}))))))
|
||||||
(rf/reg-event-fx ::delete-annotation
|
(rf/reg-event-fx ::delete-annotation
|
||||||
|
|
|
||||||
135
tl/src/tl/md.cljs
Normal file
135
tl/src/tl/md.cljs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
(ns tl.md
|
||||||
|
"Markdown + link formatting for annotation content. Owns the on-the-wire string
|
||||||
|
form — plain text with `[label](mark:ref@at)` link tokens and a small inline
|
||||||
|
markdown subset (**bold**, *italic*, `code`) — and the round-trip around it:
|
||||||
|
|
||||||
|
parse-content content string -> [:text str] / [:link {…}] segments
|
||||||
|
link-token {:label :ref :at} -> a link token string
|
||||||
|
serialize-editor contenteditable DOM -> content string
|
||||||
|
inline plain text -> Hiccup (inline markdown)
|
||||||
|
|
||||||
|
No reagent, no scene graph, no DOM construction here — just the format. Link
|
||||||
|
*resolution* (which timeline frame a {:ref :at} points at) lives in tl.scene;
|
||||||
|
chip construction lives in tl.views."
|
||||||
|
(:require [clojure.string :as str]))
|
||||||
|
|
||||||
|
;; --- link tokens: [label](mark:ref@at) -----------------------------------
|
||||||
|
|
||||||
|
;; Two link kinds share the [label](scheme…) form:
|
||||||
|
;; frame [label](mark:ref@at) jump the playhead to a spot
|
||||||
|
;; timeline [label](timeline:gid) push that timeline onto the stack
|
||||||
|
(def ^:private link-re
|
||||||
|
"\\[([^\\]]*)\\]\\((?:mark:([^@)]+)@(-?\\d+)|timeline:([^)]+))\\)")
|
||||||
|
|
||||||
|
(defn link-token
|
||||||
|
"The token for a link map: {:kind :frame :label :ref :at} (the default) or
|
||||||
|
{:kind :timeline :label :ref}."
|
||||||
|
[{:keys [kind label ref at]}]
|
||||||
|
(if (= kind :timeline)
|
||||||
|
(str "[" label "](timeline:" (name ref) ")")
|
||||||
|
(str "[" label "](mark:" (name ref) "@" at ")")))
|
||||||
|
|
||||||
|
(defn parse-content
|
||||||
|
"Split content `s` into [:text str] / [:link {…}] segments. Each link carries
|
||||||
|
:kind — :frame (with :ref/:at) or :timeline (with :ref)."
|
||||||
|
[s]
|
||||||
|
(if (empty? s)
|
||||||
|
[]
|
||||||
|
(let [re (js/RegExp. link-re "g")]
|
||||||
|
(loop [out [] last 0]
|
||||||
|
(if-let [m (.exec re s)]
|
||||||
|
(let [idx (.-index m) pre (subs s last idx)
|
||||||
|
link (if (aget m 4)
|
||||||
|
{:kind :timeline :label (aget m 1) :ref (keyword (aget m 4))}
|
||||||
|
{:kind :frame :label (aget m 1) :ref (keyword (aget m 2))
|
||||||
|
:at (js/parseInt (aget m 3) 10)})]
|
||||||
|
(recur (cond-> out
|
||||||
|
(seq pre) (conj [:text pre])
|
||||||
|
:always (conj [:link link]))
|
||||||
|
(+ idx (.-length (aget m 0)))))
|
||||||
|
(let [tail (subs s last)]
|
||||||
|
(cond-> out (seq tail) (conj [:text tail]))))))))
|
||||||
|
|
||||||
|
;; --- contenteditable DOM -> content string -------------------------------
|
||||||
|
|
||||||
|
(defn- serialize-node [^js n]
|
||||||
|
(cond
|
||||||
|
(= 3 (.-nodeType n)) (.-textContent n)
|
||||||
|
(= "BR" (.-tagName n)) "\n"
|
||||||
|
(and (.-classList n) (.contains (.-classList n) "link-chip"))
|
||||||
|
(link-token (if (= "timeline" (.. n -dataset -kind))
|
||||||
|
{:kind :timeline :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref))}
|
||||||
|
{:kind :frame :label (.. n -dataset -label) :ref (keyword (.. n -dataset -ref))
|
||||||
|
:at (js/parseInt (.. n -dataset -at) 10)}))
|
||||||
|
;; A wrapper element (e.g. the <div> a browser inserts on Enter): recurse so
|
||||||
|
;; chips nested inside it still serialize to their token instead of being
|
||||||
|
;; flattened to text, and treat block wrappers as a line break.
|
||||||
|
:else (let [kids (apply str (map serialize-node (array-seq (.-childNodes n))))]
|
||||||
|
(if (#{"DIV" "P"} (.-tagName n)) (str "\n" kids) kids))))
|
||||||
|
|
||||||
|
(defn serialize-editor
|
||||||
|
"Serialize a contenteditable element back to its content string (text + link
|
||||||
|
tokens), preserving line breaks and chips wherever the browser nested them."
|
||||||
|
[^js el]
|
||||||
|
(apply str (map serialize-node (array-seq (.-childNodes el)))))
|
||||||
|
|
||||||
|
;; --- inline markdown: **bold**, *italic*, `code` -------------------------
|
||||||
|
|
||||||
|
(def ^:private inline-re #"(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)")
|
||||||
|
|
||||||
|
(defn- inline
|
||||||
|
"Render inline markdown in plain text `s` to a seq of strings / Hiccup spans."
|
||||||
|
[s]
|
||||||
|
(loop [s s, acc []]
|
||||||
|
(if-let [m (re-find inline-re s)]
|
||||||
|
(let [tok (if (vector? m) (first m) m)
|
||||||
|
i (str/index-of s tok)
|
||||||
|
before (subs s 0 i)
|
||||||
|
after (subs s (+ i (count tok)))
|
||||||
|
el (cond
|
||||||
|
(str/starts-with? tok "`") [:code (subs tok 1 (dec (count tok)))]
|
||||||
|
(str/starts-with? tok "**") [:strong (subs tok 2 (- (count tok) 2))]
|
||||||
|
:else [:em (subs tok 1 (dec (count tok)))])]
|
||||||
|
(recur after (conj acc before el)))
|
||||||
|
(conj acc s))))
|
||||||
|
|
||||||
|
;; --- block markdown: headings, lists, paragraphs + inline links ----------
|
||||||
|
|
||||||
|
(defn- spans
|
||||||
|
"Inline content of a line as a seq of Hiccup nodes: link chips (via `link-fn`),
|
||||||
|
inline markdown, and plain strings."
|
||||||
|
[s link-fn]
|
||||||
|
(mapcat (fn [[k v]] (if (= k :link) [(link-fn v)] (inline v)))
|
||||||
|
(parse-content s)))
|
||||||
|
|
||||||
|
(defn render
|
||||||
|
"Render annotation `content` (markdown blocks — ATX headings, `- `/`* ` lists,
|
||||||
|
paragraphs — with inline **bold**/*italic*/`code` and link tokens) to a Hiccup
|
||||||
|
[:div.md …] tree. `link-fn` turns a {:label :ref :at} link into a Hiccup chip;
|
||||||
|
it's injected because resolving a link to a timeline frame needs the scene."
|
||||||
|
[content link-fn]
|
||||||
|
(letfn [(flush-para [out para]
|
||||||
|
(cond-> out
|
||||||
|
(seq para) (conj (into [:p] (spans (str/join " " para) link-fn)))))]
|
||||||
|
(loop [ls (str/split-lines (or content "")), out [], para []]
|
||||||
|
(if (empty? ls)
|
||||||
|
(into [:div.md] (flush-para out para))
|
||||||
|
(let [t (str/trim (first ls))]
|
||||||
|
(cond
|
||||||
|
(str/blank? t)
|
||||||
|
(recur (rest ls) (flush-para out para) [])
|
||||||
|
|
||||||
|
(re-find #"^#{1,6}\s" t)
|
||||||
|
(let [n (count (re-find #"^#+" t))
|
||||||
|
txt (str/replace t #"^#+\s*" "")
|
||||||
|
tag (keyword (str "h" (min n 6)))]
|
||||||
|
(recur (rest ls) (conj (flush-para out para) (into [tag] (spans txt link-fn))) []))
|
||||||
|
|
||||||
|
(re-find #"^[-*]\s" t)
|
||||||
|
(let [[items more] (split-with #(re-find #"^\s*[-*]\s" %) ls)
|
||||||
|
lis (for [it items]
|
||||||
|
(into [:li] (spans (str/replace (str/trim it) #"^[-*]\s*" "") link-fn)))]
|
||||||
|
(recur more (conj (flush-para out para) (into [:ul] lis)) []))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(recur (rest ls) out (conj para t))))))))
|
||||||
|
|
@ -378,27 +378,30 @@
|
||||||
(runs scene ctx gid))}))))]
|
(runs scene ctx gid))}))))]
|
||||||
(vec (concat (sort-by :name tracks) (sort-by :name anns)))))
|
(vec (concat (sort-by :name tracks) (sort-by :name anns)))))
|
||||||
|
|
||||||
(def ^:private link-re "\\[([^\\]]*)\\]\\(mark:([^@)]+)@(-?\\d+)\\)")
|
(defn path-to
|
||||||
|
"Stack path from :root down to `gid` following :parent links, or nil if an
|
||||||
|
ancestor is missing — an orphan whose parent context was deleted."
|
||||||
|
[scene gid]
|
||||||
|
(loop [g gid, acc ()]
|
||||||
|
(cond
|
||||||
|
(= g :root) (vec (cons :root acc))
|
||||||
|
(or (nil? g) (not (contains? (:groups scene) g))) nil
|
||||||
|
:else (recur (get-in scene [:groups g :parent]) (cons g acc)))))
|
||||||
|
|
||||||
(defn link-token
|
(defn timelines
|
||||||
"The markdown token for a link {:label :ref :at}."
|
"Every reachable timeline you can open as a context — root, plus named child
|
||||||
[{:keys [label ref at]}]
|
annotations whose parent chain still reaches root — each with the stack path
|
||||||
(str "[" label "](mark:" (name ref) "@" at ")"))
|
to reach it and its parent's name (:in) for disambiguation. Orphans (parent
|
||||||
|
deleted) are dropped."
|
||||||
(defn parse-content
|
[scene]
|
||||||
"Split markdown `s` into a vector of [:text str] / [:link {:label :ref :at}]."
|
(->> (:groups scene)
|
||||||
[s]
|
(keep (fn [[gid g]]
|
||||||
(if (empty? s)
|
(when (and (= :annotation (:type g)) (not (:draft g)))
|
||||||
[]
|
(when-let [path (path-to scene gid)]
|
||||||
(let [re (js/RegExp. link-re "g")]
|
(let [parent (:parent g)]
|
||||||
(loop [out [] last 0]
|
{:gid gid :name (or (:name g) (name gid))
|
||||||
(if-let [m (.exec re s)]
|
:in (if (or (nil? parent) (= :root parent))
|
||||||
(let [idx (.-index m) pre (subs s last idx)]
|
"root" (get-in scene [:groups parent :name] (name parent)))
|
||||||
(recur (cond-> out
|
:path path})))))
|
||||||
(seq pre) (conj [:text pre])
|
(sort-by :name)
|
||||||
:always (conj [:link {:label (aget m 1)
|
(into [{:gid :root :name "root" :in nil :path [:root]}])))
|
||||||
:ref (keyword (aget m 2))
|
|
||||||
:at (js/parseInt (aget m 3) 10)}]))
|
|
||||||
(+ idx (.-length (aget m 0)))))
|
|
||||||
(let [tail (subs s last)]
|
|
||||||
(cond-> out (seq tail) (conj [:text tail]))))))))
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@
|
||||||
(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])))
|
||||||
(rf/reg-sub ::create-error (fn [db] (:create-error db)))
|
(rf/reg-sub ::create-error (fn [db] (:create-error db)))
|
||||||
(rf/reg-sub ::login-error (fn [db] (:login-error db)))
|
(rf/reg-sub ::login-error (fn [db] (:login-error db)))
|
||||||
|
(rf/reg-sub ::net-error (fn [db] (:net-error db)))
|
||||||
|
(rf/reg-sub ::projects-error (fn [db] (:projects-error db)))
|
||||||
|
(rf/reg-sub ::save-error (fn [db] (:save-error db)))
|
||||||
|
(rf/reg-sub ::load-error (fn [db] (get-in db [:load :error])))
|
||||||
(rf/reg-sub ::authed? (fn [db] (boolean (get-in db [:auth :authenticated]))))
|
(rf/reg-sub ::authed? (fn [db] (boolean (get-in db [:auth :authenticated]))))
|
||||||
(rf/reg-sub ::username (fn [db] (get-in db [:auth :username])))
|
(rf/reg-sub ::username (fn [db] (get-in db [:auth :username])))
|
||||||
(rf/reg-sub ::fps (fn [db] (:fps db)))
|
(rf/reg-sub ::fps (fn [db] (:fps db)))
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
[reagent.core :as r]
|
[reagent.core :as r]
|
||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[tl.subs :as subs]
|
[tl.subs :as subs]
|
||||||
|
[tl.md :as md]
|
||||||
[tl.scene :as scene]
|
[tl.scene :as scene]
|
||||||
[tl.routes :as routes]
|
[tl.routes :as routes]
|
||||||
[tl.events :as events]))
|
[tl.events :as events]))
|
||||||
|
|
@ -101,20 +102,28 @@
|
||||||
(when discontinuity?
|
(when discontinuity?
|
||||||
(seek-video! fps ns))
|
(seek-video! fps ns))
|
||||||
(rf/dispatch [::events/set-playhead ctx nl]))
|
(rf/dispatch [::events/set-playhead ctx nl]))
|
||||||
(.pause v)) ; end → pause event disengages
|
(do (rf/dispatch [::events/set-playhead ctx (+ ls (- se ss))]) ; snap to the very end
|
||||||
|
(.pause v))) ; end → pause event disengages
|
||||||
(rf/dispatch [::events/set-playhead ctx (+ ls (- sf ss))])))))
|
(rf/dispatch [::events/set-playhead ctx (+ ls (- sf ss))])))))
|
||||||
(when @play (reset! raf (js/requestAnimationFrame play-tick)))))
|
(when @play (reset! raf (js/requestAnimationFrame play-tick)))))
|
||||||
|
|
||||||
(defn- engage-play!
|
(defn- engage-play!
|
||||||
"Drive the playhead from the (already-playing) video, in the current context,
|
"Drive the playhead from the (already-playing) video, in the current context,
|
||||||
from wherever currentTime is. Idempotent."
|
from wherever currentTime is. If playback had finished (playhead parked at the
|
||||||
|
end), restart from the top of this timeline instead. Idempotent."
|
||||||
[]
|
[]
|
||||||
(let [segs @(rf/subscribe [::subs/segments])
|
(let [segs @(rf/subscribe [::subs/segments])
|
||||||
ctx @(rf/subscribe [::subs/context])
|
ctx @(rf/subscribe [::subs/context])
|
||||||
fps @(rf/subscribe [::subs/fps])
|
fps @(rf/subscribe [::subs/fps])
|
||||||
|
ph @(rf/subscribe [::subs/playhead])
|
||||||
v @video-el]
|
v @video-el]
|
||||||
(when (and v (seq segs) (not @play))
|
(when (and v (seq segs) (not @play))
|
||||||
(reset! play {:ctx ctx :segs segs :fps fps :idx (seg-at-src segs (* (.-currentTime v) fps))})
|
(if (>= ph (scene/length segs))
|
||||||
|
(let [s0 (scene/local->source segs 0)] ; at the end → rewind to start
|
||||||
|
(rf/dispatch [::events/set-playhead ctx 0])
|
||||||
|
(seek-video! fps s0)
|
||||||
|
(reset! play {:ctx ctx :segs segs :fps fps :idx 0 :pending-ns s0}))
|
||||||
|
(reset! play {:ctx ctx :segs segs :fps fps :idx (seg-at-src segs (* (.-currentTime v) fps))}))
|
||||||
(rf/dispatch [::events/set-playing true])
|
(rf/dispatch [::events/set-playing true])
|
||||||
(reset! raf (js/requestAnimationFrame play-tick)))))
|
(reset! raf (js/requestAnimationFrame play-tick)))))
|
||||||
|
|
||||||
|
|
@ -142,6 +151,16 @@
|
||||||
(defn video-monitor []
|
(defn video-monitor []
|
||||||
[:video {:src @(rf/subscribe [::subs/clip-url]) :controls true :preload "auto"
|
[:video {:src @(rf/subscribe [::subs/clip-url]) :controls true :preload "auto"
|
||||||
:plays-inline true :webkit-playsinline "true"
|
:plays-inline true :webkit-playsinline "true"
|
||||||
|
;; (Re)loading a clip resets the element to currentTime 0, so a restored
|
||||||
|
;; / deep-linked playhead would show in the UI but play from 0. Seek the
|
||||||
|
;; element to the current playhead once its metadata is ready.
|
||||||
|
:on-loaded-metadata
|
||||||
|
(fn [_]
|
||||||
|
(when-not @play
|
||||||
|
(let [segs @(rf/subscribe [::subs/segments])
|
||||||
|
fps @(rf/subscribe [::subs/fps])
|
||||||
|
ph @(rf/subscribe [::subs/playhead])]
|
||||||
|
(seek-video! fps (scene/local->source segs ph)))))
|
||||||
:on-play (fn [_] (engage-play!))
|
:on-play (fn [_] (engage-play!))
|
||||||
:on-pause (fn [_] (disengage!))
|
:on-pause (fn [_] (disengage!))
|
||||||
:ref (fn [n] (when n (reset! video-el n)))}])
|
:ref (fn [n] (when n (reset! video-el n)))}])
|
||||||
|
|
@ -282,31 +301,40 @@
|
||||||
{:ref (keyword ref) :at (js/parseInt at 10)}))
|
{:ref (keyword ref) :at (js/parseInt at 10)}))
|
||||||
|
|
||||||
(defn content-display
|
(defn content-display
|
||||||
"Read-only render of markdown `content`: text plus clickable link chips."
|
"Read-only render of annotation `content`: markdown blocks (headings, lists,
|
||||||
|
paragraphs) with inline formatting plus clickable link chips."
|
||||||
[scene ctx content]
|
[scene ctx content]
|
||||||
(into [:div.md]
|
(md/render content
|
||||||
(map (fn [[k v]]
|
(fn [{:keys [kind label ref at]}]
|
||||||
(if (= k :text)
|
(if (= :timeline kind)
|
||||||
v
|
(let [path (scene/path-to scene ref)]
|
||||||
(let [lf (scene/link-local scene ctx (select-keys v [:ref :at]))]
|
[:span.link-chip {:class (when-not path "broken")
|
||||||
|
:title (when-not path "timeline no longer here")
|
||||||
|
:on-click #(when path (rf/dispatch [::events/open-stack path]))}
|
||||||
|
"⤢ " label])
|
||||||
|
(let [lf (scene/link-local scene ctx {:ref ref :at at})]
|
||||||
[:span.link-chip {:class (when-not lf "broken")
|
[:span.link-chip {:class (when-not lf "broken")
|
||||||
:title (when-not lf "linked clip no longer in this timeline")
|
:title (when-not lf "linked clip no longer in this timeline")
|
||||||
:on-click #(when lf (goto! lf))}
|
:on-click #(when lf (goto! lf))}
|
||||||
(when-not lf "⚠ ") (:label v)
|
(when-not lf "⚠ ") label
|
||||||
(when lf [:span.link-f (str " " (js/Math.round lf) "f")])])))
|
(when lf [:span.link-f (str " " (js/Math.round lf) "f")])])))))
|
||||||
(scene/parse-content content))))
|
|
||||||
|
|
||||||
(defn- build-chip [{:keys [label ref at]}]
|
(defn- build-chip [{:keys [label ref at kind]}]
|
||||||
(let [span (js/document.createElement "span")
|
(let [span (js/document.createElement "span")
|
||||||
lf (scene/link-local @(rf/subscribe [::subs/scene]) @(rf/subscribe [::subs/context])
|
scene @(rf/subscribe [::subs/scene])
|
||||||
{:ref ref :at at})]
|
timeline? (= :timeline kind)
|
||||||
(set! (.-className span) (if lf "link-chip" "link-chip broken"))
|
lf (when-not timeline?
|
||||||
|
(scene/link-local scene @(rf/subscribe [::subs/context]) {:ref ref :at at}))
|
||||||
|
ok (if timeline? (some? (scene/path-to scene ref)) lf)]
|
||||||
|
(set! (.-className span) (if ok "link-chip" "link-chip broken"))
|
||||||
(set! (.-contentEditable span) "false")
|
(set! (.-contentEditable span) "false")
|
||||||
(when-not lf (set! (.-title span) "linked clip no longer in this timeline"))
|
(when-not ok (set! (.-title span) (if timeline? "timeline no longer here"
|
||||||
(set! (.-textContent span) (str (when-not lf "⚠ ") label))
|
"linked clip no longer in this timeline")))
|
||||||
|
(set! (.-textContent span) (str (cond timeline? "⤢ " (not ok) "⚠ ") label))
|
||||||
(aset (.-dataset span) "label" label)
|
(aset (.-dataset span) "label" label)
|
||||||
(aset (.-dataset span) "ref" (name ref))
|
(aset (.-dataset span) "ref" (name ref))
|
||||||
(aset (.-dataset span) "at" (str at))
|
(when kind (aset (.-dataset span) "kind" (name kind)))
|
||||||
|
(when-not timeline? (aset (.-dataset span) "at" (str at)))
|
||||||
(when lf
|
(when lf
|
||||||
(let [f (js/document.createElement "span")]
|
(let [f (js/document.createElement "span")]
|
||||||
(set! (.-className f) "link-f")
|
(set! (.-className f) "link-f")
|
||||||
|
|
@ -316,24 +344,11 @@
|
||||||
|
|
||||||
(defn- populate! [^js el content]
|
(defn- populate! [^js el content]
|
||||||
(set! (.-innerHTML el) "")
|
(set! (.-innerHTML el) "")
|
||||||
(doseq [[k v] (scene/parse-content content)]
|
(doseq [[k v] (md/parse-content content)]
|
||||||
(.appendChild el (if (= k :text)
|
(.appendChild el (if (= k :text)
|
||||||
(.createTextNode js/document v)
|
(.createTextNode js/document v)
|
||||||
(build-chip v)))))
|
(build-chip v)))))
|
||||||
|
|
||||||
(defn- serialize-editor [^js el]
|
|
||||||
(apply str
|
|
||||||
(map (fn [^js n]
|
|
||||||
(cond
|
|
||||||
(= 3 (.-nodeType n)) (.-textContent n)
|
|
||||||
(and (.-classList n) (.contains (.-classList n) "link-chip"))
|
|
||||||
(scene/link-token {:label (.. n -dataset -label)
|
|
||||||
:ref (keyword (.. n -dataset -ref))
|
|
||||||
:at (js/parseInt (.. n -dataset -at) 10)})
|
|
||||||
(= "BR" (.-tagName n)) "\n"
|
|
||||||
:else (str "\n" (.-textContent n))))
|
|
||||||
(array-seq (.-childNodes el)))))
|
|
||||||
|
|
||||||
(defn content-editor
|
(defn content-editor
|
||||||
"Contenteditable markdown surface for the draft's :content. Uncontrolled: we
|
"Contenteditable markdown surface for the draft's :content. Uncontrolled: we
|
||||||
populate it once from `initial`, then serialize back out on input. Exposes its
|
populate it once from `initial`, then serialize back out on input. Exposes its
|
||||||
|
|
@ -341,7 +356,7 @@
|
||||||
chip at the caret."
|
chip at the caret."
|
||||||
[gid initial]
|
[gid initial]
|
||||||
(r/with-let [el (atom nil) saved (atom nil)
|
(r/with-let [el (atom nil) saved (atom nil)
|
||||||
emit #(rf/dispatch [::events/set-content gid (serialize-editor @el)])
|
emit #(rf/dispatch [::events/set-content gid (md/serialize-editor @el)])
|
||||||
save! (fn [] (let [s (.getSelection js/window)]
|
save! (fn [] (let [s (.getSelection js/window)]
|
||||||
(when (and @el (= @el (.-activeElement js/document))
|
(when (and @el (= @el (.-activeElement js/document))
|
||||||
(pos? (.-rangeCount s)) (.contains @el (.-anchorNode s)))
|
(pos? (.-rangeCount s)) (.contains @el (.-anchorNode s)))
|
||||||
|
|
@ -362,13 +377,20 @@
|
||||||
{:content-editable true :suppress-content-editable-warning true
|
{:content-editable true :suppress-content-editable-warning true
|
||||||
:data-placeholder "Content (optional)"
|
:data-placeholder "Content (optional)"
|
||||||
:ref (fn [n] (when (and n (not= n @el))
|
:ref (fn [n] (when (and n (not= n @el))
|
||||||
(reset! el n) (populate! n initial) (reset! active-insert! insert!)))
|
(reset! el n) (populate! n initial) (reset! active-insert! insert!)
|
||||||
|
;; focus the content surface when the form opens (instead of
|
||||||
|
;; the marks picker), caret at the end so edits append.
|
||||||
|
(.focus n)
|
||||||
|
(let [r (doto (.createRange js/document)
|
||||||
|
(.selectNodeContents n) (.collapse false))
|
||||||
|
s (.getSelection js/window)]
|
||||||
|
(.removeAllRanges s) (.addRange s r))))
|
||||||
:on-input emit :on-key-up save! :on-mouse-up save! :on-blur save!
|
:on-input emit :on-key-up save! :on-mouse-up save! :on-blur save!
|
||||||
:on-click (fn [e] (when-let [chip (.closest (.-target e) ".link-chip")]
|
:on-click (fn [e] (when-let [chip (.closest (.-target e) ".link-chip")]
|
||||||
(when-let [lf (link-frame (.. chip -dataset -ref) (.. chip -dataset -at))]
|
(when-let [lf (link-frame (.. chip -dataset -ref) (.. chip -dataset -at))]
|
||||||
(goto! lf))))}]))
|
(goto! lf))))}]))
|
||||||
|
|
||||||
(defn- point-candidates [scene ctx q]
|
(defn- point-candidates [scene ctx q timelines?]
|
||||||
(let [needle (str/lower-case q)
|
(let [needle (str/lower-case q)
|
||||||
n (when (re-matches #"-?\d+" q) (js/parseInt q 10))]
|
n (when (re-matches #"-?\d+" q) (js/parseInt q 10))]
|
||||||
(vec
|
(vec
|
||||||
|
|
@ -381,7 +403,15 @@
|
||||||
(:search item)))]
|
(:search item)))]
|
||||||
(when (str/includes? hay needle)
|
(when (str/includes? hay needle)
|
||||||
(assoc item :group (:name g)))))
|
(assoc item :group (:name g)))))
|
||||||
(:items g)))))))))
|
(:items g)))))
|
||||||
|
;; timeline-open targets — any timeline, anywhere (only when inserting a link).
|
||||||
|
;; "timeline" is part of every haystack, so typing the category lists them all.
|
||||||
|
(when timelines?
|
||||||
|
(->> (scene/timelines scene)
|
||||||
|
(keep (fn [{:keys [gid name in path]}]
|
||||||
|
(when (str/includes? (str/lower-case (str "timeline " name " " in)) needle)
|
||||||
|
{:label name :kind :timeline :group (when in (str "in " in))
|
||||||
|
:point {:kind :timeline :ref gid} :path path})))))))))
|
||||||
|
|
||||||
(defn- point-label [cand off local]
|
(defn- point-label [cand off local]
|
||||||
(str (:label cand) " @" (js/Math.round local) "f"
|
(str (:label cand) " @" (js/Math.round local) "f"
|
||||||
|
|
@ -389,30 +419,39 @@
|
||||||
(str " +" off))))
|
(str " +" off))))
|
||||||
|
|
||||||
(defn- pick-point [cand off]
|
(defn- pick-point [cand off]
|
||||||
(let [o (if (:abs cand) 0 (min (max 0 off) (max 0 (dec (or (:len cand) 1)))))
|
(if (= :timeline (:kind cand))
|
||||||
local (+ (:local cand) o)]
|
(assoc (:point cand) :label (:label cand) :local (:local cand) :candidate cand :offset 0)
|
||||||
(assoc (cond-> (:point cand) (not (:abs cand)) (update :at + o))
|
(let [o (if (:abs cand) 0 (min (max 0 off) (max 0 (dec (or (:len cand) 1)))))
|
||||||
:label (point-label cand o local)
|
local (+ (:local cand) o)]
|
||||||
:local local
|
(assoc (cond-> (:point cand) (not (:abs cand)) (update :at + o))
|
||||||
:candidate cand
|
:label (point-label cand o local)
|
||||||
:offset o)))
|
:local local
|
||||||
|
:candidate cand
|
||||||
|
:offset o))))
|
||||||
|
|
||||||
(defn point-picker
|
(defn point-picker
|
||||||
"Shared clip / annotation / frame autocomplete. `on-pick` receives the link
|
"Shared clip / annotation / frame autocomplete. `on-pick` receives the link
|
||||||
point plus :label and resolved ctx-local :local. Callers decide whether that
|
point plus :label and resolved ctx-local :local. Callers decide whether that
|
||||||
pick advances mark entry or just stages a link."
|
pick advances mark entry or just stages a link."
|
||||||
[{:keys [scene ctx value on-pick on-cancel placeholder auto-focus? class]}]
|
[{:keys [scene ctx value on-pick on-cancel placeholder auto-focus? class timelines?]}]
|
||||||
(r/with-let [text (r/atom (or (:label value) ""))
|
(r/with-let [text (r/atom (or (:label value) ""))
|
||||||
idx (r/atom 0)
|
idx (r/atom 0)
|
||||||
off (r/atom (or (:offset value) 0))
|
off (r/atom (or (:offset value) 0))
|
||||||
picked (r/atom value)
|
picked (r/atom value)
|
||||||
open? (r/atom true)]
|
open? (r/atom true)]
|
||||||
(let [cands (point-candidates scene ctx @text)
|
(let [cands (point-candidates scene ctx @text timelines?)
|
||||||
i (min @idx (max 0 (dec (count cands))))
|
i (min @idx (max 0 (dec (count cands))))
|
||||||
cur (or (:candidate @picked) (get cands i))
|
cur (or (:candidate @picked) (get cands i))
|
||||||
maxo (max 0 (dec (or (:len cur) 1)))
|
maxo (max 0 (dec (or (:len cur) 1)))
|
||||||
o (min (max 0 @off) maxo)
|
o (min (max 0 @off) maxo)
|
||||||
frame? (and cur (not (:abs cur)) (pos? maxo))
|
frame? (and cur (not (:abs cur)) (pos? maxo))
|
||||||
|
preview! (fn [cand o2]
|
||||||
|
(when cand
|
||||||
|
(cond
|
||||||
|
(not timelines?) (goto! (:local (pick-point cand o2)))
|
||||||
|
(= :timeline (:kind cand)) (rf/dispatch [::events/open-stack (:path cand)])
|
||||||
|
:else (rf/dispatch [::events/preview-frame
|
||||||
|
(:local (pick-point cand o2))]))))
|
||||||
emit! (fn [cand o2]
|
emit! (fn [cand o2]
|
||||||
(when cand
|
(when cand
|
||||||
(let [p (pick-point cand o2)]
|
(let [p (pick-point cand o2)]
|
||||||
|
|
@ -420,11 +459,8 @@
|
||||||
(reset! text (:label p))
|
(reset! text (:label p))
|
||||||
(reset! off (:offset p))
|
(reset! off (:offset p))
|
||||||
(reset! open? false)
|
(reset! open? false)
|
||||||
(goto! (:local p))
|
(preview! cand o2)
|
||||||
(on-pick p))))
|
(on-pick p))))
|
||||||
preview! (fn [cand o2]
|
|
||||||
(when cand
|
|
||||||
(goto! (:local (pick-point cand o2)))))
|
|
||||||
go! (fn [j]
|
go! (fn [j]
|
||||||
(reset! picked nil)
|
(reset! picked nil)
|
||||||
(reset! idx j)
|
(reset! idx j)
|
||||||
|
|
@ -442,7 +478,7 @@
|
||||||
(reset! idx 0)
|
(reset! idx 0)
|
||||||
(reset! off 0)
|
(reset! off 0)
|
||||||
(reset! open? true)
|
(reset! open? true)
|
||||||
(preview! (first (point-candidates scene ctx (.. % -target -value))) 0))
|
(preview! (first (point-candidates scene ctx (.. % -target -value) timelines?)) 0))
|
||||||
:on-key-down
|
:on-key-down
|
||||||
(fn [e]
|
(fn [e]
|
||||||
(case (.-key e)
|
(case (.-key e)
|
||||||
|
|
@ -475,9 +511,11 @@
|
||||||
:on-mouse-down #(.preventDefault %)
|
:on-mouse-down #(.preventDefault %)
|
||||||
:on-mouse-enter #(go! j)
|
:on-mouse-enter #(go! j)
|
||||||
:on-click #(emit! c 0)}
|
:on-click #(emit! c 0)}
|
||||||
|
(when timelines? [:span.cand-kind (if (= :timeline (:kind c)) "⤢ " "↪ ")])
|
||||||
[:span.cand-label (:label c)]
|
[:span.cand-label (:label c)]
|
||||||
(when (:group c) [:span.cand-group (str " " (:group c))])
|
(when (:group c) [:span.cand-group (str " " (:group c))])
|
||||||
[:span.link-f (str " " (js/Math.round (:local c)) "f")]])))])))
|
(when-not (= :timeline (:kind c))
|
||||||
|
[:span.link-f (str " " (js/Math.round (:local c)) "f")])])))])))
|
||||||
|
|
||||||
(defn- local->draft-point [segs local]
|
(defn- local->draft-point [segs local]
|
||||||
(some (fn [{m :mark [c d] :local}]
|
(some (fn [{m :mark [c d] :local}]
|
||||||
|
|
@ -494,7 +532,7 @@
|
||||||
(defn- link-picker [{:keys [scene ctx on-commit on-cancel]}]
|
(defn- link-picker [{:keys [scene ctx on-commit on-cancel]}]
|
||||||
(r/with-let [picked (r/atom nil)]
|
(r/with-let [picked (r/atom nil)]
|
||||||
[:div.link-insert
|
[:div.link-insert
|
||||||
[point-picker {:scene scene :ctx ctx :value @picked :auto-focus? true
|
[point-picker {:scene scene :ctx ctx :value @picked :auto-focus? true :timelines? true
|
||||||
:on-pick #(reset! picked %)
|
:on-pick #(reset! picked %)
|
||||||
:on-cancel on-cancel}]
|
:on-cancel on-cancel}]
|
||||||
[:button.hl-ok {:type "button" :title "Insert link" :disabled (nil? @picked)
|
[:button.hl-ok {:type "button" :title "Insert link" :disabled (nil? @picked)
|
||||||
|
|
@ -541,6 +579,15 @@
|
||||||
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
|
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
|
||||||
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
|
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
|
||||||
[:div.commentary {:ref (fn [n] (reset! el n))}
|
[:div.commentary {:ref (fn [n] (reset! el n))}
|
||||||
|
;; this context's own description (links resolve in its parent), with an
|
||||||
|
;; edit button — Edit drops into the parent timeline so marks are editable.
|
||||||
|
(let [cg (get-in scene [:groups ctx])]
|
||||||
|
[:div.ctx-content
|
||||||
|
(if (seq (:content cg))
|
||||||
|
[content-display scene (or (:parent cg) ctx) (:content cg)]
|
||||||
|
[:div.muted "No description yet."])
|
||||||
|
(when authed?
|
||||||
|
[:button.edit-btn {:on-click #(rf/dispatch [::events/edit-here ctx])} "✎ Edit"])])
|
||||||
(when authed?
|
(when authed?
|
||||||
[:div.commentary-head
|
[:div.commentary-head
|
||||||
[:button.add-btn {:on-click #(rf/dispatch [::events/open-draft])}
|
[:button.add-btn {:on-click #(rf/dispatch [::events/open-draft])}
|
||||||
|
|
@ -585,75 +632,84 @@
|
||||||
(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])
|
||||||
scene @(rf/subscribe [::subs/scene])
|
scene @(rf/subscribe [::subs/scene])
|
||||||
segs @(rf/subscribe [::subs/segments])
|
;; anchor the form to the draft's home context, not the live stack top:
|
||||||
ctx @(rf/subscribe [::subs/context])
|
;; a link-insert timeline preview moves the stack, but this annotation
|
||||||
|
;; still belongs to (:parent d), so its marks/pickers stay stable.
|
||||||
|
ctx (or (:parent d) (:gid d)) ; annotation: its parent; root: itself
|
||||||
|
segs (scene/content-segments scene ctx)
|
||||||
pt @(rf/subscribe [::subs/pt])
|
pt @(rf/subscribe [::subs/pt])
|
||||||
linking @(rf/subscribe [::subs/linking])
|
linking @(rf/subscribe [::subs/linking])
|
||||||
gid (:gid d)
|
gid (:gid d)
|
||||||
new? (= :new (:draft d))
|
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)]))
|
put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)]))
|
||||||
rows (scene/marks->rows scene (:marks d))
|
rows (scene/marks->rows scene (:marks d))
|
||||||
valid? (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?
|
||||||
(rf/dispatch [::events/save-group gid (dissoc d :draft :gid)
|
(rf/dispatch [::events/save-group gid (dissoc d :draft :gid)
|
||||||
(when-not new? orig)]))]
|
(when-not new? orig)])
|
||||||
|
(rf/dispatch [::events/finish-edit]))]
|
||||||
[:form.form {:on-submit (fn [e] (.preventDefault e) (save))}
|
[:form.form {:on-submit (fn [e] (.preventDefault e) (save))}
|
||||||
[:div.form-head (if new? "New annotation" "Edit annotation")]
|
[:div.form-head (cond root? "Edit description" new? "New annotation" :else "Edit annotation")]
|
||||||
[:div.form-row
|
(when-not root?
|
||||||
[:input.form-name {:placeholder "Name" :value (:name d)
|
[:div.form-row
|
||||||
:on-change #(put (assoc d :name (.. % -target -value)))}]
|
[:input.form-name {:placeholder "Name" :value (:name d)
|
||||||
[:input.form-color {:type "color" :value (:color d)
|
:on-change #(put (assoc d :name (.. % -target -value)))}]
|
||||||
:on-change #(put (assoc d :color (.. % -target -value)))}]]
|
[:input.form-color {:type "color" :value (:color d)
|
||||||
|
:on-change #(put (assoc d :color (.. % -target -value)))}]])
|
||||||
[:div.form-marks-label "Content"]
|
[:div.form-marks-label "Content"]
|
||||||
^{:key gid} [content-editor gid (:content orig)]
|
^{:key gid} [content-editor gid (:content orig)]
|
||||||
(if (and linking (= gid (:gid linking)))
|
(if (and linking (= gid (:gid linking)))
|
||||||
[link-picker {:scene scene :ctx ctx
|
[link-picker {:scene scene :ctx ctx
|
||||||
:on-commit #(commit-link! (select-keys % [:ref :at]) (:label %))
|
:on-commit #(commit-link! (select-keys % [:ref :at :kind]) (:label %))
|
||||||
:on-cancel #(rf/dispatch [::events/cancel-linking])}]
|
:on-cancel #(rf/dispatch [::events/cancel-linking])}]
|
||||||
[:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-linking gid])}
|
[:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-linking gid])}
|
||||||
"🔗 Insert link"])
|
"🔗 Insert link"])
|
||||||
(when (and linking (= gid (:gid linking)))
|
(when (and linking (= gid (:gid linking)))
|
||||||
[:div.form-hint "Click a clip or the frame-readout to link it, or pick above."])
|
[:div.form-hint "Click a clip or the frame-readout to link it, or pick above."])
|
||||||
[:div.form-marks-label "Marks"]
|
(when-not root?
|
||||||
(doall
|
[:<>
|
||||||
(for [[i row] (map-indexed vector rows)]
|
[:div.form-marks-label "Marks"]
|
||||||
^{:key i}
|
(doall
|
||||||
[:div.mark-row
|
(for [[i row] (map-indexed vector rows)]
|
||||||
[frame-chip scene segs put d i :start (:s row)]
|
^{:key i}
|
||||||
[:span.mark-arrow "→"]
|
[:div.mark-row
|
||||||
[frame-chip scene segs put d i :end (:e row)]
|
[frame-chip scene segs put d i :start (:s row)]
|
||||||
[:button.row-x {:type "button" :title "Remove"
|
[:span.mark-arrow "→"]
|
||||||
:on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]]))
|
[frame-chip scene segs put d i :end (:e row)]
|
||||||
(when (map? pt)
|
[:button.row-x {:type "button" :title "Remove"
|
||||||
[:div.mark-row
|
:on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]]))
|
||||||
[:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt))
|
(when (map? pt)
|
||||||
" @" (:f pt) "f")]]
|
[:div.mark-row
|
||||||
[:span.mark-arrow "→"]
|
[:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt))
|
||||||
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
|
" @" (:f pt) "f")]]
|
||||||
:placeholder "click end clip..."
|
[:span.mark-arrow "→"]
|
||||||
:on-pick #(let [end-local (if (and (zero? (:offset %))
|
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
|
||||||
(pos? (or (get-in % [:candidate :len]) 0)))
|
:placeholder "click end clip..."
|
||||||
(+ (:local %) (get-in % [:candidate :len]))
|
:on-pick #(let [end-local (if (and (zero? (:offset %))
|
||||||
(:local %))]
|
(pos? (or (get-in % [:candidate :len]) 0)))
|
||||||
(when-let [p (local->draft-end-point segs end-local)]
|
(+ (:local %) (get-in % [:candidate :len]))
|
||||||
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)])))}]])
|
(:local %))]
|
||||||
(when (= pt :new)
|
(when-let [p (local->draft-end-point segs end-local)]
|
||||||
[:div.mark-row
|
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)])))}]])
|
||||||
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
|
(when (= pt :new)
|
||||||
:placeholder "click a clip..."
|
[:div.mark-row
|
||||||
:on-pick #(when-let [p (local->draft-point segs (:local %))]
|
[point-picker {:scene scene :ctx ctx :class "active"
|
||||||
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]])
|
:placeholder "click a clip..."
|
||||||
[:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."]
|
:on-pick #(when-let [p (local->draft-point segs (:local %))]
|
||||||
[:div.form-marks-label "Script"]
|
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]])
|
||||||
[:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-highlighting gid])}
|
[:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."]
|
||||||
(str "+ Add script annotation"
|
[:div.form-marks-label "Script"]
|
||||||
(when-let [n (seq (:script d))] (str " (" (count n) ")")))]
|
[: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-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"
|
||||||
:on-click #(if new?
|
:on-click #(do (if new?
|
||||||
(rf/dispatch [::events/drop-group gid])
|
(rf/dispatch [::events/drop-group gid])
|
||||||
(rf/dispatch [::events/restore-group gid orig]))}
|
(rf/dispatch [::events/restore-group gid orig]))
|
||||||
|
(rf/dispatch [::events/finish-edit]))}
|
||||||
"Cancel"]]])))
|
"Cancel"]]])))
|
||||||
|
|
||||||
;; --- chrome ---------------------------------------------------------------
|
;; --- chrome ---------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
(ns tl.scene-test
|
(ns tl.scene-test
|
||||||
(:require [cljs.test :refer-macros [deftest is testing]]
|
(:require [cljs.test :refer-macros [deftest is testing]]
|
||||||
|
[tl.md :as md]
|
||||||
[tl.scene :as s]))
|
[tl.scene :as s]))
|
||||||
|
|
||||||
;; --- shared fixture -------------------------------------------------------
|
;; --- shared fixture -------------------------------------------------------
|
||||||
|
|
@ -327,15 +328,40 @@
|
||||||
|
|
||||||
(deftest parse-content-splits-text-and-links
|
(deftest parse-content-splits-text-and-links
|
||||||
(testing "content round-trips through link-token / parse-content"
|
(testing "content round-trips through link-token / parse-content"
|
||||||
(let [tok (s/link-token {:label "B-roll clip @10" :ref :clip-b :at 10})
|
(let [tok (md/link-token {:label "B-roll clip @10" :ref :clip-b :at 10})
|
||||||
s (str "see " tok " here")]
|
s (str "see " tok " here")]
|
||||||
(is (= "[B-roll clip @10](mark:clip-b@10)" tok))
|
(is (= "[B-roll clip @10](mark:clip-b@10)" tok))
|
||||||
(is (= [[:text "see "]
|
(is (= [[:text "see "]
|
||||||
[:link {:label "B-roll clip @10" :ref :clip-b :at 10}]
|
[:link {:kind :frame :label "B-roll clip @10" :ref :clip-b :at 10}]
|
||||||
[:text " here"]]
|
[:text " here"]]
|
||||||
(s/parse-content s))))
|
(md/parse-content s))))
|
||||||
(is (= [] (s/parse-content "")))
|
(testing "labels with parens and @ survive the round-trip"
|
||||||
(is (= [[:text "plain note"]] (s/parse-content "plain note")))))
|
(let [link {:kind :frame :label "CU Tashi (1) @0f" :ref :t2-c5 :at 0}]
|
||||||
|
(is (= [[:link link]] (md/parse-content (md/link-token link))))))
|
||||||
|
(testing "timeline links carry just a ref and round-trip"
|
||||||
|
(let [tl {:kind :timeline :label "Match point" :ref :ann-1}]
|
||||||
|
(is (= "[Match point](timeline:ann-1)" (md/link-token tl)))
|
||||||
|
(is (= [[:link tl]] (md/parse-content (md/link-token tl))))))
|
||||||
|
(is (= [] (md/parse-content "")))
|
||||||
|
(is (= [[:text "plain note"]] (md/parse-content "plain note")))))
|
||||||
|
|
||||||
|
(deftest path-to-builds-stack-and-drops-orphans
|
||||||
|
(let [scene {:groups {:root {:type :timeline :parent nil}
|
||||||
|
:a {:type :annotation :parent :root}
|
||||||
|
:b {:type :annotation :parent :a}
|
||||||
|
:orphan {:type :annotation :parent :gone}}}]
|
||||||
|
(testing "path is root → … → target"
|
||||||
|
(is (= [:root] (s/path-to scene :root)))
|
||||||
|
(is (= [:root :a] (s/path-to scene :a)))
|
||||||
|
(is (= [:root :a :b] (s/path-to scene :b))))
|
||||||
|
(testing "an orphan (missing ancestor) has no path"
|
||||||
|
(is (nil? (s/path-to scene :orphan))))
|
||||||
|
(testing "timelines lists root + reachable annotations, not orphans"
|
||||||
|
(let [gids (set (map :gid (s/timelines scene)))]
|
||||||
|
(is (contains? gids :root))
|
||||||
|
(is (contains? gids :a))
|
||||||
|
(is (contains? gids :b))
|
||||||
|
(is (not (contains? gids :orphan)))))))
|
||||||
|
|
||||||
(deftest seg-point-and-link-local-round-trip
|
(deftest seg-point-and-link-local-round-trip
|
||||||
(testing "a clip-scoped point resolves back to the same ctx-local frame"
|
(testing "a clip-scoped point resolves back to the same ctx-local frame"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue