(ns outliner.boundaries.dom.caret (:require [outliner.model.core.text-ops :as text-ops] [outliner.boundaries.dom.ui-utils :as ui-utils])) (def ^:private text-node-type 3) (def ^:private element-node-type 1) (def ^:private leading-invisible-pattern #"^[\uFEFF\u200C\u200D\u200E\u200F\u2060]+") (defn- collect-text "Recursively collects text from a DOM tree up to a target node and offset. Returns a map with :text (the accumulated string) and :found? (boolean)." [node target-node target-offset acc] (if (and target-node node (.-isSameNode node) (.isSameNode node target-node)) (if (= (.-nodeType node) text-node-type) (let [txt (subs (.-textContent node) 0 (min target-offset (count (.-textContent node))))] (assoc acc :text (str (:text acc) txt) :found? true)) (let [children (.-childNodes node) len (.-length children) to-take (if (>= target-offset 0) (min target-offset len) len)] (loop [i 0 current-acc acc] (if (>= i to-take) (assoc current-acc :found? true) (recur (inc i) (collect-text (aget children i) nil -1 current-acc)))))) (let [nt (.-nodeType node)] (cond (= nt text-node-type) (update acc :text str (.-textContent node)) (= nt element-node-type) (let [children (.-childNodes node) len (.-length children)] (loop [i 0 current-acc acc] (if (or (:found? current-acc) (>= i len)) current-acc (recur (inc i) (collect-text (aget children i) target-node target-offset current-acc))))) :else acc)))) (defn dom-pos->text-offset "Calculates the character offset in the 'clean' text representation corresponding to a specific position in the DOM tree." [root target-node target-offset] (let [initial-acc {:text "" :found? false}] (if (and root target-node (.-isSameNode root) (.isSameNode root target-node)) (let [children (.-childNodes root) len (.-length children) to-take (if (>= target-offset 0) (min target-offset len) len)] (loop [i 0 acc initial-acc] (if (>= i to-take) (count (text-ops/clean-text (:text acc))) (recur (inc i) (collect-text (aget children i) nil -1 acc))))) (let [children (.-childNodes root) len (.-length children) final-acc (loop [i 0 acc initial-acc] (if (or (:found? acc) (>= i len)) acc (recur (inc i) (collect-text (aget children i) target-node target-offset acc))))] (if (:found? final-acc) (count (text-ops/clean-text (:text final-acc))) nil))))) (defn range-in-element? [el r] (and el r (let [start-con (.-startContainer r) end-con (.-endContainer r)] (or (identical? el start-con) (.contains el start-con) (identical? el end-con) (.contains el end-con))))) (defn get-caret-pos ([el] (get-caret-pos el nil)) ([el e] (let [target-ranges (when (and e (.-getTargetRanges e)) (.getTargetRanges e)) range (if (and target-ranges (> (.-length target-ranges) 0)) (let [sr (aget target-ranges 0) r (.createRange js/document)] (.setStart r (.-startContainer sr) (.-startOffset sr)) (.setEnd r (.-endContainer sr) (.-endOffset sr)) (when (range-in-element? el r) r)) (let [selection (js/window.getSelection)] (when (and selection (> (.-rangeCount selection) 0)) (let [r (.getRangeAt selection 0)] (when (range-in-element? el r) r)))))] (let [pos (if (and el range) (dom-pos->text-offset el (.-endContainer range) (.-endOffset range)) nil)] (js/console.log "get-caret-pos" pos "has-el?" (some? el) "has-range?" (some? range)) pos)))) (defn get-caret-pos-from-event [e el] (let [touch (when (and (.-changedTouches e) (pos? (.. e -changedTouches -length))) (aget (.-changedTouches e) 0)) x (if touch (.-clientX touch) (.-clientX e)) y (if touch (.-clientY touch) (.-clientY e))] (cond (.-caretRangeFromPoint js/document) (let [range (.caretRangeFromPoint js/document x y)] (if (and el range) (dom-pos->text-offset el (.-endContainer range) (.-endOffset range)) nil)) (.-caretPositionFromPoint js/document) (let [pos (.caretPositionFromPoint js/document x y)] (if (and el pos) (dom-pos->text-offset el (.-offsetNode pos) (.-offset pos)) nil)) :else nil))) (defn caret-at-start? [e] (let [{:keys [editor-el]} (ui-utils/get-target-info e) el (or editor-el (.-target e)) pos (get-caret-pos el e) pos-no-e (get-caret-pos el) at-start? (or (some-> pos zero?) (some-> pos-no-e zero?))] (js/console.log "caret-at-start?" at-start? "pos" pos "pos-no-e" pos-no-e) at-start?)) (defn caret-at-end? [e] (let [{:keys [editor-el]} (ui-utils/get-target-info e) el (or editor-el (.-target e)) pos (get-caret-pos el e) pos-no-e (get-caret-pos el) len (count (text-ops/clean-text (.-textContent el))) at-end? (or (some-> pos (= len)) (some-> pos-no-e (= len)))] (js/console.log "caret-at-end?" at-end? "len" len "pos" pos "pos-no-e" pos-no-e) at-end?)) (defn caret-at-first-line? [e] (let [{:keys [editor-el]} (ui-utils/get-target-info e) el (or editor-el (.-target e)) sel (js/window.getSelection)] (or (not (and sel (pos? (.-rangeCount sel)))) (let [range (.getRangeAt sel 0) test-range (.cloneRange range)] (.setStart test-range el 0) (.setEnd test-range (.-startContainer range) (.-startOffset range)) (let [rects (.getClientRects test-range)] (or (< (.-length rects) 2) (let [first-rect (aget rects 0) last-rect (aget rects (dec (.-length rects)))] (<= (js/Math.abs (- (.-top first-rect) (.-top last-rect))) 3)))))))) (defn caret-at-last-line? [e] (let [{:keys [editor-el]} (ui-utils/get-target-info e) el (or editor-el (.-target e)) sel (js/window.getSelection)] (or (not (and sel (pos? (.-rangeCount sel)))) (let [range (.getRangeAt sel 0) test-range (.cloneRange range)] (.setStart test-range (.-endContainer range) (.-endOffset range)) (.setEnd test-range el (.-length (.-childNodes el))) (let [rects (.getClientRects test-range)] (or (< (.-length rects) 2) (let [first-rect (aget rects 0) last-rect (aget rects (dec (.-length rects)))] (<= (js/Math.abs (- (.-top first-rect) (.-top last-rect))) 3)))))))) (defn get-selection-offsets [el] (let [selection (js/window.getSelection)] (if (and selection (> (.-rangeCount selection) 0)) (let [range (.getRangeAt selection 0)] (if (range-in-element? el range) {:start (dom-pos->text-offset el (.-startContainer range) (.-startOffset range)) :end (dom-pos->text-offset el (.-endContainer range) (.-endOffset range))} nil)) nil))) (defn- find-text-pos "Recursively finds the DOM node and local offset for a given text character index." [node target-pos acc] (if (:found? acc) acc (let [nt (.-nodeType node)] (cond (= nt text-node-type) (let [text (.-textContent node) len (count text) current-pos (:current-pos acc)] (if (<= target-pos (+ current-pos len)) (assoc acc :found? true :result {:node node :offset (- target-pos current-pos)}) (update acc :current-pos + len))) (= nt element-node-type) (let [children (.-childNodes node) child-count (.-length children)] (loop [i 0 current-acc acc] (if (or (:found? current-acc) (>= i child-count)) current-acc (recur (inc i) (find-text-pos (aget children i) target-pos current-acc))))) :else acc)))) (defn text-offset->dom-pos "Performs the inverse of 'dom-pos->text-offset': finds the exact DOM text node and local offset that corresponds to a character offset in the 'clean' text representation." [root target-pos] (let [text-content (.-textContent root) leading-match (re-find leading-invisible-pattern text-content) invisible-count (count leading-match) adjusted-target (+ target-pos invisible-count) initial-acc {:current-pos 0 :found? false :result nil} children (.-childNodes root) child-count (.-length children)] (:result (loop [i 0 acc initial-acc] (if (or (:found? acc) (>= i child-count)) acc (recur (inc i) (find-text-pos (aget children i) adjusted-target acc))))))) (defn set-caret-pos [el pos] (when el (.focus el) (let [range (js/document.createRange) sel (js/window.getSelection)] (try (if-let [{:keys [node offset]} (text-offset->dom-pos el pos)] (do (.setStart range node offset) (.setEnd range node offset)) (let [last-idx (.-length (.-childNodes el))] (.setStart range el last-idx) (.setEnd range el last-idx))) (.removeAllRanges sel) (.addRange sel range) (.collapse range false) (catch js/Error e (js/console.warn "Caret pos error" e)))))) (defn set-selection-range [el start end] (when el (let [range (js/document.createRange) sel (js/window.getSelection)] (try (let [start-pos (text-offset->dom-pos el start) end-pos (text-offset->dom-pos el end)] (if start-pos (.setStart range (:node start-pos) (:offset start-pos)) (let [last-idx (.-length (.-childNodes el))] (.setStart range el last-idx))) (if end-pos (.setEnd range (:node end-pos) (:offset end-pos)) (let [last-idx (.-length (.-childNodes el))] (.setEnd range el last-idx))) (.removeAllRanges sel) (.addRange sel range)) (catch js/Error e (js/console.warn "Selection range error" e)))))) (defn scroll-to-selection [] (let [sel (js/window.getSelection)] (when (and sel (pos? (.-rangeCount sel))) (let [range (.getRangeAt sel 0) rect (.getBoundingClientRect range) top (.-top rect) bottom (.-bottom rect) vp-height js/window.innerHeight header-height 80 footer-height 100 padding 20] (when (pos? (.-height rect)) (cond (< top (+ header-height padding)) (js/window.scrollBy #js {:top (- top header-height padding) :behavior "smooth"}) (> bottom (- vp-height footer-height padding)) (js/window.scrollBy #js {:top (- bottom (- vp-height footer-height padding)) :behavior "smooth"})))))))