Add object page creation via CMD+K with inline tag syntax

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 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-02-15 02:03:55 +01:00
parent 40ed601c20
commit d5ff77611c
8 changed files with 196 additions and 42 deletions

View File

@@ -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)))))))

View File

@@ -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!)

View File

@@ -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 {

View File

@@ -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?)))))

View File

@@ -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;
}
}

View File

@@ -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/<create-class! class
{:redirect? false})
create-page? (page-handler/<create! @!input {:redirect? true}))]
(shui/dialog-close! :ls-dialog-cmdk)
(when (and create-class? result)
(state/pub-event! [:dialog/show-block result {:tag-dialog? true}]))))))
(rum/defc page-dialog-footer
[block {:keys [open-label] :or {open-label "Open tag page"}}]
[:div.flex.w-full.items-center.justify-between.px-3.py-2.gap-2.bg-gray-03.border-t.border-gray-05
;; Left: tip
[:div.text-sm.leading-6
[:div.flex.flex-row.gap-1.items-center
[:span.font-medium.text-gray-12 "Tip:"]
[:div.flex.flex-row.gap-1.items-center.opacity-50
[:span "Press"]
(shui/shortcut ["cmd" "j"] {:style :combo :interactive? false :aria-hidden? true})
[:span "to jump to a property"]]]]
;; Right: action buttons
[:div.flex.items-center.gap-2
;; More dropdown
(shui/dropdown-menu
(shui/dropdown-menu-trigger
{:asChild true}
(shui/button {:variant :ghost :size :sm :class "opacity-60 hover:opacity-100"}
"More"))
(shui/dropdown-menu-content
{:align "end" :side "top"}
(shui/dropdown-menu-item
{:on-click (fn []
(shui/dialog-close!)
(route-handler/redirect-to-page! (:block/uuid block)))}
open-label)
(shui/dropdown-menu-item
{:on-click (fn []
(state/sidebar-add-block! (state/get-current-repo) (:db/id block) :page)
(shui/dialog-close!))}
"Open in sidebar")))
;; Primary action: Done
(shui/button
{:variant :default
:size :sm
:on-click #(shui/dialog-close!)}
"Done")]])
(defn- <ensure-class-exists!
"Ensure a class with the given title exists. Creates it if not found.
@@ -733,6 +776,56 @@
(js/console.log "[wikidata] Created new class:" class-title "with default-icon:" (get wikidata/class->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 <ensure-class-exists! tag-names))
page (page-handler/<create! page-name {:redirect? false})]
(when page
;; Apply tags (works for both new and existing pages)
(doseq [tag-entity (remove nil? tag-entities)]
(db-property-handler/set-block-property!
(:block/uuid page) :block/tags (:db/id tag-entity)))
;; Morph CMD+K into object page dialog
(shui/dialog-transition-to! :ls-dialog-cmdk
(page-dialog-content page {:open-label "Open page"})
{:close-btn? true}))))
;; Create new tag or page — async
:else
(p/let [result (cond
create-class?
(db-page-handler/<create-class! class
{:redirect? false})
create-page? (page-handler/<create! @!input {:redirect? true}))]
(if (and create-class? result)
;; Morph CMD+K into tag settings
(shui/dialog-transition-to! :ls-dialog-cmdk
(page-dialog-content result {})
{:close-btn? true})
;; Non-class creation (page): just close CMD+K
(shui/dialog-close! :ls-dialog-cmdk))))))
(defn- get-page-by-wikidata-id
"Find an existing page that was created from the given Wikidata entity."
[qid]
@@ -1314,7 +1407,7 @@
:class (cond-> "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)])

View File

@@ -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?))

View File

@@ -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)