(ns outliner.boundaries.dom.ui-utils (:require [outliner.model.core.text-ops :as text-ops] [outliner.model.state :as state] [clojure.string :as string] [outliner.model.core.annotations :as annotations])) (def middle-button 1) (def ^:private text-node-type 3) (def ^:private element-node-type 1) (defn- get-node-tag [node] (some-> (.-tagName node) string/lower-case)) ;; --- DOM Element Info --- (defn get-element-info [el] (let [el (if (and el (= (.-nodeType el) text-node-type)) (.-parentElement el) el) id-el (when (and el (.-closest el)) (.closest el "[data-node-id]")) context-el (when (and el (.-closest el)) (.closest el "[data-node-context]")) link-el (when (and el (.-closest el)) (.closest el "a"))] {:id (when id-el (.getAttribute id-el "data-node-id")) :context (when context-el (keyword (.getAttribute context-el "data-node-context"))) :action (when id-el (.getAttribute id-el "data-action")) :href (when link-el (.getAttribute link-el "href")) :editor-el context-el})) (defn get-target-info [e] (let [target (or (.-target e) js/document.activeElement) el (cond (nil? target) nil (and (pos? (.-nodeType target)) (.-closest target)) target :else (.-parentElement target))] (get-element-info el))) ;; --- Clipboard --- (defn copy-to-clipboard [text] (let [legacy-copy (fn [t] (let [textarea (js/document.createElement "textarea")] (set! (.-value textarea) t) (set! (.-style textarea) "position:fixed;top:0;left:0;opacity:0;") (js/document.body.appendChild textarea) (.focus textarea) (.select textarea) (try (if (js/document.execCommand "copy") (state/show-toast! "Copied to clipboard") (js/console.error "execCommand copy failed")) (catch :default e (js/console.error "Failed to copy: " e)) (finally (js/document.body.removeChild textarea)))))] (if (and js/navigator.clipboard (.-writeText js/navigator.clipboard)) (-> (.writeText js/navigator.clipboard text) (.then #(state/show-toast! "Copied to clipboard")) (.catch (fn [e] (js/console.warn "Clipboard API failed, falling back" e) (legacy-copy text)))) (legacy-copy text)))) ;; --- HTML ↔ Annotations --- (defn- merge-node-results [acc res] (let [curr-len (count (:text acc)) new-text (str (:text acc) (:text res)) new-annotations (into (:annotations acc) (map (fn [ann] (-> ann (update :range-start + curr-len) (update :range-end + curr-len))) (:annotations res)))] {:text new-text :annotations new-annotations})) (defn- get-annotation-from-tag [tag-name node start end] (cond (contains? #{"b" "strong"} tag-name) {:range-start start :range-end end :type :style :style :bold} (contains? #{"i" "em"} tag-name) {:range-start start :range-end end :type :style :style :italic} (= tag-name "mark") {:range-start start :range-end end :type :style :style :highlight} (= tag-name "code") {:range-start start :range-end end :type :style :style :code} (= tag-name "a") {:range-start start :range-end end :type :link :url (.getAttribute node "href")} (and (= tag-name "span") (.. node -classList (contains "horizontal-line"))) {:range-start start :range-end end :type :horizontal-line} :else nil)) (defn- html-to-annotated-recursive [node] (cond (nil? node) {:text "" :annotations []} (= (.-nodeType node) text-node-type) {:text (text-ops/clean-text (.-textContent node)) :annotations []} (= (.-nodeType node) element-node-type) (let [tag-name (get-node-tag node) children (.-childNodes node) len (.-length children) combined (loop [i 0 acc {:text "" :annotations []}] (if (>= i len) acc (recur (inc i) (merge-node-results acc (html-to-annotated-recursive (aget children i)))))) my-annotation (get-annotation-from-tag tag-name node 0 (count (:text combined)))] (if (and my-annotation (= (:type my-annotation) :link)) (let [url (:url my-annotation) display-text (:text combined) new-text (str "[" display-text "](" url ")") new-annotations (conj (mapv (fn [ann] (-> ann (update :range-start + 1) (update :range-end + 1))) (:annotations combined)) {:range-start 0 :range-end (count new-text) :type :markdown-link :url url :display-text display-text})] {:text new-text :annotations new-annotations}) (if my-annotation (update combined :annotations conj my-annotation) combined))) :else {:text "" :annotations []})) (defn html-to-annotated [node] (update (html-to-annotated-recursive node) :annotations annotations/merge-annotations)) (defn- escape-html [s] (if (nil? s) "" (-> s (string/replace #"&" "&") (string/replace #"<" "<") (string/replace #">" ">") (string/replace #"\"" """)))) (defn- resolve-link-info [url node-exists-fn] (let [shortcut-id (when (and url (string/starts-with? url "node-id://")) (subs url (count "node-id://"))) shortcut? (some? shortcut-id) broken? (and shortcut? node-exists-fn (not (node-exists-fn shortcut-id)))] {:url url :shortcut? shortcut? :broken? broken?})) (declare render-annotated-html-content) (defn- wrap-annotation-html [focused? node-exists-fn inner annotation] (case (:type annotation) :style (case (:style annotation) :bold (str "" inner "") :italic (str "" inner "") :bold-italic (str "" inner "") :highlight (str "" inner "") :code (str "" inner "") inner) (:link :markdown-link) (if focused? inner (let [{:keys [url shortcut? broken?]} (resolve-link-info (:url annotation) node-exists-fn)] (str "" inner ""))) :horizontal-line (str "" inner "") inner)) (defn- get-link-label-range [text start end is-markdown] (if is-markdown (let [label-start (inc start) label-end (or (string/index-of text "](" label-start) end)] [label-start label-end]) [start end])) (defn- get-internal-annotations [all-annotations range-start range-end link-to-exclude] (->> all-annotations (filter (fn [a] (not= a link-to-exclude))) (map (fn [a] (let [s (max (:range-start a) range-start) e (min (:range-end a) range-end)] (when (< s e) (assoc a :range-start (- s range-start) :range-end (- e range-start)))))) (filter some?) vec)) (defn- render-link-html [focused? text all-annotations node-exists-fn start end link] (let [[label-start label-end] (get-link-label-range text start end (= (:type link) :markdown-link)) label (subs text label-start label-end) internal (get-internal-annotations all-annotations label-start label-end link) rendered-label (render-annotated-html-content label internal focused? node-exists-fn) wrapped-link (wrap-annotation-html focused? node-exists-fn rendered-label link) wrapping-styles (filter (fn [a] (and (= (:type a) :style) (<= (:range-start a) start) (>= (:range-end a) end))) all-annotations)] (reduce (partial wrap-annotation-html focused? node-exists-fn) wrapped-link (annotations/sort-annotations wrapping-styles)))) (defn- render-plain-segment-html [focused? text all-annotations node-exists-fn start end] (let [content (subs text start end) internal (get-internal-annotations all-annotations start end nil) segments (text-ops/get-segments content internal)] (apply str (map (fn [seg] (let [v (escape-html (:text seg)) segment-annotations (:anns seg)] (reduce (partial wrap-annotation-html focused? node-exists-fn) v (annotations/sort-annotations segment-annotations)))) segments)))) (defn- render-annotated-html-content [text all-annotations focused? node-exists-fn] (if focused? (let [segments (text-ops/get-segments text all-annotations)] (apply str (map (fn [seg] (let [v (escape-html (:text seg)) segment-annotations (:anns seg)] (reduce (partial wrap-annotation-html focused? node-exists-fn) v (annotations/sort-annotations segment-annotations)))) segments))) (let [top-links (annotations/get-top-annotations all-annotations #{:link :markdown-link}) boundaries (annotations/get-boundaries top-links (count text))] (loop [bs boundaries acc []] (if (next bs) (let [start (first bs) end (second bs) link (some (fn [ann] (when (and (= (:range-start ann) start) (= (:range-end ann) end)) ann)) top-links)] (if link (recur (rest bs) (conj acc (render-link-html focused? text all-annotations node-exists-fn start end link))) (recur (rest bs) (conj acc (render-plain-segment-html focused? text all-annotations node-exists-fn start end))))) (apply str acc)))))) (defn render-annotated-html ([annotated] (render-annotated-html annotated true nil false)) ([annotated focused?] (render-annotated-html annotated focused? nil false)) ([annotated focused? node-exists-fn] (render-annotated-html annotated focused? node-exists-fn false)) ([{:keys [text annotations]} focused? node-exists-fn include-zwsp?] (let [prefix (if include-zwsp? "\uFEFF" "")] (if (empty? text) prefix (str prefix (render-annotated-html-content text (vec annotations) focused? node-exists-fn)))))) (defn dom-content-matches-state? [el text annotations] (let [current (html-to-annotated el)] (and (= (:text current) text) (= (:annotations current) (vec annotations))))) (defn sync-dom-from-state! ([el text annotations focused?] (sync-dom-from-state! el text annotations focused? nil false)) ([el text annotations focused? node-exists-fn] (sync-dom-from-state! el text annotations focused? node-exists-fn false)) ([el text annotations focused? node-exists-fn include-zwsp?] (let [expected-html (render-annotated-html {:text text :annotations annotations} focused? node-exists-fn include-zwsp?)] (when (and (not= (.-innerHTML el) expected-html) (or focused? (not= js/document.activeElement el) (not (dom-content-matches-state? el text annotations)))) (set! (.-innerHTML el) expected-html))))) (def ensure-content sync-dom-from-state!) ;; --- Hiccup Rendering --- (declare render-annotated-content) (defn- wrap-annotation-hiccup [idx node-exists-fn link-props inner annotation] (let [tag (case (:type annotation) :style (case (:style annotation) :bold :strong :italic :em :bold-italic :strong :highlight :mark :code :code nil) (:link :markdown-link) :a :horizontal-line :span nil) props (case (:type annotation) :style (case (:style annotation) :bold {:key (str idx "-b")} :italic {:key (str idx "-i")} :bold-italic {:key (str idx "-bi")} :highlight {:key (str idx "-h")} :code {:key (str idx "-c")} {}) (:link :markdown-link) (let [{:keys [url shortcut? broken?]} (resolve-link-info (:url annotation) node-exists-fn)] (cond-> (assoc link-props :key (str idx "-l") :href url :content-editable "false") shortcut? (-> (dissoc :target :rel) (assoc :class (if broken? "shortcut-link broken" "shortcut-link"))))) :horizontal-line {:key (str idx "-hr") :class "horizontal-line"} {}) content (let [children (if (and (vector? inner) (keyword? (first inner))) [inner] (if (sequential? inner) inner [inner]))] (if (= (:style annotation) :bold-italic) [(into [:em] children)] children))] (if tag (into [tag props] content) inner))) (defn- render-link-hiccup [idx text all-annotations node-exists-fn link-props start end link] (let [[label-start label-end] (get-link-label-range text start end (= (:type link) :markdown-link)) label (subs text label-start label-end) internal (get-internal-annotations all-annotations label-start label-end link) rendered-label (render-annotated-content label internal node-exists-fn link-props) wrapped-link (wrap-annotation-hiccup idx node-exists-fn link-props rendered-label link) wrapping-styles (filter (fn [a] (and (= (:type a) :style) (<= (:range-start a) start) (>= (:range-end a) end))) all-annotations)] (reduce (partial wrap-annotation-hiccup idx node-exists-fn link-props) wrapped-link (annotations/sort-annotations wrapping-styles)))) (defn- render-plain-segment-hiccup [idx text all-annotations node-exists-fn link-props start end] (let [content (subs text start end) internal (get-internal-annotations all-annotations start end nil) segments (text-ops/get-segments content internal)] (map-indexed (fn [j seg] (let [v (:text seg) segment-annotations (:anns seg)] (reduce (partial wrap-annotation-hiccup (str idx "-" j) node-exists-fn link-props) v (annotations/sort-annotations segment-annotations)))) segments))) (defn- render-annotated-content [text all-annotations node-exists-fn link-props] (let [top-links (annotations/get-top-annotations all-annotations #{:link :markdown-link}) boundaries (annotations/get-boundaries top-links (count text))] (loop [bs boundaries idx 0 acc []] (if (next bs) (let [start (first bs) end (second bs) link (some (fn [ann] (when (and (= (:range-start ann) start) (= (:range-end ann) end)) ann)) top-links)] (if link (recur (rest bs) (inc idx) (conj acc (render-link-hiccup idx text all-annotations node-exists-fn link-props start end link))) (recur (rest bs) (inc idx) (into acc (render-plain-segment-hiccup idx text all-annotations node-exists-fn link-props start end))))) acc)))) (defn render-annotated [{:keys [text annotations]} & [node-exists-fn include-zwsp?]] (let [prefix (if include-zwsp? "\uFEFF" "")] (if (empty? text) [:span prefix] (let [link-props {:target "_blank" :rel "noopener noreferrer" :on-click #(.stopPropagation %) :on-mouse-down #(.stopPropagation %) :on-touch-start #(.stopPropagation %) :on-pointer-down #(.stopPropagation %) :on-focus #(.stopPropagation %)}] (into [:span prefix] (render-annotated-content text (vec annotations) node-exists-fn link-props))))))