(ns frontend.components.page (:require ["/frontend/utils" :as utils] [clojure.string :as string] [dommy.core :as dom] [frontend.components.block :as component-block] [frontend.components.class :as class-component] [frontend.components.content :as content] [frontend.components.db-based.page :as db-page] [frontend.components.editor :as editor] [frontend.components.file-based.hierarchy :as hierarchy] [frontend.components.objects :as objects] [frontend.components.plugins :as plugins] [frontend.components.query :as query] [frontend.components.reference :as reference] [frontend.components.scheduled-deadlines :as scheduled] [frontend.components.svg :as svg] [frontend.config :as config] [frontend.context.i18n :refer [t]] [frontend.date :as date] [frontend.db :as db] [frontend.db-mixins :as db-mixins] [frontend.db.async :as db-async] [frontend.db.model :as model] [frontend.extensions.graph :as graph] [frontend.extensions.graph.pixi :as pixi] [frontend.extensions.pdf.utils :as pdf-utils] [frontend.format.mldoc :as mldoc] [frontend.handler.common :as common-handler] [frontend.handler.config :as config-handler] [frontend.handler.dnd :as dnd] [frontend.handler.editor :as editor-handler] [frontend.handler.graph :as graph-handler] [frontend.handler.notification :as notification] [frontend.handler.page :as page-handler] [frontend.handler.route :as route-handler] [frontend.mixins :as mixins] [frontend.mobile.util :as mobile-util] [frontend.rum :as frontend-rum] [frontend.state :as state] [frontend.ui :as ui] [frontend.util :as util] [frontend.util.text :as text-util] [goog.object :as gobj] [logseq.common.util :as common-util] [logseq.common.util.page-ref :as page-ref] [logseq.db :as ldb] [logseq.graph-parser.mldoc :as gp-mldoc] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] [promesa.core :as p] [reitit.frontend.easy :as rfe] [rum.core :as rum])) (defn- get-page-name [state] (let [route-match (first (:rum/args state))] (get-in route-match [:parameters :path :name]))) ;; Named block links only works on web (and publishing) (if util/web-platform? (defn- get-block-uuid-by-block-route-name "Return string block uuid for matching :name and :block-route-name params or nil if not found" [state] ;; Only query if block name is in the route (when-let [route-name (get-in (first (:rum/args state)) [:parameters :path :block-route-name])] (->> (model/get-block-by-page-name-and-block-route-name (state/get-current-repo) (get-page-name state) route-name) :block/uuid str))) (def get-block-uuid-by-block-route-name (constantly nil))) (defn- open-root-block! [state] (let [[_ block _ sidebar? preview?] (:rum/args state)] (when (and (or preview? (not (contains? #{:home :all-journals} (state/get-current-route)))) (not sidebar?)) (when (and (string/blank? (:block/title block)) (not preview?)) (editor-handler/edit-block! block :max))))) (rum/defc page-blocks-inner < {:did-mount (fn [state] (open-root-block! state) state)} [page-e blocks config sidebar? _preview? _block-uuid] (when page-e (let [hiccup (component-block/->hiccup blocks config {})] [:div.page-blocks-inner {:style {:min-height 29}} (rum/with-key (content/content (str (:block/uuid page-e)) {:hiccup hiccup :sidebar? sidebar?}) (str (:block/uuid page-e) "-hiccup"))]))) (declare page-cp) (if config/publishing? (rum/defc dummy-block [_page] [:div]) (rum/defc dummy-block [page] (let [[hover set-hover!] (rum/use-state false) click-handler-fn (fn [] (p/let [result (editor-handler/insert-first-page-block-if-not-exists! (:block/uuid page)) result (:tx-data result) first-child-id (first (map :block/uuid result)) first-child (when first-child-id (db/entity [:block/uuid first-child-id]))] (when first-child (editor-handler/edit-block! first-child :max {:container-id :unknown-container})))) drop-handler-fn (fn [^js event] (util/stop event) (p/let [block-uuids (state/get-selection-block-ids) lookup-refs (map (fn [id] [:block/uuid id]) block-uuids) selected (db/pull-many (state/get-current-repo) '[*] lookup-refs) blocks (if (seq selected) selected [@component-block/*dragging-block]) _ (editor-handler/insert-first-page-block-if-not-exists! (:block/uuid page))] (js/setTimeout #(let [target-block page] (dnd/move-blocks event blocks target-block nil :sibling)) 0))) *dummy-block-uuid (rum/use-ref (random-uuid)) *el-ref (rum/use-ref nil) _ (frontend-rum/use-atom (@state/state :selection/blocks)) selection-ids (state/get-selection-block-ids) selected? (contains? (set selection-ids) (rum/deref *dummy-block-uuid)) idstr (str (rum/deref *dummy-block-uuid)) focus! (fn [] (js/setTimeout #(some-> (rum/deref *el-ref) (.focus)) 16))] ;; mounted ;(hooks/use-effect! #(focus!) []) (hooks/use-effect! #(if selected? (focus!) (some-> (rum/deref *el-ref) (.blur))) [selected?]) (shui/trigger-as :div.ls-dummy-block.ls-block {:style {:width "100%" ;; The same as .dnd-separator :border-top (if hover "3px solid #ccc" nil) :margin-left 20} :ref *el-ref :tabIndex 0 :on-click click-handler-fn :id idstr :blockid idstr :class (when selected? "selected")} [:div.flex.items-center [:div.flex.items-center.mx-1 {:style {:height 24}} [:span.bullet-container.cursor [:span.bullet]]] [:div.flex.flex-1.cursor-text {:on-drag-enter #(set-hover! true) :on-drag-over #(util/stop %) :on-drop drop-handler-fn :on-drag-leave #(set-hover! false)} [:span.opacity-70.text "Click here to edit..."]]])))) (rum/defc add-button [args container-id] (let [*bullet-ref (rum/use-ref nil)] [:div.flex-1.flex-col.rounded-sm.add-button-link-wrap {:on-click (fn [e] (util/stop e) (state/set-state! :editor/container-id container-id) (editor-handler/api-insert-new-block! "" args)) :on-mouse-over #(dom/add-class! (rum/deref *bullet-ref) "opacity-50") :on-mouse-leave #(dom/remove-class! (rum/deref *bullet-ref) "opacity-50") :on-key-down (fn [e] (util/stop e) (when (= "Enter" (util/ekey e)) (state/set-state! :editor/container-id container-id) (editor-handler/api-insert-new-block! "" args))) :tab-index 0} [:div.flex.flex-row [:div.flex.items-center {:style {:height 28 :margin-left 22}} [:span.bullet-container.cursor.opacity-0.transition-opacity.ease-in.duration-100 {:ref *bullet-ref} [:span.bullet]]]]])) (rum/defcs page-blocks-cp < rum/reactive db-mixins/query {:will-mount (fn [state] (let [page-e (first (:rum/args state)) page-name (:block/name page-e)] (when (and page-name (db/journal-page? page-name) (>= (date/journal-title->int page-name) (date/journal-title->int (date/today)))) (state/pub-event! [:journal/insert-template page-name]))) state)} [state block* {:keys [sidebar? whiteboard?] :as config}] (when-let [id (:db/id block*)] (let [block (db/sub-block id) block-id (:block/uuid block) block? (not (db/page? block)) children (:block/_parent block) children (cond (ldb/class? block) (remove (fn [b] (contains? (set (map :db/id (:block/tags b))) (:db/id block))) children) (ldb/property? block) (remove (fn [b] (some? (get b (:db/ident block)))) children) :else children)] (cond (and (not block?) (empty? children) block) (dummy-block block) :else (let [document-mode? (state/sub :document/mode?) hiccup-config (merge {:id (str (:block/uuid block)) :db/id (:db/id block) :block? block? :editor-box editor/box :document/mode? document-mode?} config) config (common-handler/config-with-document-mode hiccup-config) blocks (if block? [block] (db/sort-by-order children block))] (let [add-button? (not (or config/publishing? (let [last-child-id (model/get-block-deep-last-open-child-id (db/get-db) (:db/id (last blocks))) block' (if last-child-id (db/entity last-child-id) (last blocks)) link (:block/link block')] (string/blank? (:block/title (or link block'))))))] [:div.relative {:class (when add-button? "show-add-button")} (page-blocks-inner block blocks config sidebar? whiteboard? block-id) (let [args {:block-uuid block-id}] (add-button args (:container-id config)))])))))) (rum/defc today-queries < rum/reactive [repo today? sidebar?] (when (and today? (not sidebar?)) (let [queries (get-in (state/sub-config repo) [:default-queries :journals])] (when (seq queries) [:div#today-queries (for [query queries] (let [query' (if (config/db-based-graph?) (assoc query :collapsed? true) query)] (rum/with-key (ui/catch-error (ui/component-error "Failed default query:" {:content (pr-str query')}) (query/custom-query (component-block/wrap-query-components {:attr {:class "mt-10"} :editor-box editor/box :page page-cp}) query')) (str repo "-custom-query-" (:query query')))))])))) (rum/defc tagged-pages [repo tag tag-title] (let [[pages set-pages!] (rum/use-state nil)] (hooks/use-effect! (fn [] (p/let [result (db-async/custom-format title) title)) old-name title] [:div.ls-page-title.flex.flex-1.flex-row.flex-wrap.w-full.relative.items-center.gap-2 [:h1.page-title.flex-1.cursor-pointer.gap-1 {:class (when-not whiteboard-page? "title") :on-pointer-down (fn [e] (when (util/right-click? e) (state/set-state! :page-title/context {:page (:block/title page) :page-entity page}))) :on-click (fn [e] (when-not (= (.-nodeName (.-target e)) "INPUT") (.preventDefault e) (if (gobj/get e "shiftKey") (state/sidebar-add-block! repo (:db/id page) :page) (when (and (not hls-page?) (not journal?) (not config/publishing?) (not (ldb/built-in? page))) (reset! *input-value (if untitled? "" old-name)) (reset! *edit? true)))))} (if @*edit? (page-title-editor page {:*title-value *title-value :*edit? *edit? :*input-value *input-value :page-name (:block/title page) :old-name old-name :untitled? untitled? :whiteboard-page? whiteboard-page? :preview? preview?}) [:span.title.block {:on-click (fn [] (when (and (not preview?) (contains? #{:home :all-journals} (get-in (state/get-route-match) [:data :name]))) (route-handler/redirect-to-page! (:block/uuid page)))) :data-value @*input-value :data-ref (:block/title page) :style {:opacity (when @*edit? 0)}} (let [nested? (and (string/includes? title page-ref/left-brackets) (string/includes? title page-ref/right-brackets))] (cond untitled? [:span.opacity-50 (t :untitled)] nested? (component-block/map-inline {} (gp-mldoc/inline->edn title (mldoc/get-default-config (get page :block/format :markdown)))) :else title))])]]))))) (rum/defc db-page-title-actions [page] [:div.absolute.-top-4.left-0.opacity-0.db-page-title-actions [:div.flex.flex-row.items-center.gap-2 (when-not (:logseq.property/icon (db/entity (:db/id page))) (shui/button {:variant :outline :size :sm :class "px-2 py-0 h-6 text-xs text-muted-foreground" :on-click (fn [e] (state/pub-event! [:editor/new-property {:property-key "Icon" :block page :target (.-target e)}]))} "Add icon")) (shui/button {:variant :outline :size :sm :class "px-2 py-0 h-6 text-xs text-muted-foreground" :on-click (fn [e] (state/pub-event! [:editor/new-property {:block page :target (.-target e)}]))} "Set property")]]) (rum/defc db-page-title [page whiteboard-page? sidebar? container-id] (let [with-actions? (not config/publishing?)] [:div.ls-page-title.flex.flex-1.w-full.content.items-start.title {:class (when-not whiteboard-page? "title") :on-pointer-down (fn [e] (when (util/right-click? e) (state/set-state! :page-title/context {:page (:block/title page) :page-entity page}))) :on-click (fn [e] (when-not (some-> e (.-target) (.closest ".ls-properties-area")) (when-not (= (.-nodeName (.-target e)) "INPUT") (.preventDefault e) (when (gobj/get e "shiftKey") (state/sidebar-add-block! (state/get-current-repo) (:db/id page) :page)))))} [:div.w-full.relative (component-block/block-container {:page-title? true :page-title-actions-cp (when (and with-actions? (not= (:db/id (state/get-edit-block)) (:db/id page))) db-page-title-actions) :hide-title? sidebar? :sidebar? sidebar? :hide-children? true :container-id container-id :show-tag-and-property-classes? true :from-journals? (contains? #{:home :all-journals} (get-in (state/get-route-match) [:data :name]))} page)]])) (defn- page-mouse-over [e *control-show? *all-collapsed?] (util/stop e) (reset! *control-show? true) (p/let [blocks (editor-handler/> blocks (filter (fn [b] (editor-handler/collapsable? (:block/uuid b)))) (empty?))] (reset! *all-collapsed? all-collapsed?))) (defn- page-mouse-leave [e *control-show?] (util/stop e) (reset! *control-show? false)) (rum/defcs page-blocks-collapse-control < [state title *control-show? *all-collapsed?] [:a.page-blocks-collapse-control {:id (str "control-" title) :on-click (fn [event] (util/stop event) (if @*all-collapsed? (editor-handler/expand-all!) (editor-handler/collapse-all!)) (swap! *all-collapsed? not))} [:span.mt-6 {:class (if @*control-show? "control-show cursor-pointer" "control-hide")} (ui/rotating-arrow @*all-collapsed?)]]) (defn- get-path-page-name [state page-name] (or page-name (get-block-uuid-by-block-route-name state) ;; is page name or uuid (get-page-name state) (state/get-current-page))) (defn get-page-entity [page-name] (cond (uuid? page-name) (db/entity [:block/uuid page-name]) (common-util/uuid-string? page-name) (db/entity [:block/uuid (uuid page-name)]) :else (db/get-page page-name))) (defn- get-sanity-page-name [state page-name] (when-let [path-page-name (get-path-page-name state page-name)] (util/page-name-sanity-lc path-page-name))) (rum/defc lsp-pagebar-slot < rum/static [] (when (not config/publishing?) (when config/lsp-enabled? [:div.flex.flex-row (plugins/hook-ui-slot :page-head-actions-slotted nil) (plugins/hook-ui-items :pagebar)]))) (rum/defc tabs < rum/static {:did-mount (fn [state] (let [*tabs-rendered? (:*tabs-rendered? (last (:rum/args state)))] (reset! *tabs-rendered? true) state))} [page opts] (let [class? (ldb/class? page) property? (ldb/property? page) both? (and class? property?) default-tab (cond both? "tag" class? "tag" :else "property")] [:div.page-tabs (shui/tabs {:defaultValue default-tab :class (str "w-full")} (when (or both? property?) [:div.flex.flex-row.gap-1.items-center (shui/tabs-list {:class "h-8"} (when class? (shui/tabs-trigger {:value "tag" :class "py-1 text-xs"} "Tagged nodes")) (when property? (shui/tabs-trigger {:value "property" :class "py-1 text-xs"} "Nodes with property")) (when property? (db-page/configure-property page)))]) (when class? (shui/tabs-content {:value "tag"} (objects/class-objects page opts))) (when property? (shui/tabs-content {:value "property"} (objects/property-related-objects page (:current-page? opts)))))])) (rum/defc sidebar-page-properties [config page] (let [[collapsed? set-collapsed!] (rum/use-state true)] [:div.ls-sidebar-page-properties.flex.flex-col.gap-2.mt-2 [:div (shui/button {:variant :ghost :size :sm :class "px-1 text-muted-foreground" :on-click #(set-collapsed! (not collapsed?))} [:span.text-xs (str (if collapsed? "Open" "Hide")) " properties"])] (when-not collapsed? [:<> (component-block/db-properties-cp config page {:sidebar-properties? true}) [:hr.my-4]])])) ;; A page is just a logical block (rum/defcs ^:large-vars/cleanup-todo page-inner < rum/reactive db-mixins/query mixins/container-id (rum/local false ::all-collapsed?) (rum/local false ::control-show?) (rum/local nil ::current-page) (rum/local false ::tabs-rendered?) [state {:keys [repo page preview? sidebar? linked-refs? unlinked-refs? config] :as option}] (let [current-repo (state/sub :git/current-repo) *tabs-rendered? (::tabs-rendered? state) repo (or repo current-repo) block-id (:block/uuid page) block? (some? (:block/page page)) class-page? (ldb/class? page) property-page? (ldb/property? page) title (:block/title page) journal? (db/journal-page? title) db-based? (config/db-based-graph? repo) fmt-journal? (boolean (date/journal-title->int title)) whiteboard? (:whiteboard? option) ;; in a whiteboard portal shape? whiteboard-page? (model/whiteboard-page? page) ;; is this page a whiteboard? today? (and journal? (= title (date/journal-name))) *control-show? (::control-show? state) *all-collapsed? (::all-collapsed? state) block-or-whiteboard? (or block? whiteboard?) home? (= :home (state/get-current-route)) show-tabs? (and db-based? (or class-page? (ldb/property? page))) tabs-rendered? (rum/react *tabs-rendered?)] (if page (when (or title block-or-whiteboard?) [:div.flex-1.page.relative.cp__page-inner-wrap (merge (if (seq (:block/tags page)) (let [page-names (map :block/title (:block/tags page))] (when (seq page-names) {:data-page-tags (text-util/build-data-value page-names)})) {}) {:key title :class (util/classnames [{:is-journals (or journal? fmt-journal?) :is-node-page (or class-page? property-page?)}])}) (if (and whiteboard-page? (not sidebar?)) [:div ((state/get-component :whiteboard/tldraw-preview) (:block/uuid page))] ;; FIXME: this is not reactive [:div.relative.grid.gap-8.page-inner (when-not (or block? sidebar?) [:div.flex.flex-row.space-between (when (and (or (mobile-util/native-platform?) (util/mobile?)) (not db-based?)) [:div.flex.flex-row.pr-2 {:style {:margin-left -15} :on-mouse-over (fn [e] (page-mouse-over e *control-show? *all-collapsed?)) :on-mouse-leave (fn [e] (page-mouse-leave e *control-show?))} (page-blocks-collapse-control title *control-show? *all-collapsed?)]) (when (and (not whiteboard?) (ldb/page? page)) (if db-based? (db-page-title page whiteboard-page? sidebar? (:container-id state)) (page-title-cp page {:journal? journal? :fmt-journal? fmt-journal? :preview? preview?}))) (lsp-pagebar-slot)]) (when (and db-based? sidebar? (ldb/page? page)) [:div.-mb-8 (sidebar-page-properties config page)]) (when (and block? (not sidebar?) (not whiteboard?)) (let [config (merge config {:id "block-parent" :block? true})] [:div.mb-4 (component-block/breadcrumb config repo block-id {:level-limit 3})])) (when show-tabs? (tabs page {:current-page? option :sidebar? sidebar? :*tabs-rendered? *tabs-rendered?})) (when (or (not show-tabs?) tabs-rendered?) [:div.ls-page-blocks {:style {:margin-left (if whiteboard? 0 -20)}} (page-blocks-cp page (merge option {:sidebar? sidebar? :container-id (:container-id state) :whiteboard? whiteboard?}))])]) (when (and (not preview?) (or (not show-tabs?) tabs-rendered?)) [:div.ml-1.flex.flex-col.gap-4 (when today? (today-queries repo today? sidebar?)) (when today? (scheduled/scheduled-and-deadlines title)) (when (and (not block?) (not db-based?)) (tagged-pages repo page title)) (when (and (ldb/page? page) (:logseq.property/_parent page)) (class-component/class-children page)) ;; referenced blocks (when-not (or whiteboard? linked-refs? (and block? (not db-based?))) [:div {:key "page-references"} (rum/with-key (reference/references page {:sidebar? sidebar?}) (str title "-refs"))]) (when-not block-or-whiteboard? (when (and (not journal?) (not db-based?)) (hierarchy/structures (:block/title page)))) (when-not (or whiteboard? unlinked-refs? sidebar? home? (or class-page? property-page?) (and block? (not db-based?))) [:div {:key "page-unlinked-references"} (reference/unlinked-references page {:sidebar? sidebar?})])])]) [:div.opacity-75 "Page not found"]))) (rum/defcs page-aux < rum/reactive {:init (fn [state] (let [page* (first (:rum/args state)) page-name (:page-name page*) page-id-uuid-or-name (or (:db/id page*) (:block/uuid page*) (get-sanity-page-name state page-name)) option (last (:rum/args state)) preview-or-sidebar? (or (:preview? option) (:sidebar? option)) page-uuid? (when page-name (util/uuid-string? page-name)) *loading? (atom true) page (db/get-page page-id-uuid-or-name) *page (atom page)] (when (:block.temp/fully-loaded? page) (reset! *loading? false)) (p/let [page-block (db-async/ c1 1) "s" "") ;; c2 (count (:links graph)) ;; s2 (if (> c2 1) "s" "") ] ;; (util/format "%d page%s, %d link%s" c1 s1 c2 s2) (util/format "%d page%s" c1 s1))] [:div.p-6 ;; [:div.flex.items-center.justify-between.mb-2 ;; [:span "Layout"] ;; (ui/select ;; (mapv ;; (fn [item] ;; (if (= (:label item) layout) ;; (assoc item :selected "selected") ;; item)) ;; [{:label "gForce"} ;; {:label "dagre"}]) ;; (fn [_e value] ;; (set-setting! :layout value)) ;; {:class "graph-layout"})] [:div.flex.items-center.justify-between.mb-2 [:span (t :settings-page/enable-journals)] ;; FIXME: why it's not aligned well? [:div.mt-1 (ui/toggle journal? (fn [] (let [value (not journal?)] (reset! *journal? value) (set-setting! :journal? value))) true)]] [:div.flex.items-center.justify-between.mb-2 [:span "Orphan pages"] [:div.mt-1 (ui/toggle orphan-pages? (fn [] (let [value (not orphan-pages?)] (reset! *orphan-pages? value) (set-setting! :orphan-pages? value))) true)]] [:div.flex.items-center.justify-between.mb-2 [:span "Built-in pages"] [:div.mt-1 (ui/toggle builtin-pages? (fn [] (let [value (not builtin-pages?)] (reset! *builtin-pages? value) (set-setting! :builtin-pages? value))) true)]] [:div.flex.items-center.justify-between.mb-2 [:span "Excluded pages"] [:div.mt-1 (ui/toggle excluded-pages? (fn [] (let [value (not excluded-pages?)] (reset! *excluded-pages? value) (set-setting! :excluded-pages? value))) true)]] (when (config/db-based-graph? (state/get-current-repo)) [:div.flex.flex-col.mb-2 [:p "Created before"] (when created-at-filter [:div (.toDateString (js/Date. (+ created-at-filter (get-in graph [:all-pages :created-at-min]))))]) (ui/tooltip ;; Slider keeps track off the range from min created-at to max created-at ;; because there were bugs with setting min and max directly (ui/slider created-at-filter {:min 0 :max (- (get-in graph [:all-pages :created-at-max]) (get-in graph [:all-pages :created-at-min])) :on-change #(do (reset! *created-at-filter (int %)) (set-setting! :created-at-filter (int %)))}) [:div.px-1 (str (js/Date. (+ created-at-filter (get-in graph [:all-pages :created-at-min]))))])]) (when (seq focus-nodes) [:div.flex.flex-col.mb-2 [:p {:title "N hops from selected nodes"} "N hops from selected nodes"] (ui/tooltip (ui/slider (or n-hops 10) {:min 1 :max 10 :on-change #(reset! *n-hops (int %))}) [:div n-hops])]) [:a.opacity-70.opacity-100 {:on-click (fn [] (swap! *graph-reset? not) (reset! *focus-nodes []) (reset! *n-hops nil) (reset! *created-at-filter nil) (set-setting! :created-at-filter nil) (state/clear-search-filters!))} "Reset Graph"]]])) {}) (graph-filter-section [:span.font-medium "Search"] (fn [open?] (filter-expand-area open? [:div.p-6 (if (seq search-graph-filters) [:div (for [q search-graph-filters] [:div.flex.flex-row.justify-between.items-center.mb-2 [:span.font-medium q] [:a.search-filter-close.opacity-70.opacity-100 {:on-click #(state/remove-search-filter! q)} svg/close]]) [:a.opacity-70.opacity-100 {:on-click state/clear-search-filters!} "Clear All"]] [:a.opacity-70.opacity-100 {:on-click #(route-handler/go-to-search! :graph)} "Click to search"])])) {:search-filters search-graph-filters}) (graph-filter-section [:span.font-medium "Forces"] (fn [open?] (filter-expand-area open? [:div [:p.text-sm.opacity-70.px-4 (let [c2 (count (:links graph)) s2 (if (> c2 1) "s" "")] (util/format "%d link%s" c2 s2))] [:div.p-6 (simulation-switch) [:div.flex.flex-col.mb-2 [:p {:title "Link Distance"} "Link Distance"] (ui/tooltip (ui/slider (/ link-dist 10) {:min 1 ;; 10 :max 18 ;; 180 :on-change #(let [value (int %)] (reset! *link-dist (* value 10)) (set-forcesetting! :link-dist (* value 10)))}) [:div link-dist])] [:div.flex.flex-col.mb-2 [:p {:title "Charge Strength"} "Charge Strength"] (ui/tooltip (ui/slider (/ charge-strength 100) {:min -10 ;;-1000 :max 10 ;;1000 :on-change #(let [value (int %)] (reset! *charge-strength (* value 100)) (set-forcesetting! :charge-strength (* value 100)))}) [:div charge-strength])] [:div.flex.flex-col.mb-2 [:p {:title "Charge Range"} "Charge Range"] (ui/tooltip (ui/slider (/ charge-range 100) {:min 5 ;;500 :max 40 ;;4000 :on-change #(let [value (int %)] (reset! *charge-range (* value 100)) (set-forcesetting! :charge-range (* value 100)))}) [:div charge-range])] [:a {:on-click (fn [] (swap! *graph-forcereset? not) (reset! *link-dist 70) (reset! *charge-strength -600) (reset! *charge-range 600))} "Reset Forces"]]])) {}) (graph-filter-section [:span.font-medium "Export"] (fn [open?] (filter-expand-area open? (when-let [canvas (js/document.querySelector "#global-graph canvas")] [:div.p-6 ;; We'll get an empty image if we don't wrap this in a requestAnimationFrame [:div [:a {:on-click #(.requestAnimationFrame js/window (fn [] (utils/canvasToImage canvas "graph" "png")))} "as PNG"]]]))) {:search-filters search-graph-filters})]]]])) (defonce last-node-position (atom nil)) (defn- graph-register-handlers [graph focus-nodes n-hops dark?] (.on graph "nodeClick" (fn [event node] (let [x (.-x event) y (.-y event) drag? (not (let [[last-node last-x last-y] @last-node-position threshold 5] (and (= node last-node) (<= (abs (- x last-x)) threshold) (<= (abs (- y last-y)) threshold))))] (graph/on-click-handler graph node event focus-nodes n-hops drag? dark?)))) (.on graph "nodeMousedown" (fn [event node] (reset! last-node-position [node (.-x event) (.-y event)])))) (rum/defc global-graph-inner < rum/reactive [graph settings forcesettings theme] (let [[width height] (rum/react layout) dark? (= theme "dark") n-hops (rum/react *n-hops) link-dist (rum/react *link-dist) charge-strength (rum/react *charge-strength) charge-range (rum/react *charge-range) reset? (rum/react *graph-reset?) forcereset? (rum/react *graph-forcereset?) focus-nodes (when n-hops (rum/react *focus-nodes)) graph (if (and (integer? n-hops) (seq focus-nodes) (not (:orphan-pages? settings))) (graph-handler/n-hops graph focus-nodes n-hops) graph)] [:div.relative#global-graph (graph/graph-2d {:nodes (:nodes graph) :links (:links graph) :width (- width 24) :height (- height 48) :dark? dark? :link-dist link-dist :charge-strength charge-strength :charge-range charge-range :register-handlers-fn (fn [graph] (graph-register-handlers graph *focus-nodes *n-hops dark?)) :reset? reset? :forcereset? forcereset?}) (graph-filters graph settings forcesettings n-hops)])) (defn- filter-graph-nodes [nodes filters] (if (seq filters) (let [filter-patterns (map #(re-pattern (str "(?i)" (util/regex-escape %))) filters)] (filter (fn [node] (some #(re-find % (:label node)) filter-patterns)) nodes)) nodes)) (rum/defc graph-aux [settings forcesettings theme search-graph-filters] (let [[graph set-graph!] (hooks/use-state nil)] (hooks/use-effect! (fn [] (p/let [result (state/