(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))))))