diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index cb4011e..2e23516 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -136,7 +136,18 @@ body { overflow: hidden; } .form-marks-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #7aa6d6; margin-top: 4px; } -.mark-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +/* contenteditable content surface + inline link chips */ +.content-editor { white-space: pre-wrap; word-break: break-word; outline: none; cursor: text; } +.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; + padding: 0 5px; margin: 0 1px; font-size: 12px; cursor: pointer; user-select: none; } +.link-chip:hover { background: #1d3e60; } +.link-chip.broken { background: #3a1d1d; color: #e69; border-color: #a44; } +.link-chip .link-f { color: #6f9; opacity: .7; font-size: 11px; } +.md .link-chip .link-f { color: #6f9; } + +.mark-row { display: flex; flex-wrap: nowrap; align-items: center; gap: 6px; min-width: 0; } .mark-arrow { color: #666; } .row-x, .add-mark { background: #1c1c1c; color: #aaa; border: 1px solid #333; border-radius: 4px; @@ -151,16 +162,18 @@ body { overflow: hidden; } } /* point editor + autocomplete */ -.pt-input { position: relative; flex: 1 1 130px; display: inline-block; } +.pt-input { position: relative; flex: 1 1 130px; display: flex; align-items: center; min-width: 0; } +.mark-row .pt-input { flex: 0 1 160px; max-width: 180px; } .pt-text { - width: 100%; box-sizing: border-box; + flex: 1; min-width: 0; box-sizing: border-box; background: #0e0e0e; color: #ddd; border: 1px solid #333; border-radius: 4px; padding: 6px 8px; font-size: 13px; } +.pt-frame-row { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; } .pt-dropdown { position: absolute; left: 0; right: 0; top: 100%; z-index: 5; margin: 2px 0 0; padding: 4px; list-style: none; - max-height: 220px; overflow-y: auto; + max-height: 124px; overflow-y: auto; background: #1b1b1b; border: 1px solid #3a6ea5; border-radius: 4px; box-shadow: 0 6px 18px rgba(0,0,0,0.5); } @@ -168,8 +181,13 @@ body { overflow: hidden; } color: #cde; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pt-dropdown li.hi { background: #2a4a6a; } +.pt-dropdown .cand-group { color: #789; font-size: 11px; } +.pt-dropdown .link-f { color: #6f9; opacity: .7; } -.pt-chip { flex: 1; display: flex; align-items: center; gap: 4px; +.link-insert { display: flex; align-items: flex-start; gap: 6px; align-self: stretch; } +.link-insert .pt-input { flex: 1 1 auto; max-width: none; } + +.pt-chip { flex: 1 1 0; min-width: 0; display: flex; align-items: center; gap: 4px; background: #233; border: 1px solid #3a6ea5; border-radius: 4px; padding: 2px 4px; } .pt-chip-name { font-size: 12px; color: #cfe3f5; white-space: nowrap; diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index b3692e1..f99a7f9 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -135,6 +135,29 @@ (fn [db [_ gid page rect]] (update-in db [:scene :groups gid :script] (fnil conj []) {:page page :rect rect}))) +;; content edits go through their own event (not put-group) so the contenteditable +;; surface can update :content without re-reading a possibly-stale draft map. +(rf/reg-event-db ::set-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" +;; 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). +(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)))}))) + (rf/reg-event-db ::set-playhead (fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf))) (rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p))) diff --git a/tl/src/tl/scene.cljs b/tl/src/tl/scene.cljs index 77356cf..58726d1 100644 --- a/tl/src/tl/scene.cljs +++ b/tl/src/tl/scene.cljs @@ -305,3 +305,100 @@ :marks [{:id :root-m :start 0 :end duration}]})})) (defn clip-name [scene gid] (:name (grp scene gid))) + +;; --- links ---------------------------------------------------------------- +;; A link is a ref-point {:ref id :at n} — the same shape as a mark endpoint, so +;; it resolves through the usual machinery — named inside an annotation's markdown +;; content as `[label](mark:ref@at)`. The id is just a mark id in the flat pool: +;; a clip group (clip-scoped time), the context itself (absolute time), or another +;; annotation's mark (a moment in that annotation). We only need the ctx-local +;; frame (to seek / label it) and the pickable targets within a context. + +(defn link-local + "Local frame within `ctx` for link ref-point `point`, or nil if it no longer + resolves into ctx. An absolute link (:ref = ctx) IS the local frame." + [scene ctx {:keys [ref at] :as point}] + (if (= ref ctx) + at + (when-let [{:keys [frame]} (point-frame scene ctx point)] + (source->local (content-segments scene ctx) frame)))) + +(defn seg-point + "Ref-point {:ref :at} for mark-time frame `f` within content-segment `seg` + (the convention selection->marks uses, so it resolves identically)." + [scene {:keys [mark src]} f] + {:ref mark :at (+ (- (first src) (:xs (target-range scene mark))) (or f 0))}) + +(defn- runs + "Contiguous runs of `gid`'s marks in `ctx`-local coords, each {:id :lo :len} + where :id is the run's first mark (a discontinuous annotation lists each + piece) and :len is that first mark's frame count — the offset you can pick + into it, since the link's ref only spans that one mark." + [scene ctx gid] + (let [csegs (content-segments scene ctx) + pts (->> (:marks (grp scene gid)) + (keep (fn [m] (when-let [seg (first (resolve-mark scene gid m))] + (let [[s e] (:src seg)] + {:id (:id m) :lo (source->local csegs s) + :hi (source->local csegs (dec e))})))) + (filter :lo) + (sort-by :lo))] + (reduce (fn [out {:keys [id lo hi]}] + (if-let [p (peek out)] + (if (and (:hi p) hi (<= (- lo (:hi p)) 1)) + (conj (pop out) (assoc p :hi hi)) ; extend run (display only) + (conj out {:id id :lo lo :hi hi :len (inc (- hi lo))})) + [{:id id :lo lo :hi hi :len (inc (- hi lo))}])) + [] pts))) + +(defn linkables + "Pickable link targets within `ctx`, grouped for the autocomplete: one group + per video track (its clips, by start) and per child annotation (its run + starts). Each candidate is {:label :point {:ref :at} :local}." + [scene ctx] + (let [tracks (->> (content-segments scene ctx) + (group-by :track) + (mapv (fn [[t segs]] + (let [track-name (get-in scene [:tracks t :name] (name t))] + {:name track-name :kind :track + :items (mapv (fn [i {:keys [mark] [c d] :local :as seg}] + {:label (str track-name " (" (inc i) ")") + :search (clip-name scene mark) + :point (seg-point scene seg 0) :local c :len (- d c)}) + (range) + (sort-by (comp first :local) segs))})))) + anns (->> (:groups scene) + (keep (fn [[gid g]] + (when (and (= :annotation (:type g)) (= ctx (:parent g)) + (not (:draft g))) + {:name (or (:name g) (name gid)) :kind :annotation + :items (mapv (fn [{:keys [id lo len]}] + {:label (or (:name g) (name gid)) + :point {:ref id :at 0} :local lo :len len}) + (runs scene ctx gid))}))))] + (vec (concat (sort-by :name tracks) (sort-by :name anns))))) + +(def ^:private link-re "\\[([^\\]]*)\\]\\(mark:([^@)]+)@(-?\\d+)\\)") + +(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])))))))) diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 9e39546..9e1e16e 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -14,6 +14,7 @@ (rf/reg-sub ::script-url (fn [db] (get-in db [:project :script]))) (rf/reg-sub ::pane (fn [db] (get-in db [:view :pane] :annotations))) (rf/reg-sub ::highlighting (fn [db] (get-in db [:view :highlighting]))) +(rf/reg-sub ::linking (fn [db] (get-in db [:view :linking]))) (rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails]))) (rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status]))) (rf/reg-sub ::create-error (fn [db] (:create-error db))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 5e164dd..6d0be2f 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -11,6 +11,8 @@ (defonce raf (atom nil)) (defonce scroll-el (atom nil)) (defonce play (atom nil)) ; {:ctx :segs :fps :idx} while playing, else nil +(defonce active-insert! (atom nil)) ; the live content-editor's (insert! link) fn +(declare commit-link!) (def ^:private seek-guard-frames 0.75) (def ^:private seek-settle-frames 2) @@ -144,11 +146,16 @@ :ref (fn [n] (when n (reset! video-el n)))}]) (defn- clip-label - "“