(ns outliner.view.components (:require [reagent.core :as r] [clojure.pprint :as pprint] [outliner.model.state :as state] [outliner.control.dispatch :as dispatch] [outliner.model.core.logic :as logic] [outliner.boundaries.dom.ui-utils :as ui-utils] [outliner.boundaries.dom.caret :as caret] [outliner.view.focus :as focus] [outliner.boundaries.auth :as auth] [outliner.boundaries.firebase :as fb] [outliner.view.icons :as icons] [outliner.view.events :as events])) ;; --- Focus and Caret Management Lifecycle --- ;; ;; Managing focus and caret position in a collaborative, tree-based editor ;; with rich text and structural DOM changes is complex. Here's how it works: ;; ;; 1. Focus Detection: ;; - 'node-text-editor' and 'zoom-title-editor' are content-editable divs. ;; - Global click and input events (in events.cljs) detect which node is being ;; interacted with using 'ui-utils/get-target-info'. ;; ;; 2. State Synchronization: ;; - When a node is focused, its ID is stored in 'view-state' as ':focused-id'. ;; - When text is changed, 'dispatch! [:outliner.command/update-node-text ...]' ;; is called. ;; ;; 3. Blur and the 500ms Delay ('maybe-clear-focus'): ;; - When an editor loses focus, it triggers ':on-blur'. ;; - We don't clear ':focused-id' immediately because the blur might be ;; transient (e.g., clicking a toolbar button, or the browser blurring ;; an element during a DOM re-order like indent/outdent). ;; - 'maybe-clear-focus' waits 500ms. If after 500ms the application still ;; has focus but no editor is active, it finally clears ':focused-id'. ;; ;; 4. Caret Preservation: ;; - Commands that change the structure (indent, move, etc.) set the ;; ':preserve-caret-position?' flag in the command registry. ;; - 'dispatch/dispatch!' captures the current caret offset before running the ;; command and stores it in 'view-state' as ':preserved-caret-position'. ;; ;; 5. Focus Restoration ('manage-focus'): ;; - After a state change, Reagent re-renders the components. ;; - Components use a ':ref' callback that calls 'manage-focus'. ;; - 'manage-focus' checks if the current element's ID matches ':focused-id'. ;; - If it matches and the element isn't already focused, it calls '.focus()'. ;; - It then restores the caret position from ':preserved-caret-position' or ;; ':preserved-selection' and clears those flags. (defn- debug-info [id text annotations] [:div.debug-info {:style {:display "none"} :aria-hidden true :data-debug-info (with-out-str (pprint/pprint {:id id :text text :annotations annotations}))}]) (defn on-kb-action [on-click] (fn [e] (when (#{"Enter" " "} (.-key e)) (.preventDefault e) (on-click)))) (defn zoom-or-collapse [id] (let [current-zoom-id (:zoom-id @state/view-state)] (if (= id current-zoom-id) (dispatch/dispatch! [:outliner.command/collapse-children id]) (dispatch/dispatch! [:outliner.command/zoom-in id])))) (defn on-zoom-click [id & [on-click-extra]] (fn [e] (when-not (or (.-ctrlKey e) (.-metaKey e) (.-shiftKey e) (= ui-utils/middle-button (.-button e))) (.preventDefault e) (when on-click-extra (on-click-extra e)) (zoom-or-collapse id)))) (defn- capture-text [id] (let [node (get-in @state/doc-state [:nodes id]) sanitized (logic/sanitize-text node)] (state/update-node! id sanitized))) (defn node-text-editor [id node focused?] (js/console.log "node-text-editor " id (:text node) ", focused?=" focused?) (let [text (logic/resolve-text node) {:keys [annotations read-only?]} node] [:div.node-text {:class (cond-> (when (:complete? node) "complete") focused? (str " focused") read-only? (str " read-only")) :content-editable (not read-only?) :-webkit-user-select "text" :tab-index 0 :data-node-id id :data-node-context :node :suppressContentEditableWarning true :on-blur (fn [e] (js/console.log "blur " id (:text node) e) (when-not (focus/focus-locked?) (capture-text id)) (js/setTimeout #(focus/maybe-clear-focus id) 500)) :ref (fn [el] (when el (js/console.log "ref " id (:text node) el) (ui-utils/sync-dom-from-state! el text annotations focused? state/node-exists? (auth/is-mobile?)) (focus/manage-focus el id text)))}])) (defn node-hr-viewer [id] [:div.node-text.node-hr {:data-node-id id :data-node-context :node :role "button" :tab-index 0 :on-key-down (on-kb-action #(dispatch/dispatch! [:outliner.command/focus id 0])) :on-click (fn [e] (.stopPropagation e) (.preventDefault e) (.focus (.-currentTarget e)) (dispatch/dispatch! [:outliner.command/focus id 0]))} [:div.hr-line]]) (defn zoom-title-editor [id node focused?] (let [text (logic/resolve-text node) {:keys [annotations read-only?]} node] [:<> [debug-info id text annotations] [:div.zoom-title {:class (cond-> (when focused? "focused") read-only? (str " read-only")) :content-editable (not read-only?) :tab-index 0 :data-node-id id :data-node-context :title :suppressContentEditableWarning true :style {:outline "none"} :on-blur (fn [e] (when-not (focus/focus-locked?) (capture-text id)) (js/setTimeout #(focus/maybe-clear-focus id) 500)) :ref (fn [el] (when el (ui-utils/sync-dom-from-state! el text annotations focused? state/node-exists? (auth/is-mobile?)) (focus/manage-focus el id text)))}]])) (defn node-whitespace [] [:div.node-whitespace {:on-click #(.stopPropagation %) :on-pointer-down #(.stopPropagation %)}]) (defn expand-toggle [id has-children? expanded? node zoom-id] (when (and has-children? (not (and (:system-root? node) (not zoom-id)))) [:div.expand-handle {:data-node-id id :data-action "expand" :role "button" :tab-index 0 :on-key-down (on-kb-action #(dispatch/dispatch! [:outliner.command/toggle-expand id])) :aria-label (if expanded? "Collapse" "Expand")} (if expanded? [icons/icon-triangle-down] [icons/icon-triangle-right])])) (defn completion-label [node] (if (:complete? node) "Uncomplete" "Complete")) (defn copy-shortcut [id] (let [node (get-in @state/doc-state [:nodes id]) text (:text node) md-link (str "[" text "](node-id://" id ")")] (ui-utils/copy-to-clipboard md-link))) (defn export-node [id] (let [md (logic/node-to-markdown @state/doc-state id)] (ui-utils/copy-to-clipboard md))) (defn open-html-view [id] (let [url (js/URL. js/window.location.href) params (.-searchParams url)] (if id (.set params "zoom" id) (.delete params "zoom")) (.set params "format" "text/html") (js/window.open (.toString url) "_blank"))) (defn dispatch-style-command [id style] (let [el (js/document.querySelector (str "[data-node-id='" id "'].node-text")) sel (caret/get-selection-offsets el)] (dispatch/dispatch! [:outliner.command/apply-style id style sel]))) (defn context-menu-item [label on-click & [icon icon-class]] [:div.context-menu-item {:on-click (fn [e] (.stopPropagation e) (on-click) (dispatch/dispatch! [:outliner.command/close-context-menu])) :on-mouse-down #(.preventDefault %)} (when icon [:span.menu-icon {:class icon-class} icon]) label]) (defn toolbar-button [{:keys [label on-click class disabled aria-label]}] [:button.toolbar-button (cond-> {:on-click (fn [e] (.stopPropagation e) (when on-click (on-click))) :on-pointer-down #(.preventDefault %) :on-touch-start #(.preventDefault %) :on-mouse-down #(.preventDefault %) :tab-index -1 :disabled disabled :aria-label aria-label} class (assoc :class class)) label]) (declare node-component) (defn- completion-style [node] (when (:complete? node) {:color "#999999" :text-decoration "line-through"})) (defn plain-html-node [nodes node-id] (let [node (get nodes node-id)] (when node (let [children (:children node)] [:li {:style (completion-style node)} [ui-utils/render-annotated node state/node-exists? false] (when (seq children) [:ul (for [child-id children] ^{:key child-id} [plain-html-node nodes child-id])])])))) (defn plain-html-view [doc-state zoom-id] (let [nodes (:nodes doc-state)] [:div.plain-html-export {:style {:max-width "800px" :margin "40px auto" :padding "0 20px" :font-family "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif"}} (if-let [zoom-node (get nodes zoom-id)] [:<> [:p {:style (merge {:font-size "1.5rem" :font-weight "bold" :color "#808000" :margin-bottom "1rem"} (completion-style zoom-node))} [ui-utils/render-annotated zoom-node state/node-exists? false]] (when (seq (:children zoom-node)) [:ul (for [child-id (:children zoom-node)] ^{:key child-id} [plain-html-node nodes child-id])])] [:ul (for [root-id (:root doc-state)] ^{:key root-id} [plain-html-node nodes root-id])])])) (defn context-menu [view-state id node] (when-not (:read-only? node) (let [menu-open? (= id (:active-context-menu-id view-state))] [:div.kebab-icon {:data-node-id id :data-action "menu" :role "button" :tab-index 0 :on-key-down (on-kb-action #(dispatch/dispatch! [:outliner.command/toggle-context-menu id])) :aria-label "Node menu"} [icons/icon-kebab-horizontal] (when menu-open? [:div.context-menu [context-menu-item (completion-label node) #(dispatch/dispatch! [:outliner.command/complete id]) [icons/icon-check]] [context-menu-item "Bold" #(dispatch-style-command id :bold) [icons/icon-bold]] [context-menu-item "Italic" #(dispatch-style-command id :italic) [icons/icon-italic]] [context-menu-item "Highlight" #(dispatch-style-command id :highlight) [icons/icon-highlight]] [context-menu-item "Code" #(dispatch-style-command id :code) [icons/icon-code]] [context-menu-item "Shortcut" #(copy-shortcut id) [icons/icon-shortcut] "icon-shortcut"] [context-menu-item "View" #(open-html-view id) [icons/icon-browser]] [context-menu-item "Export" #(export-node id) [icons/icon-export]] [context-menu-item "Delete" #(dispatch/dispatch! [:outliner.command/delete-node id]) [icons/icon-trash]]])]))) (defn node-component [id] (let [node (get-in @state/doc-state [:nodes id]) view-state @state/view-state] (when node (let [children (:children node) expanded? (:expanded? node true) has-children? (seq children) dragged? (= id (:dragged-id view-state)) is-target? (= id (:drop-target view-state)) drop-pos (:drop-position view-state) focused? (= id (:focused-id view-state))] [:div.node {:data-node-id id :class (cond-> "" (:system-root? node) (str " system-root") dragged? (str " dragging") (and is-target? (= drop-pos :before)) (str " drop-before") (and is-target? (= drop-pos :after)) (str " drop-after") (and is-target? (= drop-pos :as-child)) (str " drop-as-child"))} [debug-info id (:text node) (:annotations node)] [:div.node-body [:div.node-controls [context-menu view-state id node] [expand-toggle id has-children? expanded? node (:zoom-id view-state)]] [:div.node-main [:div.node-content {:on-drag-over #(events/on-drag-over % id) :on-drop #(events/on-drop % id) :on-drag-leave #(events/on-drag-leave id)} (let [hr? (and (logic/horizontal-line? node) (not focused?))] [:<> (when-not hr? (let [is-mobile? (auth/is-mobile?)] [(if is-mobile? :div :a) (cond-> {:class "bullet" :draggable true :data-node-id id :on-click (on-zoom-click id) :role "button" :tab-index 0 :on-key-down (on-kb-action #(dispatch/dispatch! [:outliner.command/zoom-in id])) :aria-label "Zoom into node" :on-drag-start #(events/on-drag-start % id) :on-drag-end events/on-drag-end} (not is-mobile?) (assoc :href (str "?zoom=" id)))])) (if hr? [node-hr-viewer id] [node-text-editor id node focused?])]) [node-whitespace]] (when (and has-children? expanded?) [:div.children (for [child-id children] ^{:key child-id} [node-component child-id])])]]])))) (defn breadcrumb-separator [] [:span.breadcrumb-separator [icons/icon-chevron-right]]) (defn breadcrumb-item [node] (let [id (:id node)] [:<> [breadcrumb-separator] [:a.breadcrumb {:content-editable "false" :href (str "?zoom=" id) :data-node-id id :on-click (on-zoom-click id) :role "button" :tab-index 0 :on-key-down (on-kb-action #(dispatch/dispatch! [:outliner.command/zoom-in id]))} (logic/breadcrumb-text node)]])) (defn breadcrumb-last-item [node] (let [id (:id node)] [:<> [breadcrumb-separator] [:a.breadcrumb.current {:content-editable "false" :href (str "?zoom=" id) :data-node-id id :on-click (on-zoom-click id) :role "button" :tab-index 0 :on-key-down (on-kb-action #(zoom-or-collapse id))} (logic/breadcrumb-text node)]])) (defn breadcrumbs [doc-state view-state] (r/with-let [expanded? (r/atom false) last-zoom-id (r/atom (:zoom-id view-state))] (let [zoom-id (:zoom-id view-state)] (when (not= zoom-id @last-zoom-id) (reset! last-zoom-id zoom-id) (reset! expanded? false)) [:div.breadcrumbs [:a.breadcrumb.breadcrumb-home {:href "?" :on-click (on-zoom-click nil (fn [_] (reset! expanded? false))) :role "button" :tab-index 0 :on-key-down (on-kb-action (fn [] (reset! expanded? false) (zoom-or-collapse nil))) :aria-label "Home"} [icons/icon-home]] (when zoom-id (let [all-nodes (logic/get-path doc-state zoom-id) parent-nodes (butlast all-nodes) current-node (last all-nodes) count (count parent-nodes)] (if (or @expanded? (<= count 3)) [:<> (for [node parent-nodes] ^{:key (:id node)} [breadcrumb-item node]) [breadcrumb-last-item current-node]] (let [first-node (first parent-nodes) last-two (take-last 2 parent-nodes)] [:<> ^{:key (:id first-node)} [breadcrumb-item first-node] [:span.breadcrumb-ellipsis {:on-click #(reset! expanded? true) :role "button" :tab-index 0 :on-key-down (on-kb-action #(reset! expanded? true))} [breadcrumb-separator] "..."] (for [node last-two] ^{:key (:id node)} [breadcrumb-item node]) [breadcrumb-last-item current-node]]))))]))) (defn auth-button [sys-state] (let [user (:user sys-state)] [:div.auth-section (if user [:div.user-info [:img.user-photo {:src (fb/get-user-photo-url user) :alt "User avatar" :referrer-policy "no-referrer"}] [:button.logout-btn {:on-click auth/sign-out} "Sign Out"]] [:button.login-btn {:on-click auth/sign-in-with-google} "Sign in with Google"])])) (def toolbar-buttons [{:id :back :label [icons/icon-arrow-left] :aria-label "Back" :class "symbol-btn" :disabled-fn (fn [sys-state] (empty? (:location-history sys-state))) :on-click (fn [_] (dispatch/dispatch! [:outliner.command/go-back])) :node-specific? false} ;; :only-when-unfocused? true} {:id :outdent :label [icons/icon-outdent] :aria-label "Outdent" :class "symbol-btn" :on-click #(dispatch/dispatch! [:outliner.command/outdent %]) :node-specific? true} {:id :indent :label [icons/icon-indent] :aria-label "Indent" :class "symbol-btn" :on-click #(dispatch/dispatch! [:outliner.command/indent %]) :node-specific? true} {:id :undo :label [icons/icon-undo] :aria-label "Undo" :class "symbol-btn" :disabled-fn (fn [sys-state] (empty? (:undo-stack sys-state))) :on-click (fn [_] (dispatch/dispatch! [:outliner.command/undo])) :node-specific? false} {:id :redo :label [icons/icon-redo] :aria-label "Redo" :class "symbol-btn" :disabled-fn (fn [sys-state] (empty? (:redo-stack sys-state))) :on-click (fn [_] (dispatch/dispatch! [:outliner.command/redo])) :node-specific? false} {:id :complete :label [icons/icon-check] :aria-label "Complete" :class "symbol-btn" :on-click #(dispatch/dispatch! [:outliner.command/complete %]) :node-specific? true} {:id :bold :label [icons/icon-bold] :aria-label "Bold" :class "symbol-btn" :on-click #(dispatch-style-command % :bold) :node-specific? true} {:id :italic :label [icons/icon-italic] :aria-label "Italic" :class "symbol-btn" :on-click #(dispatch-style-command % :italic) :node-specific? true} {:id :highlight :label [icons/icon-highlight] :aria-label "Highlight" :class "symbol-btn" :on-click #(dispatch-style-command % :highlight) :node-specific? true} {:id :code :label [icons/icon-code] :aria-label "Code" :class "symbol-btn" :on-click #(dispatch-style-command % :code) :node-specific? true} {:id :export :label [icons/icon-export] :aria-label "Export" :class "symbol-btn" :on-click #(export-node %) :node-specific? true} {:id :view-html :label [icons/icon-browser] :aria-label "View as HTML" :class "symbol-btn" :on-click #(open-html-view %) :node-specific? true} {:id :shortcut :label [icons/icon-shortcut] :aria-label "Shortcut" :class "symbol-btn" :on-click #(copy-shortcut %) :node-specific? true} {:id :new-tab :label [icons/icon-external-link] :aria-label "Open in new tab" :class "symbol-btn" :on-click #(js/window.open (str "?zoom=" %) "_blank") :node-specific? true} {:id :delete :label [icons/icon-trash] :aria-label "Delete" :class "symbol-btn" :on-click #(dispatch/dispatch! [:outliner.command/delete-node %]) :node-specific? true} {:id :dark-mode :label-fn (fn [sys-state] (if (:dark-mode? sys-state) "☀️" "🌙")) :aria-label "Toggle dark mode" :class "symbol-btn dark-mode-mobile-only" :on-click (fn [_] (dispatch/dispatch! [:outliner.command/toggle-dark-mode])) :node-specific? false}]) (defn mobile-toolbar [view-state sys-state] (r/with-let [last-id (r/atom nil)] (let [doc-state @state/doc-state focused-id (:focused-id view-state) _ (when (get-in doc-state [:nodes focused-id]) (reset! last-id focused-id)) active-id (if (get-in doc-state [:nodes focused-id]) focused-id (when (get-in doc-state [:nodes @last-id]) @last-id)) vp (:viewport sys-state)] [:div.mobile-toolbar {:style (when vp {:top (str (+ (:top vp) (:height vp)) "px") :left (str (:left vp) "px") :width (str (:width vp) "px") :transform "translateY(-100%)" :bottom "auto"})} (for [btn toolbar-buttons] (let [applicable? (cond (:only-when-unfocused? btn) (nil? focused-id) (:node-specific? btn) focused-id :else true)] (when applicable? ^{:key (:id btn)} [toolbar-button {:label (if (:label-fn btn) ((:label-fn btn) sys-state) (:label btn)) :aria-label (:aria-label btn) :class (:class btn) :disabled (when (:disabled-fn btn) ((:disabled-fn btn) sys-state)) :on-click #((:on-click btn) active-id)}])))]))) (defn toast-notification [message sys-state] (let [vp (:viewport sys-state) is-mobile? (auth/is-mobile?) ;; Base bottom offset from the bottom of the visual viewport ;; 120px should clear the 44px toolbar plus some margin bottom-offset (if is-mobile? 120 32)] [:div.toast {:class (when message "visible") :style (if (and is-mobile? vp) {:bottom (str (+ (- (.-innerHeight js/window) (:top vp) (:height vp)) bottom-offset) "px") :left (str (+ (:left vp) (/ (:width vp) 2)) "px") :transform (if message "translateX(-50%) translateY(0)" "translateX(-50%) translateY(100%)") :opacity (if message 1 0)} {:bottom (str "calc(" bottom-offset "px + env(safe-area-inset-bottom))") :transform (if message "translateX(-50%) translateY(0)" "translateX(-50%) translateY(100%)")})} message]))