(ns outliner.view.events (:require [outliner.model.state :as state] [outliner.control.dispatch :as dispatch] [outliner.view.keybindings :as keybindings] [outliner.view.focus :as focus] [outliner.boundaries.dom.caret :as caret] [outliner.boundaries.dom.ui-utils :as ui-utils] [outliner.boundaries.auth :as auth] [clojure.string :as string])) (defn process-keydown [e context id] (when-let [binding (keybindings/match-binding e context)] (.preventDefault e) (let [cmd (:command binding) args (keybindings/get-command-args cmd e id)] (dispatch/dispatch! (into [cmd] args))))) ;; --- Global Handlers --- (defn on-global-click [e] (let [{:keys [id action editor-el]} (ui-utils/get-target-info e) target (let [t (.-target e)] (if (and t (= (.-nodeType t) 3)) (.-parentElement t) t)) anchor (and target (.-closest target) (.closest target "a")) is-content-link? (and anchor editor-el (.contains editor-el anchor))] (when is-content-link? (let [href (.getAttribute anchor "href") shortcut? (and href (string/starts-with? href "node-id://"))] (when (and id editor-el (not shortcut?)) (let [node (get-in @state/doc-state [:nodes id]) link-ann (first (filter #(and (#{:link :markdown-link} (:type %)) (= (:url %) href)) (:annotations node))) pos (or (:range-end link-ann) 0)] (set! (.-innerHTML editor-el) (ui-utils/render-annotated-html {:text (:text node) :annotations (:annotations node)} true state/node-exists? (auth/is-mobile?))) (dispatch/dispatch! [:outliner.command/focus id pos]))) (when href (if shortcut? (let [zoom-id (subs href (count "node-id://"))] (.preventDefault e) (when (state/node-exists? zoom-id) (dispatch/dispatch! [:outliner.command/zoom-in zoom-id]))) (js/window.open href "_blank" "noopener,noreferrer"))))) (when (and id editor-el (not is-content-link?)) (let [view-state @state/view-state is-focused? (= id (:focused-id view-state))] (when (and (not is-focused?) (.-isConnected editor-el) (not= js/document.activeElement editor-el)) (.focus editor-el)) (let [pos (caret/get-caret-pos-from-event e editor-el)] (when (or (not is-focused?) (not= pos (:preserved-caret-position view-state))) (dispatch/dispatch! [:outliner.command/focus id pos]))))) (case action "expand" (dispatch/dispatch! [:outliner.command/toggle-expand id]) "menu" (do (.stopPropagation e) (.preventDefault e) (dispatch/dispatch! [:outliner.command/toggle-context-menu id])) nil (when (and (not editor-el) (not (some-> target (.closest ".mobile-toolbar"))) (not (some-> target (.closest ".app-header")))) (dispatch/dispatch! [:outliner.command/focus nil]))))) (defn on-global-input [e] (let [{:keys [id editor-el]} (ui-utils/get-target-info e)] (when (and id editor-el) (let [pos (caret/get-caret-pos editor-el e) annotated (ui-utils/html-to-annotated editor-el)] (dispatch/dispatch! [:outliner.command/update-node-text id annotated pos]))))) (defn on-paste [e] (let [{:keys [id editor-el]} (ui-utils/get-target-info e) clipboard-data (.-clipboardData e) text (.getData clipboard-data "text/plain")] (when (and id editor-el) (.preventDefault e) (if (string/includes? text "\n") (let [pos (caret/get-caret-pos editor-el e)] (dispatch/dispatch! [:outliner.command/paste-markdown id pos text])) (js/document.execCommand "insertText" false text))))) (defn on-global-keydown [e] (let [{:keys [id context]} (ui-utils/get-target-info e)] (process-keydown e context id))) (defn- sync-selection-to-state [id editor-el view-state] (let [offsets (caret/get-selection-offsets editor-el)] (when (and offsets (or (not= id (:focused-id view-state)) (if (= (:start offsets) (:end offsets)) (not= (:start offsets) (:preserved-caret-position view-state)) (not= offsets (:preserved-selection view-state))))) (dispatch/dispatch! [:outliner.command/focus id (if (= (:start offsets) (:end offsets)) (:start offsets) offsets)])))) (defn- clear-focus-if-needed [view-state] (let [sys-state @state/sys-state active js/document.activeElement] (when (and (:focused-id view-state) (not (#{:pending-sync :syncing} (:sync-status sys-state))) (not (:preserved-caret-position view-state)) (not (:preserved-selection view-state)) (not (focus/focus-locked?))) (dispatch/dispatch! [:outliner.command/focus nil])))) (defn on-selection-change [_] (let [sel (js/window.getSelection) view-state @state/view-state] (when (and (not (:preserved-caret-position view-state)) (not (:preserved-selection view-state)) (not (focus/focus-locked?))) (if (and sel (pos? (.-rangeCount sel))) (let [range (.getRangeAt sel 0) node (.-commonAncestorContainer range) {:keys [id editor-el]} (ui-utils/get-element-info node)] (if (and id editor-el) (sync-selection-to-state id editor-el view-state) (clear-focus-if-needed view-state))) (clear-focus-if-needed view-state))))) (defn on-before-input [e id context editor-el] (let [input-type (.-inputType e) pos (caret/get-caret-pos editor-el e) pos-no-e (caret/get-caret-pos editor-el)] (cond (and (#{"deleteContentBackward" "deleteSoftLineBackward" "deleteHardLineBackward" "deleteBackward" "deleteWordBackward"} input-type) (= context :node)) (let [at-start? (or (some-> pos zero?) (some-> pos-no-e zero?))] (when at-start? (.preventDefault e) (dispatch/dispatch! [:outliner.command/merge-or-delete id (:zoom-id @state/view-state)]))) (or (= input-type "insertParagraph") (= input-type "insertLineBreak")) (do (.preventDefault e) (let [mock-event #js {:key "Enter" :shiftKey false :ctrlKey false :altKey false :metaKey false :target editor-el :preventDefault #(.preventDefault e)}] (process-keydown mock-event context id)))))) ;; --- Drag and Drop --- (defn on-drag-start [e id] (dispatch/dispatch! [:outliner.command/drag-start id]) (.. e -dataTransfer (setData "text/plain" id))) (defn on-drag-over [e target-id] (.preventDefault e) (let [rect (.. e -currentTarget getBoundingClientRect) y (- (.-clientY e) (.-top rect)) h (.-height rect) pos (cond (< y (* h 0.25)) :before (> y (* h 0.75)) :after :else :as-child)] (dispatch/dispatch! [:outliner.command/drag-over target-id pos]))) (defn on-drop [e target-id] (.preventDefault e) (dispatch/dispatch! [:outliner.command/drop target-id])) (defn on-drag-end [_] (dispatch/dispatch! [:outliner.command/drag-end])) (defn on-drag-leave [id] (when (= id (:drop-target @state/view-state)) (dispatch/dispatch! [:outliner.command/drag-leave]))) ;; --- Viewport --- (defn setup-visual-viewport-listener [] (when-let [vv (.-visualViewport js/window)] (let [ticking (atom false) pending-update? (atom false) update-fn (fn update-fn [_] (if @ticking (reset! pending-update? true) (do (reset! ticking true) (js/requestAnimationFrame (fn [] (let [height (.-height vv) width (.-width vv) top (.-offsetTop vv) left (.-offsetLeft vv)] (swap! state/sys-state assoc :viewport {:height height :width width :top top :left left})) (reset! ticking false) (when @pending-update? (reset! pending-update? false) (update-fn nil)))))))] (.addEventListener vv "resize" update-fn) (.addEventListener vv "scroll" update-fn) (.addEventListener js/window "scroll" update-fn) (.addEventListener js/window "resize" update-fn) (.addEventListener js/window "focusin" update-fn) (.addEventListener js/window "focusout" (fn [e] (update-fn e) (js/setTimeout #(update-fn nil) 100) (js/setTimeout #(update-fn nil) 300) (js/setTimeout #(update-fn nil) 500))) (update-fn nil)))) ;; --- Initialization --- (defn init-events! [] (js/window.addEventListener "keydown" on-global-keydown) (js/window.addEventListener "paste" on-paste) (js/document.addEventListener "selectionchange" on-selection-change) (js/window.addEventListener "click" (fn [_] (when (:active-context-menu-id @state/view-state) (dispatch/dispatch! [:outliner.command/close-context-menu])))) (js/window.addEventListener "popstate" (fn [_] (let [zoom-id (state/get-zoom-id-from-url)] (when (not= zoom-id (:zoom-id @state/view-state)) (dispatch/dispatch! [:outliner.command/zoom-in zoom-id]))))) (.addEventListener js/document "beforeinput" (fn [e] (let [{:keys [id context editor-el]} (ui-utils/get-target-info e)] (when (and id context) (on-before-input e id context editor-el))))) (setup-visual-viewport-listener))