(ns outliner.control.commands (:require [outliner.model.core.tree :as tree] [outliner.model.core.markdown :as markdown] [outliner.model.history :as history] [outliner.model.core.text-ops :as text-ops] [outliner.model.core.annotations :as annotations] [outliner.model.core.logic :as logic])) ;; --- Command Handlers --- (defn update-node-text [doc view sys id annotated pos] {:doc (update-in doc [:nodes id] merge annotated) :view (cond-> view (number? pos) (assoc :preserved-caret-position pos) (map? pos) (assoc :preserved-selection pos))}) (defn insert-node-after [doc view sys id & [text new-id]] (let [[doc new-id] (markdown/create-node-in-doc doc new-id text) doc (tree/attach-node doc new-id id :after)] {:doc doc :view (-> view (assoc :focused-id new-id) (assoc :preserved-caret-position 0))})) (defn insert-node-before [doc view sys id & [text new-id]] (let [[doc new-id] (markdown/create-node-in-doc doc new-id text) doc (tree/attach-node doc new-id id :before)] {:doc doc :view (-> view (assoc :focused-id new-id) (assoc :preserved-caret-position 0))})) (defn insert-first-child [doc view sys parent-id & [text new-id]] (let [[doc new-id] (markdown/create-node-in-doc doc new-id text) doc (tree/attach-node doc new-id parent-id :first-child)] {:doc doc :view (-> view (assoc :focused-id new-id) (assoc :preserved-caret-position 0))})) (defn split-node [doc view sys id pos] (if (zero? pos) (insert-node-before doc view sys id "") (let [node (tree/get-node doc id) text (:text node) annotations (:annotations node []) [before-anns after-anns] (text-ops/split-annotations annotations pos) text-before (subs text 0 pos) text-after (subs text pos) has-children? (seq (:children node)) expanded? (:expanded? node true) doc (assoc-in doc [:nodes id] (markdown/sanitize-text (assoc node :text text-before :annotations before-anns)))] (if (and has-children? expanded?) (insert-first-child doc view sys id {:text text-after :annotations after-anns}) (insert-node-after doc view sys id {:text text-after :annotations after-anns}))))) (defn swap-node-up [doc view sys id] (let [siblings (tree/get-siblings doc id) index (tree/index-of siblings id)] {:doc (if (and index (> index 0)) (let [prev-id (nth siblings (dec index))] (-> doc (tree/detach-node id) (tree/attach-node id prev-id :before))) doc) :view (assoc view :focused-id id)})) (defn swap-node-down [doc view sys id] (let [siblings (tree/get-siblings doc id) index (tree/index-of siblings id)] {:doc (if (and index (< index (dec (count siblings)))) (let [next-id (nth siblings (inc index))] (-> doc (tree/detach-node id) (tree/attach-node id next-id :after))) doc) :view (assoc view :focused-id id)})) (defn indent-node [doc view sys id] (let [siblings (tree/get-siblings doc id) index (tree/index-of siblings id)] (if (and index (> index 0)) (let [prev-sibling-id (nth siblings (dec index)) new-doc (-> doc (tree/detach-node id) (tree/attach-node id prev-sibling-id :last-child))] {:doc new-doc :view (assoc view :focused-id id)}) {:doc doc :view view}))) (defn outdent-node [doc view sys id] (if-let [parent-id (tree/get-parent-id doc id)] (let [new-doc (-> doc (tree/detach-node id) (tree/attach-node id parent-id :after))] {:doc new-doc :view (assoc view :focused-id id)}) {:doc doc :view view})) (defn merge-or-delete-node ([doc view sys id] (merge-or-delete-node doc view sys id nil)) ([doc view sys id zoom-id] (let [prev-id (tree/find-prev-id doc id zoom-id)] #?(:cljs (js/console.log "merge-or-delete-node" "id" id "prev-id" prev-id)) (if prev-id (let [node (tree/get-node doc id) prev-node (tree/get-node doc prev-id) prev-text (:text prev-node) prev-anns (:annotations prev-node []) curr-text (:text node) curr-anns (:annotations node []) cursor-target (count prev-text) new-anns (annotations/merge-annotations (into prev-anns (map #(-> % (update :range-start + cursor-target) (update :range-end + cursor-target)) curr-anns))) children (:children node) doc (-> doc (tree/detach-node id) (update-in [:nodes prev-id] merge {:text (str prev-text curr-text) :annotations new-anns}) (update :nodes dissoc id)) doc (if (seq children) (-> (update-in doc [:nodes prev-id :children] into children) (as-> d (reduce #(assoc-in %1 [:nodes %2 :parent] prev-id) d children))) doc)] {:doc doc :view (-> view (assoc :focused-id prev-id) (assoc :preserved-caret-position cursor-target))}) {:doc doc :view view})))) (defn move-node [doc view sys source-id target-id position] (if (or (= source-id target-id) (tree/is-ancestor? doc source-id target-id)) {:doc doc} (let [pos (if (= position :as-child) :first-child position)] {:doc (-> doc (tree/detach-node source-id) (tree/attach-node source-id target-id pos))}))) (defn toggle-complete [doc view sys id] {:doc (update-in doc [:nodes id :complete?] not)}) (defn toggle-expand [doc view sys id] (let [node (tree/get-node doc id)] (if (and (:system-root? node) (not (:zoom-id view))) {:doc doc} (let [was-expanded? (get-in doc [:nodes id :expanded?] true) new-doc (update-in doc [:nodes id :expanded?] #(not (if (nil? %) true %))) is-expanded? (get-in new-doc [:nodes id :expanded?] true) expanded-now? (and (not was-expanded?) is-expanded?)] (cond-> {:doc new-doc} expanded-now? (assoc :on-expand id)))))) (defn expand-node [doc view sys id] {:doc (assoc-in doc [:nodes id :expanded?] true)}) (defn collapse-children [doc view sys id] (let [child-ids (if id (:children (tree/get-node doc id)) (:root doc)) new-doc (reduce (fn [d cid] (assoc-in d [:nodes cid :expanded?] false)) doc child-ids)] {:doc new-doc})) (defn collapse-node [doc view sys id] {:doc (assoc-in doc [:nodes id :expanded?] false)}) (defn toggle-context-menu [doc view sys id] (let [current (:active-context-menu-id view) new-id (if (= current id) nil id)] {:view (assoc view :active-context-menu-id new-id)})) (defn close-context-menu [doc view sys] {:view (assoc view :active-context-menu-id nil)}) (defn toggle-dark-mode [doc view sys] {:sys (update sys :dark-mode? not)}) (defn zoom-in [doc view sys id] {:view (assoc view :zoom-id id :focused-id nil) :on-expand id}) (defn zoom-out [doc view sys] (let [zoom-id (:zoom-id view) parent-id (when zoom-id (tree/get-parent-id doc zoom-id))] {:view (assoc view :zoom-id parent-id :focused-id nil)})) (defn zoom-to-root [doc view sys] {:view (assoc view :zoom-id nil :focused-id nil)}) (defn go-back [doc view sys] (if-let [prev-loc (peek (:location-history sys))] {:view (merge view prev-loc) :sys (update sys :location-history pop)} {})) (defn focus-node [doc view sys id & [pos]] #?(:cljs (js/console.log "focus-node" id pos)) {:view (-> view (assoc :focused-id id) (assoc :preserved-caret-position (when (number? pos) pos)) (assoc :preserved-selection (when (map? pos) pos)))}) (defn focus-prev [doc view sys id] (if-let [prev (tree/find-prev-id doc id (:zoom-id view))] {:view (-> view (assoc :focused-id prev) (assoc :preserved-caret-position (count (:text (tree/get-node doc prev)))))} {})) (defn focus-next [doc view sys id] (if-let [next (tree/find-next-id doc id (:zoom-id view))] {:view (-> view (assoc :focused-id next) (assoc :preserved-caret-position 0))} {})) (defn drag-start [doc view sys id] (let [node (tree/get-node doc id)] (if (:read-only? node) {:view view} {:view (assoc view :dragged-id id)}))) (defn drag-over [doc view sys target-id pos] {:view (-> view (assoc :drop-target target-id) (assoc :drop-position pos))}) (defn delete-node [doc view sys id] (let [prev-id (tree/find-prev-id doc id (:zoom-id view)) next-non-descendant (loop [curr-id id] (let [siblings (tree/get-siblings doc curr-id) index (tree/index-of siblings curr-id)] (if (and index (< index (dec (count siblings)))) (nth siblings (inc index)) (if-let [parent-id (tree/get-parent-id doc curr-id)] (if (= parent-id (:zoom-id view)) nil parent-id) nil)))) focus-id (or prev-id next-non-descendant) new-doc (-> doc (tree/detach-node id) (tree/delete-node-recursive id))] {:doc new-doc :view (assoc view :focused-id focus-id)})) (defn drop-node [doc view sys target-id] (let [source-id (:dragged-id view) pos (:drop-position view)] (if (and source-id target-id pos) (move-node doc view sys source-id target-id pos) {}))) (defn apply-style [doc view sys id style selection] (let [node (tree/get-node doc id) text (:text node) annotations (:annotations node []) {:keys [start end]} selection [s e] (if (or (nil? start) (nil? end) (= start end)) [0 (count text)] [start end]) new-annotations (-> (text-ops/toggle-style annotations s e style) text-ops/merge-style-annotations)] {:doc (assoc-in doc [:nodes id :annotations] new-annotations) :view (assoc view :preserved-selection {:start s :end e})})) (defn drag-end [doc view sys] {:view (dissoc view :dragged-id :drop-target :drop-position)}) (defn drag-leave [doc view sys] {:view (dissoc view :drop-target :drop-position)}) (def command-registry {:outliner.command/undo {:fn history/undo :structural? true} :outliner.command/redo {:fn history/redo :structural? true} :outliner.command/zoom-in {:fn zoom-in :clear-edit? true :structural? true} :outliner.command/zoom-out {:fn zoom-out :clear-edit? true :structural? true} :outliner.command/zoom-to-root {:fn zoom-to-root :clear-edit? true :structural? true} :outliner.command/go-back {:fn go-back :structural? true} :outliner.command/focus {:fn focus-node :clear-edit? true :structural? true} :outliner.command/focus-prev {:fn focus-prev} :outliner.command/focus-next {:fn focus-next} :outliner.command/update-node-text {:fn update-node-text :is-edit? true} :outliner.command/insert-first-child {:fn insert-first-child :history? true} :outliner.command/split-node {:fn split-node :history? true} :outliner.command/indent {:fn indent-node :history? true :preserve-caret-position? true} :outliner.command/outdent {:fn outdent-node :history? true :preserve-caret-position? true} :outliner.command/merge-or-delete {:fn merge-or-delete-node :history? true} :outliner.command/move-up {:fn swap-node-up :history? true :preserve-caret-position? true} :outliner.command/move-down {:fn swap-node-down :history? true :preserve-caret-position? true} :outliner.command/drag-start {:fn drag-start} :outliner.command/drag-over {:fn drag-over} :outliner.command/drop {:fn drop-node :history? true} :outliner.command/drag-end {:fn drag-end} :outliner.command/drag-leave {:fn drag-leave} :outliner.command/complete {:fn toggle-complete :history? true} :outliner.command/toggle-expand {:fn toggle-expand :history? true} :outliner.command/expand-node {:fn expand-node :history? true} :outliner.command/collapse-node {:fn collapse-node :history? true} :outliner.command/collapse-children {:fn collapse-children :history? true} :outliner.command/toggle-context-menu {:fn toggle-context-menu} :outliner.command/close-context-menu {:fn close-context-menu} :outliner.command/toggle-dark-mode {:fn toggle-dark-mode} :outliner.command/delete-node {:fn delete-node :history? true} :outliner.command/apply-style {:fn apply-style :history? true} :outliner.command/paste-markdown {:fn markdown/insert-markdown-nodes :history? true}}) (defn- prepare-state-for-command [doc view sys config args] (let [{:keys [is-edit? history? clear-edit?]} config] (cond is-edit? (let [id (first args)] (if (= (:last-edit-id sys) id) [doc view sys] (let [h (history/record-history doc view sys)] [doc view (history/mark-edit h id)]))) history? (let [h (history/record-history doc view sys)] [doc view (history/clear-edit-marker h)]) clear-edit? [doc view (history/clear-edit-marker sys)] :else [doc view sys]))) (def modification-commands #{:outliner.command/update-node-text :outliner.command/split-node :outliner.command/indent :outliner.command/outdent :outliner.command/merge-or-delete :outliner.command/move-up :outliner.command/move-down :outliner.command/complete :outliner.command/delete-node :outliner.command/apply-style :outliner.command/paste-markdown :outliner.command/drop}) (defn- read-only? [doc id] (boolean (:read-only? (tree/get-node doc id)))) (defn process-command [doc view sys [cmd & args]] #?(:cljs (js/console.log "process-command " cmd args)) (if-let [config (get command-registry cmd)] (let [id (first args) is-modifying? (contains? modification-commands cmd)] (if (and is-modifying? id (read-only? doc id)) {:doc doc :view view :sys sys} (let [[d v s] (prepare-state-for-command doc view sys config args) updates (apply (:fn config) d v s args)] (merge updates {:doc (get updates :doc d) :view (get updates :view v) :sys (get updates :sys s)})))) {:doc doc :view view :sys sys}))