Просмотр исходного кода

enhance(ux): table row/cell navigation (#11837)

* enhance(ux): up/down to navigate table rows

* enhance: remove block id+uuid class

Use `blockid`.

* enhance: arrowleft to select cell when a table row has been selected

* wip: table cell navigation

* feat: up/down/left/right table cell navigation

* enhance(ux): scroll to cell when it's not visible

* fix: save block content when exit title cell popup

* fix: table keyboard navigation doesn't work on virtualized table

The solution is to preload more rows for virtualized tables.
Tienson Qin 7 месяцев назад
Родитель
Сommit
68417bbb01

+ 1 - 1
deps/shui/src/logseq/shui/table/core.cljc

@@ -276,7 +276,7 @@
 (rum/defc table-row < rum/static
   [& prop-and-children]
   (let [[prop children] (get-prop-and-children prop-and-children)]
-    [:div.ls-table-row.flex.flex-row.items-center
+    [:div.ls-table-row.ls-block.flex.flex-row.items-center
      (merge {:class "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted bg-gray-01 items-stretch"}
             prop)
      children]))

+ 3 - 1
src/main/frontend/common.css

@@ -326,7 +326,9 @@ h1.title, h1.title input, .ls-page-title-container {
 
 .block-highlight,
 .ls-block.selected,
-.ls-dummy-block.selected {
+.ls-dummy-block.selected,
+.ls-table-cell.selected
+{
   transition: background-color 0.2s cubic-bezier(0, 1, 0, 1);
   background-color: var(--ls-block-highlight-color, var(--rx-gray-04));
 }

+ 1 - 2
src/main/frontend/components/block.cljs

@@ -3534,8 +3534,7 @@
        :data-is-property (ldb/property? block)
        :ref #(when (nil? @*ref) (reset! *ref %))
        :data-collapsed (and collapsed? has-child?)
-       :class (str "id" uuid " "
-                   (when selected? " selected")
+       :class (str (when selected? "selected")
                    (when pre-block? " pre-block")
                    (when order-list? " is-order-list")
                    (when (string/blank? title) " is-blank")

+ 26 - 12
src/main/frontend/components/objects.cljs

@@ -10,6 +10,7 @@
             [logseq.db :as ldb]
             [logseq.db.frontend.property :as db-property]
             [logseq.outliner.property :as outliner-property]
+            [logseq.shui.hooks :as hooks]
             [logseq.shui.ui :as shui]
             [promesa.core :as p]
             [rum.core :as rum]))
@@ -34,9 +35,19 @@
              [:div.block-content (asset-cp (assoc config :disable-resize? true) row)]))
    :disable-hide? true})
 
+(comment
+  (defn- edit-new-object
+    [ref id]
+    (js/setTimeout
+     (fn []
+       (when-let [title-node (d/sel1 ref (util/format ".ls-table-row[data-id='%d'] .table-block-title" id))]
+         (.click title-node)))
+     100)))
+
 (rum/defc class-objects-inner < rum/static
   [config class properties]
-  (let [;; Properties can be nil for published private graphs
+  (let [*ref (hooks/use-ref nil)
+        ;; Properties can be nil for published private graphs
         properties' (remove nil? properties)
         columns* (views/build-columns config properties' {:add-tags-column? true})
         columns (cond
@@ -70,19 +81,22 @@
                                                        (set-data! (concat full-data (map :db/id entities))))))})]))
                                 (p/let [block (add-new-class-object! class properties)]
                                   (when (:db/id block)
+                                    (set-data! (conj (vec full-data) (:db/id block)))
                                     (state/sidebar-add-block! (state/get-current-repo) (:db/id block) :block)
-                                    (set-data! (conj (vec full-data) (:db/id block)))))))))]
+                                    ;; (edit-new-object (rum/deref *ref) (:db/id block))
+                                    ))))))]
 
-    (views/view {:config config
-                 :view-parent class
-                 :view-feature-type :class-objects
-                 :columns columns
-                 :add-new-object! add-new-object!
-                 :show-add-property? true
-                 :show-items-count? true
-                 :add-property! (fn []
-                                  (state/pub-event! [:editor/new-property {:block class
-                                                                           :class-schema? true}]))})))
+    [:div {:ref *ref}
+     (views/view {:config config
+                  :view-parent class
+                  :view-feature-type :class-objects
+                  :columns columns
+                  :add-new-object! add-new-object!
+                  :show-add-property? true
+                  :show-items-count? true
+                  :add-property! (fn []
+                                   (state/pub-event! [:editor/new-property {:block class
+                                                                            :class-schema? true}]))})]))
 
 (rum/defcs class-objects < rum/reactive db-mixins/query mixins/container-id
   [state class {:keys [current-page? sidebar?]}]

+ 4 - 0
src/main/frontend/components/table.css

@@ -29,6 +29,10 @@
     }
   }
 
+  .ls-table-row, .ls-table-cell, .ls-table-cell .jtrigger {
+    @apply focus:ring-0 focus:ring-offset-0 focus-visible:outline-none;
+  }
+
   .ls-table-row {
     @apply h-[33px] min-h-[33px] max-h-[33px];
     div, span, a {

+ 213 - 62
src/main/frontend/components/views.cljs

@@ -191,11 +191,18 @@
 (rum/defc block-title
   "Used on table view"
   [block {:keys [create-new-block width]}]
-  (let [inline-title (state/get-component :block/inline-title)
+  (let [*ref (hooks/use-ref nil)
         [opacity set-opacity!] (hooks/use-state 0)
+        [focus-timeout set-focus-timeout!] (hooks/use-state nil)
+        inline-title (state/get-component :block/inline-title)
         add-to-sidebar! #(state/sidebar-add-block! (state/get-current-repo) (:db/id block) :block)]
+    (hooks/use-effect!
+     (fn []
+       #(some-> focus-timeout js/clearTimeout))
+     [])
     [:div.table-block-title.relative.flex.items-center.w-full.h-full.cursor-pointer.items-center
-     {:on-mouse-over #(set-opacity! 100)
+     {:ref *ref
+      :on-mouse-over #(set-opacity! 100)
       :on-mouse-out #(set-opacity! 0)
       :on-click (fn [e]
                   (p/let [block (or block (and (fn? create-new-block) (create-new-block)))
@@ -211,36 +218,43 @@
                         :else
                         (p/do!
                          (shui/popup-show!
-                           (.closest (.-target e) ".ls-table-cell")
-                           (fn []
-                             (let [width (-> (max 160 width)
-                                           (- 18))]
-                               [:div.ls-table-block.flex.flex-row.items-start
-                                {:style {:width width :max-width width :margin-right "6px"}
-                                 :on-click util/stop-propagation}
-                                (block-container {:popup? true
-                                                  :view? true
-                                                  :table-block-title? true} block)
-                                (shui/button
-                                  {:variant :ghost
-                                   :title "Open node"
-                                   :on-click (fn [e]
-                                               (util/stop-propagation e)
-                                               (shui/popup-hide!)
-                                               (redirect!))
-                                   :class (str "h-6 w-6 !p-0 text-muted-foreground transition-opacity duration-100 ease-in bg-gray-01 "
-                                            "opacity-" opacity)}
-                                  (ui/icon "arrow-right"))]))
-                           {:id :ls-table-block-editor
-                            :as-mask? true})
-                          (editor-handler/edit-block! block :max {:container-id :unknown-container}))))))}
+                          (.closest (.-target e) ".ls-table-cell")
+                          (fn []
+                            (let [width (-> (max 160 width)
+                                            (- 18))]
+                              [:div.ls-table-block.flex.flex-row.items-start
+                               {:style {:width width :max-width width :margin-right "6px"}
+                                :on-click util/stop-propagation}
+                               (block-container {:popup? true
+                                                 :view? true
+                                                 :table-block-title? true} block)
+                               (shui/button
+                                {:variant :ghost
+                                 :title "Open node"
+                                 :on-click (fn [e]
+                                             (util/stop-propagation e)
+                                             (shui/popup-hide!)
+                                             (redirect!))
+                                 :class (str "h-6 w-6 !p-0 text-muted-foreground transition-opacity duration-100 ease-in bg-gray-01 "
+                                             "opacity-" opacity)}
+                                (ui/icon "arrow-right"))]))
+                          {:id :ls-table-block-editor
+                           :as-mask? true
+                           :on-after-hide (fn []
+                                            (let [node (rum/deref *ref)
+                                                  cell (util/rec-get-node node "ls-table-cell")]
+                                              (p/do!
+                                               (editor-handler/save-current-block!)
+                                               (state/exit-editing-and-set-selected-blocks! [cell])
+                                               (set-focus-timeout! (js/setTimeout #(.focus cell) 100)))))})
+                         (editor-handler/edit-block! block :max {:container-id :unknown-container}))))))}
      (if block
        [:div
         (inline-title
-          (some->> (:block/title block)
-            string/trim
-            string/split-lines
-            first))]
+         (some->> (:block/title block)
+                  string/trim
+                  string/split-lines
+                  first))]
        [:div])
 
      [:div.absolute.right-0.p-1
@@ -249,11 +263,11 @@
                    (add-to-sidebar!))}
       [:div.flex.items-center
        (shui/button
-         {:variant :ghost
-          :title "Open in sidebar"
-          :class (str "h-5 w-5 !p-0 text-muted-foreground transition-opacity duration-100 ease-in bg-gray-01 "
-                   "opacity-" opacity)}
-         (ui/icon "layout-sidebar-right"))]]]))
+        {:variant :ghost
+         :title "Open in sidebar"
+         :class (str "h-5 w-5 !p-0 text-muted-foreground transition-opacity duration-100 ease-in bg-gray-01 "
+                     "opacity-" opacity)}
+        (ui/icon "layout-sidebar-right"))]]]))
 
 (defn build-columns
   [config properties & {:keys [with-object-name? with-id? add-tags-column?]
@@ -263,30 +277,30 @@
   (let [;; FIXME: Shouldn't file graphs have :block/tags?
         add-tags-column?' (and (config/db-based-graph? (state/get-current-repo)) add-tags-column?)
         properties' (->>
-                      (if (or (some #(= (:db/ident %) :block/tags) properties) (not add-tags-column?'))
-                        properties
-                        (conj properties (db/entity :block/tags)))
-                      (remove nil?))]
+                     (if (or (some #(= (:db/ident %) :block/tags) properties) (not add-tags-column?'))
+                       properties
+                       (conj properties (db/entity :block/tags)))
+                     (remove nil?))]
     (->> (concat
-           [{:id :select
-             :name "Select"
-             :header (fn [table _column] (header-checkbox table))
-             :cell (fn [table row column]
-                     (row-checkbox table row column))
-             :column-list? false
-             :resizable? false}
-            (when with-id?
-              {:id :id
-               :name "ID"
-               :header (fn [_table _column] (header-index))
-               :cell (fn [table row _column]
-                       (inc (.indexOf (:rows table) (:db/id row))))
-               :resizable? false})
-            (when with-object-name?
-              {:id :block/title
-               :name "Name"
-               :type :string
-               :header header-cp
+          [{:id :select
+            :name "Select"
+            :header (fn [table _column] (header-checkbox table))
+            :cell (fn [table row column]
+                    (row-checkbox table row column))
+            :column-list? false
+            :resizable? false}
+           (when with-id?
+             {:id :id
+              :name "ID"
+              :header (fn [_table _column] (header-index))
+              :cell (fn [table row _column]
+                      (inc (.indexOf (:rows table) (:db/id row))))
+              :resizable? false})
+           (when with-object-name?
+             {:id :block/title
+              :name "Name"
+              :type :string
+              :header header-cp
               :cell (fn [_table row _column style]
                       (block-title row {:property-ident :block/title
                                         :sidebar? (:sidebar? config)
@@ -653,14 +667,109 @@
   [cell-render-f cell-placeholder]
   (let [^js state (ui/useInView #js {:rootMargin "0px"})
         in-view? (.-inView state)]
-    [:div.h-full {:ref (.-ref state)}
+    [:div.h-full
+     {:ref (.-ref state)}
      (if in-view?
        (cell-render-f)
        cell-placeholder)]))
 
+(defn- click-cell
+  [node]
+  (when-let [trigger (or (dom/sel1 node ".jtrigger")
+                         (dom/sel1 node ".table-block-title"))]
+    (.click trigger)))
+
+(defn navigate-to-cell
+  [e cell direction]
+  (util/stop e)
+  (let [row (util/rec-get-node cell "ls-table-row")
+        cells (dom/sel row ".ls-table-cell")
+        idx (.indexOf cells cell)
+        rows-container (util/rec-get-node row "ls-table-rows")
+        rows (dom/sel rows-container ".ls-table-row")
+        row-idx (.indexOf rows row)
+        container-left (.-left (.getBoundingClientRect rows-container))
+        next-cell (case direction
+                    :left (if (> idx 1)               ; don't focus on checkbox
+                            (nth cells (dec idx))
+                            ;; last cell in the prev row
+                            (let [prev-row (when (> row-idx 0)
+                                             (nth rows (dec row-idx)))]
+                              (when prev-row
+                                (let [cells (dom/sel prev-row ".ls-table-cell")]
+                                  (last cells)))))
+                    :right (if (< idx (dec (count cells)))
+                             (nth cells (inc idx))
+                             ;; first cell in the next row
+                             (let [next-row (when (< row-idx (dec (count rows)))
+                                              (nth rows (inc row-idx)))]
+                               (when next-row
+                                 (let [cells (dom/sel next-row ".ls-table-cell")]
+                                   (second cells)))))
+                    :up (let [prev-row (when (> row-idx 0)
+                                         (nth rows (dec row-idx)))]
+                          (when prev-row
+                            (let [cells (dom/sel prev-row ".ls-table-cell")]
+                              (nth cells idx))))
+                    :down (let [next-row (when (< row-idx (dec (count rows)))
+                                           (nth rows (inc row-idx)))]
+                            (when next-row
+                              (let [cells (dom/sel next-row ".ls-table-cell")]
+                                (nth cells idx)))))]
+    (when next-cell
+      (let [next-cell-left (.-left (.getBoundingClientRect next-cell))]
+        (state/clear-selection!)
+        (dom/add-class! next-cell "selected")
+        (.focus next-cell)
+        (when (< next-cell-left container-left)
+          (.scrollIntoView next-cell #js {:inline "center"
+                                          :block "nearest"}))))))
+
+(rum/defc table-cell-container
+  [cell-opts body]
+  (let [*ref (hooks/use-ref nil)]
+    (shui/table-cell
+     (assoc cell-opts
+            :tabIndex 0
+            :ref *ref
+            :on-click (fn [] (click-cell (rum/deref *ref)))
+            :on-key-down (fn [e]
+                           (let [container (rum/deref *ref)]
+                             (case (util/ekey e)
+                               "Escape"
+                               (do
+                                 (if (util/input? (.-target e))
+                                   (do
+                                     (state/exit-editing-and-set-selected-blocks! [container])
+                                     (.focus container))
+                                   (do
+                                     (dom/remove-class! container "selected")
+                                     (let [row (util/rec-get-node container "ls-table-row")]
+                                       (state/exit-editing-and-set-selected-blocks! [row]))))
+                                 (util/stop e))
+                               "Enter"
+                               (do
+                                 (if (util/input? (.-target e)) ; number
+                                   (do
+                                     (state/exit-editing-and-set-selected-blocks! [container])
+                                     (.focus container))
+                                   (click-cell container))
+                                 (util/stop e))
+                               "ArrowUp"
+                               (navigate-to-cell e container :up)
+                               "ArrowDown"
+                               (navigate-to-cell e container :down)
+                               "ArrowLeft"
+                               (navigate-to-cell e container :left)
+                               "ArrowRight"
+                               (navigate-to-cell e container :right)
+                               nil))))
+     body)))
+
 (rum/defc table-row-inner < rum/static
   [{:keys [row-selected?] :as table} row props {:keys [show-add-property? scrolling?]}]
-  (let [pinned-columns (get-in table [:state :pinned-columns])
+  (let [*ref (hooks/use-ref nil)
+        pinned-columns (get-in table [:state :pinned-columns])
         unpinned (get-in table [:state :unpinned-columns])
         unpinned-columns (if show-add-property?
                            (conj (vec unpinned)
@@ -678,19 +787,60 @@
                                       :select? select?
                                       :add-property? add-property?
                                       :style style}
-                           cell-placeholder (shui/table-cell cell-opts nil)]
+                           cell-placeholder (table-cell-container cell-opts nil)]
                        (if (and scrolling? (not (:block/title row)))
                          cell-placeholder
                          (when-let [render (get column :cell)]
                            (lazy-table-cell
-                            (fn [] (shui/table-cell cell-opts (render table row column style)))
+                            (fn []
+                              (table-cell-container
+                               cell-opts (render table row column style)))
                             cell-placeholder)))))]
     (shui/table-row
      (merge
       props
       {:key (str (:db/id row))
+       :tabIndex 0
+       :ref *ref
        :data-state (when (row-selected? row) "selected")
-       :on-pointer-down (fn [_e] (db-async/<get-block (state/get-current-repo) (:db/id row) {:children? false}))})
+       :data-id (:db/id row)
+       :blockid (str (:block/uuid row))
+       :on-pointer-down (fn [_e] (db-async/<get-block (state/get-current-repo) (:db/id row) {:children? false}))
+       :on-key-down (fn [e]
+                      (let [container (rum/deref *ref)]
+                        (when (dom/has-class? container "selected")
+                          (case (util/ekey e)
+                            "Enter"
+                            (do
+                              (state/sidebar-add-block! (state/get-current-repo) (:db/id row) :block)
+                              (state/clear-selection!)
+                              (util/stop e))
+                            "ArrowLeft"
+                            (do
+                              (when-let [cell (->> (dom/sel container ".ls-table-cell")
+                                                   (remove (fn [node]
+                                                             (some? (dom/sel1 node ".ui__checkbox"))))
+                                                   first)]
+                                (state/clear-selection!)
+                                (dom/add-class! cell "selected")
+                                (.focus cell))
+                              (util/stop e))
+                            "ArrowRight"
+                            (do
+                              (when-let [cell (->> (dom/sel container ".ls-table-cell")
+                                                   (remove (fn [node]
+                                                             (some? (dom/sel1 node ".ui__checkbox"))))
+                                                   last)]
+                                (state/clear-selection!)
+                                (dom/remove-class! container "selected")
+                                (dom/add-class! cell "selected")
+                                (.focus cell))
+                              (util/stop e))
+                            "Escape"
+                            (do
+                              (state/clear-selection!)
+                              (util/stop e))
+                            nil))))})
      (when (seq pinned-columns)
        [:div.sticky-columns.flex.flex-row
         (map #(row-cell-f % {}) pinned-columns)])
@@ -1285,6 +1435,7 @@
     (when (seq rows)
       (ui/virtualized-list
        {:ref #(reset! *scroller-ref %)
+        :increase-viewport-by {:top 300 :bottom 300}
         :custom-scroll-parent (if sidebar?
                                 (first (dom/by-class "sidebar-item-list"))
                                 (gdom/getElement "main-content-container"))

+ 1 - 1
src/main/frontend/handler/block.cljs

@@ -66,7 +66,7 @@
 
 (defn select-block!
   [block-uuid]
-  (let [blocks (js/document.getElementsByClassName (str "id" block-uuid))]
+  (let [blocks (util/get-blocks-by-id block-uuid)]
     (when (seq blocks)
       (state/exit-editing-and-set-selected-blocks! blocks))))
 

+ 5 - 5
src/main/frontend/handler/editor.cljs

@@ -236,7 +236,7 @@
 
 (defn highlight-block!
   [block-uuid]
-  (let [blocks (array-seq (js/document.getElementsByClassName (str "id" block-uuid)))]
+  (let [blocks (util/get-blocks-by-id block-uuid)]
     (doseq [block blocks]
       (dom/add-class! block "block-highlight"))))
 
@@ -3452,7 +3452,6 @@
                (not (slide-focused?))
                (not (state/get-timestamp-block)))
       (util/stop e)
-
       (cond
         (or (state/editing?) (active-jtrigger?))
         (keydown-up-down-handler direction {})
@@ -3905,7 +3904,7 @@
                                              :collapse? true})]
        (->> blocks
             (map (fn [b] (or (some-> (:db/id (:block/link b)) db/entity) b)))
-            (map (comp gdom/getElementByClass (fn [b] (str "id" (:block/uuid b)))))
+            (mapcat (fn [b] (util/get-blocks-by-id (:block/uuid b))))
             state/exit-editing-and-set-selected-blocks!)))
    (state/set-state! :selection/selected-all? true)))
 
@@ -3920,7 +3919,7 @@
       (do
         (util/stop e)
         (state/exit-editing-and-set-selected-blocks!
-         [(gdom/getElementByClass (str "id" (:block/uuid edit-block)))]))
+         [(util/get-first-block-by-id (:block/uuid edit-block))]))
 
       edit-block
       nil
@@ -3948,7 +3947,8 @@
                   nil
 
                   (and parent (:block/parent parent))
-                  (state/exit-editing-and-set-selected-blocks! [(gdom/getElementByClass (str "id" (:block/uuid parent)))])
+                  (state/exit-editing-and-set-selected-blocks!
+                   [(util/get-first-block-by-id (:block/uuid parent))])
 
                   (:block/name parent)
                   ;; page block

+ 1 - 1
src/main/frontend/handler/ui.cljs

@@ -98,7 +98,7 @@
             (> (count fragment) 36)
             (subs fragment (- (count fragment) 36)))]
     (if (and id (util/uuid-string? id))
-      (let [elements (array-seq (js/document.getElementsByClassName (str "id" id)))]
+      (let [elements (util/get-blocks-by-id id)]
         (when (first elements)
           (util/scroll-to-element (gobj/get (first elements) "id")))
         (state/exit-editing-and-set-selected-blocks! elements))

+ 8 - 4
src/main/frontend/state.cljs

@@ -1201,13 +1201,15 @@ Similar to re-frame subscriptions"
 
 (defn dom-clear-selection!
   []
-  (doseq [node (dom/by-class "ls-block selected")]
+  (doseq [node (dom/by-class "selected")]
     (dom/remove-class! node "selected")))
 
 (defn mark-dom-blocks-as-selected
   [nodes]
   (doseq [node nodes]
-    (dom/add-class! node "selected")))
+    (dom/add-class! node "selected")
+    (when (dom/has-class? node "ls-table-row")
+      (.focus node))))
 
 (defn get-events-chan
   []
@@ -1230,8 +1232,10 @@ Similar to re-frame subscriptions"
         removed (set/difference selected-ids new-ids)]
     (mark-dom-blocks-as-selected blocks)
     (doseq [id removed]
-      (doseq [node (array-seq (gdom/getElementsByClass (str "id" id)))]
-        (dom/remove-class! node "selected")))))
+      (doseq [node (dom/sel (util/format "[blockid='%s']" id))]
+        (dom/remove-class! node "selected")
+        (when (dom/has-class? node "ls-table-row")
+          (.blur node))))))
 
 (defn set-selection-blocks!
   ([blocks]

+ 7 - 4
src/main/frontend/util.cljc

@@ -992,13 +992,16 @@
 (defonce linux? #?(:cljs goog.userAgent/LINUX
                    :clj nil))
 
+#?(:cljs
+   (defn get-blocks-by-id
+     [block-id]
+     (when (uuid-string? (str block-id))
+       (d/sel (format "[blockid='%s']" (str block-id))))))
+
 #?(:cljs
    (defn get-first-block-by-id
      [block-id]
-     (when block-id
-       (let [block-id (str block-id)]
-         (when (uuid-string? block-id)
-           (first (array-seq (js/document.getElementsByClassName (str "id" block-id)))))))))
+     (first (get-blocks-by-id block-id))))
 
 #?(:cljs
    (defn url-encode