mirror of
https://github.com/logseq/logseq.git
synced 2026-05-25 13:14:39 +00:00
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:
40
deps/shui/src/logseq/shui/dialog/core.cljs
vendored
40
deps/shui/src/logseq/shui/dialog/core.cljs
vendored
@@ -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)))))))
|
||||
|
||||
1
deps/shui/src/logseq/shui/ui.cljs
vendored
1
deps/shui/src/logseq/shui/ui.cljs
vendored
@@ -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!)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?)))))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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?))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user