From d5ff77611c8ce519b770e3d833826301c52aae87 Mon Sep 17 00:00:00 2001 From: scheinriese Date: Sun, 15 Feb 2026 02:03:55 +0100 Subject: [PATCH] Add object page creation via CMD+K with inline tag syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing "PageName #Tag" in CMD+K now creates a tagged page and morphs the dialog into a page preview modal with inherited properties for quick capture — without navigating away from the current page. Includes dialog crossfade transitions, parameterized page-dialog-footer, and fixes for property rendering in child blocks within tag-dialog mode. Co-Authored-By: Claude Opus 4.6 --- deps/shui/src/logseq/shui/dialog/core.cljs | 40 ++++- deps/shui/src/logseq/shui/ui.cljs | 1 + resources/css/shui.css | 20 +++ src/main/frontend/components/block.cljs | 2 +- src/main/frontend/components/cmdk/cmdk.css | 10 +- src/main/frontend/components/cmdk/core.cljs | 161 +++++++++++++++----- src/main/frontend/components/page.cljs | 2 +- src/main/frontend/components/property.cljs | 2 +- 8 files changed, 196 insertions(+), 42 deletions(-) diff --git a/deps/shui/src/logseq/shui/dialog/core.cljs b/deps/shui/src/logseq/shui/dialog/core.cljs index 3408f9a66a..5ffc7e4da6 100644 --- a/deps/shui/src/logseq/shui/dialog/core.cljs +++ b/deps/shui/src/logseq/shui/dialog/core.cljs @@ -123,14 +123,35 @@ (doseq [{:keys [id]} @*modals] (close! id))) +(defn transition-to! + "Replace a modal's content in-place with a crossfade animation. + Keeps the dialog container, overlay, and position stable. + Optionally merges new-opts into the modal config (e.g. :close-btn?, :content-props)." + [id new-content & [new-opts]] + (when-let [[index config] (get-modal id)] + (let [new-config (merge config + (dissoc new-opts :id) + {:content new-content + :prev-content (:content config) + :transitioning? true})] + (swap! *modals assoc index new-config)))) + +(defn clear-transition! + "Remove transition state from a modal after crossfade completes." + [id] + (when-let [[index config] (get-modal id)] + (swap! *modals assoc index (dissoc config :prev-content :transitioning?)))) + ;; components (rum/defc modal-inner [config] (let [{:keys [id title description content footer on-open-change align open? - auto-width? close-btn? root-props content-props]} config + auto-width? close-btn? root-props content-props + prev-content transitioning?]} config props (dissoc config :id :title :description :content :footer :auto-width? :close-btn? - :close :align :on-open-change :open? :root-props :content-props) + :close :align :on-open-change :open? :root-props :content-props + :prev-content :transitioning?) props (assoc-in props [:overlay-props :data-align] (name (or align :center)))] (hooks/use-effect! @@ -165,8 +186,19 @@ (dialog-title {:class (when (nil? title) "hidden")} title) (when description (dialog-description description)) - (when content - [:div.ui__dialog-main-content content]) + (when (or content prev-content) + [:div.ui__dialog-main-content + {:class (when transitioning? "relative overflow-hidden")} + ;; Exit: old content fading out (absolute positioned, on top) + (when (and transitioning? prev-content) + [:div {:class "absolute inset-0 z-10 dialog-phase-exit pointer-events-none" + :on-animation-end (fn [] (clear-transition! id))} + prev-content]) + ;; Enter: new content fading in + (when content + [:div {:class (cond-> "h-full" + transitioning? (str " dialog-phase-enter"))} + content])]) (when footer (dialog-footer footer))))))) diff --git a/deps/shui/src/logseq/shui/ui.cljs b/deps/shui/src/logseq/shui/ui.cljs index dc6a088124..93ba94e645 100644 --- a/deps/shui/src/logseq/shui/ui.cljs +++ b/deps/shui/src/logseq/shui/ui.cljs @@ -142,6 +142,7 @@ (def dialog-close! dialog-core/close!) (def dialog-close-all! dialog-core/close-all!) (def dialog-get dialog-core/get-modal) +(def dialog-transition-to! dialog-core/transition-to!) (def popup-show! popup-core/show!) (def popup-hide! popup-core/hide!) (def popup-hide-all! popup-core/hide-all!) diff --git a/resources/css/shui.css b/resources/css/shui.css index 130fa3d43b..b4ee770c24 100644 --- a/resources/css/shui.css +++ b/resources/css/shui.css @@ -264,6 +264,26 @@ div[data-radix-popper-content-wrapper] { } } +/* Dialog content crossfade for transition-to! */ +@keyframes dialogContentFadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes dialogContentFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.dialog-phase-exit { + animation: dialogContentFadeOut 200ms ease-out forwards; +} + +.dialog-phase-enter { + opacity: 0; + animation: dialogContentFadeIn 200ms ease-in 100ms forwards; +} + .ui__alert-dialog-content { &[data-mode=confirm] { .ui__alert-dialog-footer { diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index 30b1cec362..b655281257 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -3280,7 +3280,7 @@ (block-positioned-properties config block :block-below))]]) (when (and (not (:library? config)) - (or (:tag-dialog? config) + (or (and (:tag-dialog? config) (:page-title? config)) (and (not collapsed?) (not (or table? property?))))) diff --git a/src/main/frontend/components/cmdk/cmdk.css b/src/main/frontend/components/cmdk/cmdk.css index 6d66092c52..87b5dfcef7 100644 --- a/src/main/frontend/components/cmdk/cmdk.css +++ b/src/main/frontend/components/cmdk/cmdk.css @@ -6,5 +6,13 @@ } .ls-dialog-cmdk { - @apply p-0 w-auto !max-w-fit overflow-hidden; + @apply p-0 overflow-hidden flex flex-col; + width: 90dvw !important; + max-width: 56rem !important; /* 4xl */ + height: 75dvh !important; + + > .ui__dialog-main-content { + @apply flex-1 min-h-0; + } + } diff --git a/src/main/frontend/components/cmdk/core.cljs b/src/main/frontend/components/cmdk/core.cljs index 6506c79dac..f48c3bd5f8 100644 --- a/src/main/frontend/components/cmdk/core.cljs +++ b/src/main/frontend/components/cmdk/core.cljs @@ -5,6 +5,7 @@ [frontend.components.block :as block] [frontend.components.cmdk.list-item :as list-item] [frontend.components.icon :as icon] + [frontend.components.page :as component-page] [frontend.components.wikidata :as wikidata] [frontend.config :as config] [frontend.context.i18n :refer [t]] @@ -96,26 +97,45 @@ (not (#{"config.edn" "custom.js" "custom.css"} q)) (not config/publishing?)) (let [class? (string/starts-with? q "#") + has-inline-tag? (and (not class?) (string/includes? q " #")) + ;; Parse "PageName #Tag1 #Tag2" pattern + [object-page-name object-tag-names] + (when has-inline-tag? + (let [parts (string/split q #" #") + pn (string/trim (first parts)) + tns (->> (rest parts) (map string/trim) (remove string/blank?) vec)] + (when (and (not (string/blank? pn)) (seq tns)) + [pn tns]))) + create-object? (some? object-page-name) class-name (get-class-from-input q) class (let [class (db/get-case-page class-name)] (when (ldb/class? class) class))] - (->> [{:text (cond - class "Configure tag" - class? "Create tag" - :else "Create page") - :icon (if class "settings" "new-page") - :icon-theme :gray - :info (cond - class - (str "Configure #" class-name) - class? - (str "Create tag called '" class-name "'") - :else - (str "Create page called '" q "'")) - :source-create :page - :class class}] - (remove nil?))))) + (if create-object? + [{:text (str "Create as #" (string/join ", #" object-tag-names)) + :icon "new-page" + :icon-theme :gray + :info (str "Create page called '" object-page-name "'") + :source-create :page + :create-object? true + :page-name object-page-name + :tag-names object-tag-names}] + (->> [{:text (cond + class "Configure tag" + class? "Create tag" + :else "Create page") + :icon (if class "settings" "new-page") + :icon-theme :gray + :info (cond + class + (str "Configure #" class-name) + class? + (str "Create tag called '" class-name "'") + :else + (str "Create page called '" q "'")) + :source-create :page + :class class}] + (remove nil?)))))) ;; Take the results, decide how many items to show, and order the results appropriately (defn state->results-ordered [state search-mode] @@ -696,22 +716,45 @@ (when-not (contains? dont-close-commands (:id command)) (shui/dialog-close! :ls-dialog-cmdk))))) -(defmethod handle-action :create [_ state _event] - (let [item (state->highlighted-item state) - !input (::input state) - create-class? (string/starts-with? @!input "#") - create-page? (= :page (:source-create item)) - class (when create-class? (get-class-from-input @!input))] - (if (and (= (:text item) "Configure tag") (:class item)) - (state/pub-event! [:dialog/show-block (:class item) {:tag-dialog? true}]) - (p/let [result (cond - create-class? - (db-page-handler/default-icon class-title)) new-class)))) +(defmethod handle-action :create [_ state _event] + (let [item (state->highlighted-item state) + !input (::input state) + create-class? (string/starts-with? @!input "#") + create-object? (:create-object? item) + create-page? (and (= :page (:source-create item)) (not create-object?)) + class (when create-class? (get-class-from-input @!input)) + page-dialog-content (fn [block opts] + [:div.w-full.h-full.flex.flex-col + [:div.px-16.py-8.flex-1.min-h-0.overflow-y-auto + (component-page/page-container block {:tag-dialog? true})] + (page-dialog-footer block opts)])] + (cond + ;; Configure existing tag — synchronous morph + (and (= (:text item) "Configure tag") (:class item)) + (shui/dialog-transition-to! :ls-dialog-cmdk + (page-dialog-content (:class item) {}) + {:close-btn? true}) + + ;; Create object page ("PageName #Tag") — async create, then morph + create-object? + (let [page-name (:page-name item) + tag-names (:tag-names item)] + (p/let [tag-entities (p/all (mapv "w-full h-full relative flex flex-col justify-start" (not sidebar?) (str " rounded-lg"))} (input-row state all-items opts) - [:div {:class (cond-> "w-full flex-1 overflow-y-auto min-h-[65dvh] max-h-[65dvh]" + [:div {:class (cond-> "w-full flex-1 overflow-y-auto" (not sidebar?) (str " pb-14")) :ref #(let [*ref (::scroll-container-ref state)] (when-not @*ref (reset! *ref %))) @@ -1345,7 +1438,7 @@ (when-not sidebar? (hints state))])) (rum/defc cmdk-modal [props] - [:div {:class "cp__cmdk__modal rounded-lg w-[90dvw] max-w-4xl relative" + [:div {:class "cp__cmdk__modal rounded-lg w-[90dvw] max-w-4xl relative h-full" :data-keep-selection true} (cmdk props)]) diff --git a/src/main/frontend/components/page.cljs b/src/main/frontend/components/page.cljs index 863a27e8f2..d17c1e4321 100644 --- a/src/main/frontend/components/page.cljs +++ b/src/main/frontend/components/page.cljs @@ -462,7 +462,7 @@ (when show-tabs? (tabs page {:current-page? option :sidebar? sidebar?})) - (when (not tag-dialog?) + (when (or (not tag-dialog?) (not class-page?)) [:div.ls-page-blocks {:style {:margin-left (if (util/mobile?) 0 -20)} :class (when-not (or sidebar? (util/capacitor?)) diff --git a/src/main/frontend/components/property.cljs b/src/main/frontend/components/property.cljs index c847463f83..353c808fbd 100644 --- a/src/main/frontend/components/property.cljs +++ b/src/main/frontend/components/property.cljs @@ -838,7 +838,7 @@ id (::id state) db-id (:db/id (::block state)) block (db/sub-block db-id) - show-properties? (or sidebar-properties? tag-dialog?) + show-properties? (or sidebar-properties? (and tag-dialog? page-title?)) show-empty-and-hidden-properties? (let [{:keys [mode show? ids]} (state/sub :ui/show-empty-and-hidden-properties?)] (and show? (or (= mode :global)