diff --git a/README.md b/README.md index 7cf4ad4..5e1529c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ python -m venv .venv && .venv/bin/pip install -r requirements.txt .venv/bin/python manage.py migrate 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 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: (admin / admin). Upload OTIO and inspect diff --git a/server/settings.py b/server/settings.py index 4ca24b7..82b4699 100644 --- a/server/settings.py +++ b/server/settings.py @@ -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! 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 @@ -134,8 +135,18 @@ MEDIA_ROOT = BASE_DIR / 'media' DATA_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024 # 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_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 +# 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' diff --git a/tl/dev/dev.sh b/tl/dev/dev.sh index 5510eea..0cfe6fe 100755 --- a/tl/dev/dev.sh +++ b/tl/dev/dev.sh @@ -1,19 +1,43 @@ #!/usr/bin/env bash -# Dev launcher: runs the shadow-cljs watch AND the range-capable media server -# together, and tears both down on exit (Ctrl-C kills the whole group). +# Dev launcher: backend + frontend + media, all hot-reloading, all bound to +# 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 -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; } 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 & -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 & wait diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index 2e23516..a07c301 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -13,6 +13,12 @@ body { overflow: hidden; } font-family: sans-serif; background: #0c0c0c; 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 ------------------------------------ */ @@ -83,6 +89,12 @@ body { overflow: hidden; } .jump-pop button:hover { background: #2a4a6a; } /* --- 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; } .add-btn { width: 100%; background: #1c2a38; color: #cfe3f5; @@ -137,7 +149,10 @@ body { overflow: hidden; } color: #7aa6d6; margin-top: 4px; } /* 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; } .link-chip { display: inline-flex; align-items: center; gap: 3px; vertical-align: baseline; background: #173049; color: #9fd0ff; border: 1px solid #2f5d86; border-radius: 4px; @@ -182,6 +197,7 @@ body { overflow: hidden; } overflow: hidden; text-overflow: ellipsis; } .pt-dropdown li.hi { background: #2a4a6a; } .pt-dropdown .cand-group { color: #789; font-size: 11px; } +.pt-dropdown .cand-kind { color: #7aa6d6; } .pt-dropdown .link-f { color: #6f9; opacity: .7; } .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) ----------------------------- */ .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 h1 { font-size: 22px; margin: 0; color: #cfe3f5; letter-spacing: 1px; } .home a { color: #7aa6d6; text-decoration: none; } @@ -351,3 +372,11 @@ body { overflow: hidden; } .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; } + +/* 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; } +} diff --git a/tl/src/tl/api.cljs b/tl/src/tl/api.cljs index 9fa1435..c096d45 100644 --- a/tl/src/tl/api.cljs +++ b/tl/src/tl/api.cljs @@ -1,70 +1,70 @@ (ns tl.api - "Thin fetch wrapper around the Django backend. Functions dispatch re-frame - events on completion (qualified keywords, to avoid a require cycle with - tl.events). Same host as the page, port 9001, with the session cookie." - (:require [re-frame.core :as rf])) + "Request builders for the Django backend, shaped as data for re-frame's + :http-xhrio effect (day8.re-frame.http-fx). Same host as the page, port 9001, + with the session cookie. Event handlers add :on-success / :on-failure and + 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 (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] - (-> (js/fetch (str base path) #js {:credentials "include"}) - (.then (fn [r] (when (.-ok r) (.json r)))) - (.then ->clj))) +(defn GET [path opts] + (merge {:method :get :uri (str base path) + :with-credentials true :response-format json-resp} + opts)) -(defn- POST [path body on-done] - (-> (js/fetch (str base path) - #js {:method "POST" :credentials "include" - :headers #js {"Content-Type" "application/json"} - :body (js/JSON.stringify (clj->js body))}) - (.then (fn [r] (.then (.json r) #(on-done (.-ok r) (->clj %))))))) +(defn GET-url + "GET an absolute URL (a media file, e.g. the OTIO json). No session cookie — + it's public media, and credentialed cross-origin would need extra CORS." + [url opts] + (merge {:method :get :uri url :response-format json-resp} opts)) -(defn fetch-me! [] - (-> (GET "/api/me/") (.then #(rf/dispatch [:tl.events/set-auth %])))) +(defn POST [path body opts] + (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] - (POST "/api/login/" {:username username :password password} - (fn [ok body] (rf/dispatch (if ok [:tl.events/login-ok body] - [:tl.events/login-error (:detail body)]))))) +(defn upload + "Multipart POST: the FormData rides as the raw body so cljs-ajax skips :format." + [path form-data opts] + (merge {:method :post :uri (str base path) + :with-credentials true :body form-data :response-format json-resp} + opts)) -(defn logout! [] - (POST "/api/logout/" {} (fn [_ body] (rf/dispatch [:tl.events/set-auth body])))) +(defn put-scene + "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! [] - (-> (GET "/api/projects/") (.then #(rf/dispatch [:tl.events/set-projects %])))) - -(defn fetch-project-detail! [id] - (-> (GET (str "/api/projects/" id "/")) - (.then #(rf/dispatch [:tl.events/project-detail-refreshed %])))) - -(defn load-project! - "Detail → OTIO file → scene, then hand it all to ::project-ready in one shot." - [id] - (-> (GET (str "/api/projects/" id "/")) - (.then (fn [detail] - (-> (js/fetch (:otio detail)) - (.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))))}))) +(defn error-message + "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) — + the common case behind an unreachable backend." + [{:keys [status response]} fallback] + (or (:detail response) + (case status + 0 "Can't reach the server." + 403 "Not allowed." + 404 "Not found." + 500 "Server error." + nil) + fallback)) ;; --- 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)) @@ -78,17 +78,6 @@ url (str proto "//" (.. js/window -location -hostname) ":9001/ws/projects/" id "/") s (js/WebSocket. url)] (reset! socket s) + (set! (.-onerror s) (fn [_] (js/console.warn "scene sync socket error; peer updates paused"))) (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) - (.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)]))))))) diff --git a/tl/src/tl/db.cljs b/tl/src/tl/db.cljs index 72dd51a..4c98f4f 100644 --- a/tl/src/tl/db.cljs +++ b/tl/src/tl/db.cljs @@ -1,12 +1,16 @@ (ns tl.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 :auth {:authenticated false} ; from /api/me/ :projects nil ; project list (when signed in) :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) ;; the whole scene graph (see tl.scene). Seeded from OTIO at load. diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index 2611b14..87f1e1d 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -264,22 +264,49 @@ (fn [db [_ gid content]] (assoc-in db [:scene :groups gid :content] content))) ;; Linking is an edit-mode sub-task, like script highlighting: "Insert link" -;; remembers the playhead and arms the mode; while armed, clicking the timeline -;; or picking from the autocomplete inserts a link chip at the caret. Esc/Cancel -;; restores the playhead (the arrow-key previews were just seeks). +;; snapshots the playhead AND the stack, then arms the mode. While armed, the +;; autocomplete previews each candidate — frame candidates move the playhead, +;; 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 (fn [db [_ gid]] (let [ctx (peek (get-in db [:view :stack]))] (assoc-in db [:view :linking] - {:gid gid :ctx ctx :playhead (scene/playhead (:view db) ctx)})))) -(rf/reg-event-db ::stop-linking (fn [db _] (assoc-in db [:view :linking] nil))) -(rf/reg-event-fx ::cancel-linking - (fn [{:keys [db]} _] - (let [{:keys [ctx playhead]} (get-in db [:view :linking]) - sf (scene/local->source (scene/content-segments (:scene db) ctx) playhead)] - {:db (-> db (assoc-in [:view :linking] nil) - (assoc-in [:view :playheads ctx] playhead)) - :player/seek (when sf (/ sf (:fps db)))}))) + {:gid gid :ctx ctx :playhead (scene/playhead (:view db) ctx) + :stack (get-in db [:view :stack])})))) + +(declare enter-ctx) ; defined below with the stack-nav events + +(defn- restore-linking + "Drop link mode and return to the snapshotted stack + playhead, reseeking." + [{:keys [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 (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) (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))) ;; cancelling a draft is local only; saving a real annotation / deleting one ;; pushes a delta to the backend (which merges + attributes it). @@ -338,10 +380,11 @@ (fn [{:keys [db]} [_ gid g orig]] (let [g (editable-group 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))} - (and id (= :annotation (:type g)) (seq patch)) - (assoc :http-xhrio (api/put-scene id {:changed {gid patch}} + (and id (seq patch)) + (assoc :http-xhrio (api/put-scene id {:changed {gid (if root? g patch)}} {:on-success [::scene-saved] :on-failure [::save-error]})))))) (rf/reg-event-fx ::delete-annotation diff --git a/tl/src/tl/md.cljs b/tl/src/tl/md.cljs new file mode 100644 index 0000000..169b9e4 --- /dev/null +++ b/tl/src/tl/md.cljs @@ -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
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)))))))) diff --git a/tl/src/tl/scene.cljs b/tl/src/tl/scene.cljs index 58726d1..e4fb4b8 100644 --- a/tl/src/tl/scene.cljs +++ b/tl/src/tl/scene.cljs @@ -378,27 +378,30 @@ (runs scene ctx gid))}))))] (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 - "The markdown token for a link {:label :ref :at}." - [{:keys [label ref at]}] - (str "[" label "](mark:" (name ref) "@" at ")")) - -(defn parse-content - "Split markdown `s` into a vector of [:text str] / [:link {:label :ref :at}]." - [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)] - (recur (cond-> out - (seq pre) (conj [:text pre]) - :always (conj [:link {:label (aget m 1) - :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])))))))) +(defn timelines + "Every reachable timeline you can open as a context — root, plus named child + annotations whose parent chain still reaches root — each with the stack path + to reach it and its parent's name (:in) for disambiguation. Orphans (parent + deleted) are dropped." + [scene] + (->> (:groups scene) + (keep (fn [[gid g]] + (when (and (= :annotation (:type g)) (not (:draft g))) + (when-let [path (path-to scene gid)] + (let [parent (:parent g)] + {:gid gid :name (or (:name g) (name gid)) + :in (if (or (nil? parent) (= :root parent)) + "root" (get-in scene [:groups parent :name] (name parent))) + :path path}))))) + (sort-by :name) + (into [{:gid :root :name "root" :in nil :path [:root]}]))) diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 9e1e16e..5e17035 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -19,6 +19,10 @@ (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 ::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 ::username (fn [db] (get-in db [:auth :username]))) (rf/reg-sub ::fps (fn [db] (:fps db))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 4c0f55f..3d63ee6 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -4,6 +4,7 @@ [reagent.core :as r] [re-frame.core :as rf] [tl.subs :as subs] + [tl.md :as md] [tl.scene :as scene] [tl.routes :as routes] [tl.events :as events])) @@ -101,20 +102,28 @@ (when discontinuity? (seek-video! fps ns)) (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))]))))) (when @play (reset! raf (js/requestAnimationFrame play-tick))))) (defn- engage-play! "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]) ctx @(rf/subscribe [::subs/context]) fps @(rf/subscribe [::subs/fps]) + ph @(rf/subscribe [::subs/playhead]) v @video-el] (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]) (reset! raf (js/requestAnimationFrame play-tick))))) @@ -142,6 +151,16 @@ (defn video-monitor [] [:video {:src @(rf/subscribe [::subs/clip-url]) :controls true :preload "auto" :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-pause (fn [_] (disengage!)) :ref (fn [n] (when n (reset! video-el n)))}]) @@ -282,31 +301,40 @@ {:ref (keyword ref) :at (js/parseInt at 10)})) (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] - (into [:div.md] - (map (fn [[k v]] - (if (= k :text) - v - (let [lf (scene/link-local scene ctx (select-keys v [:ref :at]))] + (md/render content + (fn [{:keys [kind label ref at]}] + (if (= :timeline kind) + (let [path (scene/path-to scene ref)] + [: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") :title (when-not lf "linked clip no longer in this timeline") :on-click #(when lf (goto! lf))} - (when-not lf "⚠ ") (:label v) - (when lf [:span.link-f (str " " (js/Math.round lf) "f")])]))) - (scene/parse-content content)))) + (when-not lf "⚠ ") label + (when lf [:span.link-f (str " " (js/Math.round lf) "f")])]))))) -(defn- build-chip [{:keys [label ref at]}] - (let [span (js/document.createElement "span") - lf (scene/link-local @(rf/subscribe [::subs/scene]) @(rf/subscribe [::subs/context]) - {:ref ref :at at})] - (set! (.-className span) (if lf "link-chip" "link-chip broken")) +(defn- build-chip [{:keys [label ref at kind]}] + (let [span (js/document.createElement "span") + scene @(rf/subscribe [::subs/scene]) + timeline? (= :timeline kind) + 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") - (when-not lf (set! (.-title span) "linked clip no longer in this timeline")) - (set! (.-textContent span) (str (when-not lf "⚠ ") label)) + (when-not ok (set! (.-title span) (if timeline? "timeline no longer here" + "linked clip no longer in this timeline"))) + (set! (.-textContent span) (str (cond timeline? "⤢ " (not ok) "⚠ ") label)) (aset (.-dataset span) "label" label) (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 (let [f (js/document.createElement "span")] (set! (.-className f) "link-f") @@ -316,24 +344,11 @@ (defn- populate! [^js el content] (set! (.-innerHTML el) "") - (doseq [[k v] (scene/parse-content content)] + (doseq [[k v] (md/parse-content content)] (.appendChild el (if (= k :text) (.createTextNode js/document 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 "Contenteditable markdown surface for the draft's :content. Uncontrolled: we populate it once from `initial`, then serialize back out on input. Exposes its @@ -341,7 +356,7 @@ chip at the caret." [gid initial] (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)] (when (and @el (= @el (.-activeElement js/document)) (pos? (.-rangeCount s)) (.contains @el (.-anchorNode s))) @@ -362,13 +377,20 @@ {:content-editable true :suppress-content-editable-warning true :data-placeholder "Content (optional)" :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-click (fn [e] (when-let [chip (.closest (.-target e) ".link-chip")] (when-let [lf (link-frame (.. chip -dataset -ref) (.. chip -dataset -at))] (goto! lf))))}])) -(defn- point-candidates [scene ctx q] +(defn- point-candidates [scene ctx q timelines?] (let [needle (str/lower-case q) n (when (re-matches #"-?\d+" q) (js/parseInt q 10))] (vec @@ -381,7 +403,15 @@ (:search item)))] (when (str/includes? hay needle) (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] (str (:label cand) " @" (js/Math.round local) "f" @@ -389,30 +419,39 @@ (str " +" off)))) (defn- pick-point [cand off] - (let [o (if (:abs cand) 0 (min (max 0 off) (max 0 (dec (or (:len cand) 1))))) - local (+ (:local cand) o)] - (assoc (cond-> (:point cand) (not (:abs cand)) (update :at + o)) - :label (point-label cand o local) - :local local - :candidate cand - :offset o))) + (if (= :timeline (:kind cand)) + (assoc (:point cand) :label (:label cand) :local (:local cand) :candidate cand :offset 0) + (let [o (if (:abs cand) 0 (min (max 0 off) (max 0 (dec (or (:len cand) 1))))) + local (+ (:local cand) o)] + (assoc (cond-> (:point cand) (not (:abs cand)) (update :at + o)) + :label (point-label cand o local) + :local local + :candidate cand + :offset o)))) (defn point-picker "Shared clip / annotation / frame autocomplete. `on-pick` receives the link point plus :label and resolved ctx-local :local. Callers decide whether that 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) "")) idx (r/atom 0) off (r/atom (or (:offset value) 0)) picked (r/atom value) 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)))) cur (or (:candidate @picked) (get cands i)) maxo (max 0 (dec (or (:len cur) 1))) o (min (max 0 @off) 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] (when cand (let [p (pick-point cand o2)] @@ -420,11 +459,8 @@ (reset! text (:label p)) (reset! off (:offset p)) (reset! open? false) - (goto! (:local p)) + (preview! cand o2) (on-pick p)))) - preview! (fn [cand o2] - (when cand - (goto! (:local (pick-point cand o2))))) go! (fn [j] (reset! picked nil) (reset! idx j) @@ -442,7 +478,7 @@ (reset! idx 0) (reset! off 0) (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 (fn [e] (case (.-key e) @@ -475,9 +511,11 @@ :on-mouse-down #(.preventDefault %) :on-mouse-enter #(go! j) :on-click #(emit! c 0)} + (when timelines? [:span.cand-kind (if (= :timeline (:kind c)) "⤢ " "↪ ")]) [:span.cand-label (:label 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] (some (fn [{m :mark [c d] :local}] @@ -494,7 +532,7 @@ (defn- link-picker [{:keys [scene ctx on-commit on-cancel]}] (r/with-let [picked (r/atom nil)] [: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-cancel on-cancel}] [: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))))] (set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3)))))))) [: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? [:div.commentary-head [: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)] (let [d @(rf/subscribe [::subs/draft-group]) scene @(rf/subscribe [::subs/scene]) - segs @(rf/subscribe [::subs/segments]) - ctx @(rf/subscribe [::subs/context]) + ;; anchor the form to the draft's home context, not the live stack top: + ;; 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]) linking @(rf/subscribe [::subs/linking]) gid (:gid 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)])) 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? (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))} - [:div.form-head (if new? "New annotation" "Edit annotation")] - [:div.form-row - [:input.form-name {:placeholder "Name" :value (:name d) - :on-change #(put (assoc d :name (.. % -target -value)))}] - [:input.form-color {:type "color" :value (:color d) - :on-change #(put (assoc d :color (.. % -target -value)))}]] + [:div.form-head (cond root? "Edit description" new? "New annotation" :else "Edit annotation")] + (when-not root? + [:div.form-row + [:input.form-name {:placeholder "Name" :value (:name d) + :on-change #(put (assoc d :name (.. % -target -value)))}] + [:input.form-color {:type "color" :value (:color d) + :on-change #(put (assoc d :color (.. % -target -value)))}]]) [:div.form-marks-label "Content"] ^{:key gid} [content-editor gid (:content orig)] (if (and linking (= gid (:gid linking))) [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])}] [:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-linking gid])} "🔗 Insert link"]) (when (and linking (= gid (:gid linking))) [:div.form-hint "Click a clip or the frame-readout to link it, or pick above."]) - [:div.form-marks-label "Marks"] - (doall - (for [[i row] (map-indexed vector rows)] - ^{:key i} - [:div.mark-row - [frame-chip scene segs put d i :start (:s row)] - [:span.mark-arrow "→"] - [frame-chip scene segs put d i :end (:e row)] - [:button.row-x {:type "button" :title "Remove" - :on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]])) - (when (map? pt) - [:div.mark-row - [:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt)) - " @" (:f pt) "f")]] - [:span.mark-arrow "→"] - [point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true - :placeholder "click end clip..." - :on-pick #(let [end-local (if (and (zero? (:offset %)) - (pos? (or (get-in % [:candidate :len]) 0))) - (+ (:local %) (get-in % [:candidate :len])) - (:local %))] - (when-let [p (local->draft-end-point segs end-local)] - (rf/dispatch [::events/draft-click-seg (:seg p) (:f p)])))}]]) - (when (= pt :new) - [:div.mark-row - [point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true - :placeholder "click a clip..." - :on-pick #(when-let [p (local->draft-point segs (:local %))] - (rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]]) - [:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."] - [:div.form-marks-label "Script"] - [:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-highlighting gid])} - (str "+ Add script annotation" - (when-let [n (seq (:script d))] (str " (" (count n) ")")))] + (when-not root? + [:<> + [:div.form-marks-label "Marks"] + (doall + (for [[i row] (map-indexed vector rows)] + ^{:key i} + [:div.mark-row + [frame-chip scene segs put d i :start (:s row)] + [:span.mark-arrow "→"] + [frame-chip scene segs put d i :end (:e row)] + [:button.row-x {:type "button" :title "Remove" + :on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]])) + (when (map? pt) + [:div.mark-row + [:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt)) + " @" (:f pt) "f")]] + [:span.mark-arrow "→"] + [point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true + :placeholder "click end clip..." + :on-pick #(let [end-local (if (and (zero? (:offset %)) + (pos? (or (get-in % [:candidate :len]) 0))) + (+ (:local %) (get-in % [:candidate :len])) + (:local %))] + (when-let [p (local->draft-end-point segs end-local)] + (rf/dispatch [::events/draft-click-seg (:seg p) (:f p)])))}]]) + (when (= pt :new) + [:div.mark-row + [point-picker {:scene scene :ctx ctx :class "active" + :placeholder "click a clip..." + :on-pick #(when-let [p (local->draft-point segs (:local %))] + (rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]]) + [:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."] + [:div.form-marks-label "Script"] + [:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-highlighting gid])} + (str "+ Add script annotation" + (when-let [n (seq (:script d))] (str " (" (count n) ")")))]]) [:div.form-actions [:button.save {:type "submit" :disabled (not valid?)} "Save"] [:button.cancel {:type "button" - :on-click #(if new? - (rf/dispatch [::events/drop-group gid]) - (rf/dispatch [::events/restore-group gid orig]))} + :on-click #(do (if new? + (rf/dispatch [::events/drop-group gid]) + (rf/dispatch [::events/restore-group gid orig])) + (rf/dispatch [::events/finish-edit]))} "Cancel"]]]))) ;; --- chrome --------------------------------------------------------------- diff --git a/tl/test/tl/scene_test.cljs b/tl/test/tl/scene_test.cljs index 6f23076..d16c843 100644 --- a/tl/test/tl/scene_test.cljs +++ b/tl/test/tl/scene_test.cljs @@ -1,5 +1,6 @@ (ns tl.scene-test (:require [cljs.test :refer-macros [deftest is testing]] + [tl.md :as md] [tl.scene :as s])) ;; --- shared fixture ------------------------------------------------------- @@ -327,15 +328,40 @@ (deftest parse-content-splits-text-and-links (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")] (is (= "[B-roll clip @10](mark:clip-b@10)" tok)) (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"]] - (s/parse-content s)))) - (is (= [] (s/parse-content ""))) - (is (= [[:text "plain note"]] (s/parse-content "plain note"))))) + (md/parse-content s)))) + (testing "labels with parens and @ survive the round-trip" + (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 (testing "a clip-scoped point resolves back to the same ctx-local frame"