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

Merge branch 'master' into feat/ai-lab

Tienson Qin 2 лет назад
Родитель
Сommit
bcf35b76d4
100 измененных файлов с 2656 добавлено и 607 удалено
  1. 2 0
      .carve/ignore
  2. 5 0
      .gitignore
  3. 2 2
      android/app/build.gradle
  4. 7 2
      android/app/src/main/java/com/logseq/app/FsWatcher.java
  5. 3 2
      deps.edn
  6. 5 5
      deps/common/src/logseq/common/path.cljs
  7. 0 1
      deps/db/src/logseq/db/schema.cljs
  8. 4 0
      deps/graph-parser/.carve/ignore
  9. 0 1
      deps/graph-parser/src/logseq/graph_parser.cljs
  10. 18 26
      deps/graph-parser/src/logseq/graph_parser/block.cljs
  11. 0 1
      deps/graph-parser/src/logseq/graph_parser/cli.cljs
  12. 1 7
      deps/graph-parser/src/logseq/graph_parser/config.cljs
  13. 6 1
      deps/graph-parser/src/logseq/graph_parser/property.cljs
  14. 39 15
      deps/graph-parser/test/logseq/graph_parser_test.cljs
  15. 17 0
      deps/shui/.clj-kondo/config.edn
  16. 29 0
      deps/shui/README.md
  17. 1 0
      deps/shui/deps.edn
  18. 2 0
      deps/shui/shui-graph/journals/2023_03_27.md
  19. 348 0
      deps/shui/shui-graph/logseq/config.edn
  20. 0 0
      deps/shui/shui-graph/logseq/custom.css
  21. 22 0
      deps/shui/shui-graph/pages/About Shui.md
  22. 3 0
      deps/shui/shui-graph/pages/Page 1.md
  23. 1 0
      deps/shui/shui-graph/pages/Page 2.md
  24. 1 0
      deps/shui/shui-graph/pages/Page 3.md
  25. 4 0
      deps/shui/shui-graph/pages/contents.md
  26. 4 0
      deps/shui/shui-graph/pages/shui___components.md
  27. 62 0
      deps/shui/shui-graph/pages/shui___components___table.md
  28. 36 0
      deps/shui/src/logseq/shui/context.cljs
  29. 11 0
      deps/shui/src/logseq/shui/core.cljs
  30. 471 0
      deps/shui/src/logseq/shui/table/v2.cljs
  31. 81 0
      deps/shui/src/logseq/shui/util.cljs
  32. 21 2
      docs/dev-practices.md
  33. 15 2
      e2e-tests/basic.spec.ts
  34. 1 1
      e2e-tests/editor.spec.ts
  35. 13 11
      e2e-tests/fixtures.ts
  36. 304 0
      e2e-tests/shui/table.spec.js
  37. 7 0
      e2e-tests/utils.ts
  38. 38 6
      e2e-tests/whiteboards.spec.ts
  39. 4 4
      ios/App/App.xcodeproj/project.pbxproj
  40. 33 3
      ios/App/App/FsWatcher.swift
  41. 3 1
      package.json
  42. 1 0
      public/index.html
  43. 1 0
      resources/electron.html
  44. 1 0
      resources/index.html
  45. 10 0
      resources/js/tabler.ext.js
  46. 1 1
      resources/package.json
  47. 6 6
      shadow-cljs.edn
  48. 76 66
      src/main/frontend/components/block.cljs
  49. 9 4
      src/main/frontend/components/block.css
  50. 2 2
      src/main/frontend/components/datetime.cljs
  51. 1 1
      src/main/frontend/components/file_sync.cljs
  52. 27 17
      src/main/frontend/components/page.cljs
  53. 2 1
      src/main/frontend/components/page.css
  54. 10 0
      src/main/frontend/components/plugins.css
  55. 4 3
      src/main/frontend/components/query.cljs
  56. 79 54
      src/main/frontend/components/query_table.cljs
  57. 12 8
      src/main/frontend/components/settings.cljs
  58. 8 10
      src/main/frontend/db/model.cljs
  59. 1 1
      src/main/frontend/db/react.cljs
  60. 125 1
      src/main/frontend/dicts.cljc
  61. 0 2
      src/main/frontend/format/block.cljs
  62. 1 2
      src/main/frontend/fs/capacitor_fs.cljs
  63. 10 1
      src/main/frontend/fs/sync.cljs
  64. 2 8
      src/main/frontend/handler/block.cljs
  65. 3 5
      src/main/frontend/handler/common/file.cljs
  66. 55 43
      src/main/frontend/handler/editor.cljs
  67. 4 12
      src/main/frontend/handler/events.cljs
  68. 26 27
      src/main/frontend/handler/export/text.cljs
  69. 11 17
      src/main/frontend/handler/file_sync.cljs
  70. 3 1
      src/main/frontend/handler/paste.cljs
  71. 10 7
      src/main/frontend/handler/whiteboard.cljs
  72. 73 53
      src/main/frontend/modules/editor/undo_redo.cljs
  73. 53 37
      src/main/frontend/modules/outliner/core.cljs
  74. 16 4
      src/main/frontend/modules/outliner/datascript.cljc
  75. 3 2
      src/main/frontend/modules/outliner/transaction.cljc
  76. 11 11
      src/main/frontend/modules/shortcut/config.cljs
  77. 67 1
      src/main/frontend/modules/shortcut/dicts.cljc
  78. 6 6
      src/main/frontend/page.cljs
  79. 1 1
      src/main/frontend/rum.cljs
  80. 1 0
      src/main/frontend/schema/handler/common_config.cljc
  81. 25 0
      src/main/frontend/shui.cljs
  82. 6 3
      src/main/frontend/state.cljs
  83. 24 24
      src/main/frontend/ui.cljs
  84. 1 1
      src/main/frontend/util.cljc
  85. 1 1
      src/main/frontend/util/cursor.cljs
  86. 10 8
      src/main/frontend/util/text.cljs
  87. 1 1
      src/main/frontend/version.cljs
  88. 9 24
      src/main/logseq/api.cljs
  89. 28 0
      src/main/logseq/api/block.cljs
  90. 16 0
      src/test/frontend/db/model_test.cljs
  91. 3 2
      src/test/frontend/fs_test.cljs
  92. 43 1
      src/test/frontend/handler/editor_test.cljs
  93. 2 1
      src/test/frontend/handler/plugin_config_test.cljs
  94. 1 1
      src/test/frontend/handler/repo_conversion_test.cljs
  95. 1 8
      src/test/frontend/handler/repo_test.cljs
  96. 58 1
      src/test/frontend/modules/outliner/core_test.cljs
  97. 1 8
      src/test/frontend/modules/outliner/pipeline_test.cljs
  98. 13 15
      src/test/frontend/test/helper.cljs
  99. 17 0
      src/test/frontend/test/node_helper.cljs
  100. 40 0
      src/test/logseq/api_test.cljs

+ 2 - 0
.carve/ignore

@@ -82,3 +82,5 @@ logseq.graph-parser.nbb-test-runner/run-tests
 ;; For debugging
 frontend.fs.sync/debug-print-sync-events-loop
 frontend.fs.sync/stop-debug-print-sync-events-loop
+;; Used in macro
+frontend.state/get-current-edit-block-and-position

+ 5 - 0
.gitignore

@@ -57,3 +57,8 @@ android/app/src/main/assets/capacitor.config.json
 /public/static
 .yarn/
 .yarnrc.yml
+
+deps/shui/.lsp
+deps/shui/.lsp-cache
+deps/shui/.clj-kondo
+deps/shui/shui-graph/logseq/bak

+ 2 - 2
android/app/build.gradle

@@ -6,8 +6,8 @@ android {
         applicationId "com.logseq.app"
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 57
-        versionName "0.9.4"
+        versionCode 59
+        versionName "0.9.6"
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
         aaptOptions {
              // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

+ 7 - 2
android/app/src/main/java/com/logseq/app/FsWatcher.java

@@ -10,6 +10,8 @@ import android.net.Uri;
 
 import java.io.*;
 
+import java.net.URI;
+import java.text.Normalizer;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Stack;
@@ -90,8 +92,11 @@ public class FsWatcher extends Plugin {
             shouldRead = true;
         }
 
-        obj.put("path", Uri.fromFile(f));
-        obj.put("dir", Uri.fromFile(new File(mPath)));
+        URI dir = (new File(mPath)).toURI();
+        URI fpath = f.toURI();
+
+        obj.put("path", Normalizer.normalize(dir.relativize(fpath).toString(), Normalizer.Form.NFC));
+        obj.put("dir", Uri.fromFile(new File(mPath))); // Uri is for Android. URI is for RFC compatible
         JSObject stat;
 
         switch (event) {

+ 3 - 2
deps.edn

@@ -27,11 +27,12 @@
   camel-snake-kebab/camel-snake-kebab   {:mvn/version "0.4.2"}
   instaparse/instaparse                 {:mvn/version "1.4.10"}
   org.clojars.mmb90/cljs-cache          {:mvn/version "0.1.4"}
+  fipp/fipp                             {:mvn/version "0.6.26"}
   logseq/common                         {:local/root "deps/common"}
   logseq/graph-parser                   {:local/root "deps/graph-parser"}
   logseq/publishing                     {:local/root "deps/publishing"}
-  metosin/malli                         {:mvn/version "0.10.0"}
-  fipp/fipp                             {:mvn/version "0.6.26"}}
+  logseq/shui                           {:local/root "deps/shui"}
+  metosin/malli                         {:mvn/version "0.10.0"}}
 
  :aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"]
                   :extra-deps  {org.clojure/clojurescript        {:mvn/version "1.11.54"}

+ 5 - 5
deps/common/src/logseq/common/path.cljs

@@ -6,7 +6,7 @@
 (defn- safe-decode-uri-component
   [uri]
   (try
-    (js/decodeURIComponent uri)
+    (.normalize (js/decodeURIComponent uri) "NFC")
     (catch :default _
       (js/console.error "decode-uri-component-failed" uri)
       uri)))
@@ -157,7 +157,6 @@
 (defn path-join
   "Join path segments, or URL base and path segments"
   [base & segments]
-
   (cond
     ;; For debugging
     ; (nil? base)
@@ -190,9 +189,10 @@
 (defn path-normalize
   "Normalize path or URL"
   [path]
-  (if (is-file-url? path)
-    (url-normalize path)
-    (path-normalize-internal path)))
+  (-> (if (is-file-url? path)
+        (url-normalize path)
+        (path-normalize-internal path))
+      (.normalize "NFC")))
 
 (defn url-to-path
   "Extract path part of a URL, decoded.

+ 0 - 1
deps/db/src/logseq/db/schema.cljs

@@ -105,7 +105,6 @@
 (def retract-attributes
   #{
     :block/refs
-    :block/path-refs
     :block/tags
     :block/alias
     :block/marker

+ 4 - 0
deps/graph-parser/.carve/ignore

@@ -46,3 +46,7 @@ logseq.graph-parser.text/get-file-basename
 logseq.graph-parser.mldoc/mldoc-link?
 ;; public var
 logseq.graph-parser.schema.mldoc/block-ast-coll-schema
+;; API
+logseq.graph-parser.config/img-formats
+;; API
+logseq.graph-parser.config/text-formats

+ 0 - 1
deps/graph-parser/src/logseq/graph_parser.cljs

@@ -89,7 +89,6 @@ Options available:
          {:keys [tx ast]}
          (let [extract-options' (merge {:block-pattern (gp-config/get-block-pattern format)
                                         :date-formatter "MMM do, yyyy"
-                                        :supported-formats (gp-config/supported-formats)
                                         :uri-encoded? false
                                         :filename-format :legacy}
                                        extract-options

+ 18 - 26
deps/graph-parser/src/logseq/graph_parser/block.cljs

@@ -35,40 +35,32 @@
          (string/join))))
 
 (defn- get-page-reference
-  [block supported-formats]
+  [block format]
   (let [page (cond
                (and (vector? block) (= "Link" (first block)))
-               (let [typ (first (:url (second block)))
+               (let [url-type (first (:url (second block)))
                      value (second (:url (second block)))]
                  ;; {:url ["File" "file:../pages/hello_world.org"], :label [["Plain" "hello world"]], :title nil}
                  (or
                   (and
-                   (= typ "Page_ref")
+                   (= url-type "Page_ref")
                    (and (string? value)
                         (not (or (gp-config/local-asset? value)
                                  (gp-config/draw? value))))
                    value)
 
                   (and
-                   (= typ "Search")
+                   (= url-type "Search")
                    (page-ref/page-ref? value)
                    (text/page-ref-un-brackets! value))
 
-                  (and
-                   (= typ "Search")
-                   (not (contains? #{\# \* \/ \[} (first value)))
-                   ;; FIXME: use `gp-util/get-format` instead
-                   (let [ext (some-> (gp-util/get-file-ext value) keyword)]
-                     (when (and (not (string/starts-with? value "http:"))
-                                (not (string/starts-with? value "https:"))
-                                (not (string/starts-with? value "file:"))
-                                (not (gp-config/local-asset? value))
-                                (or (#{:excalidraw :tldr} ext)
-                                    (not (contains? supported-formats ext))))
-                       value)))
+                  (and (= url-type "Search")
+                       (= format :org)
+                       (not (gp-config/local-asset? value))
+                       value)
 
                   (and
-                   (= typ "File")
+                   (= url-type "File")
                    (second (first (:label (second block)))))))
 
                (and (vector? block) (= "Nested_link" (first block)))
@@ -329,7 +321,7 @@
     nil))
 
 (defn- with-page-refs
-  [{:keys [title body tags refs marker priority] :as block} with-id? supported-formats db date-formatter]
+  [{:keys [title body tags refs marker priority] :as block} with-id? db date-formatter]
   (let [refs (->> (concat tags refs [marker priority])
                   (remove string/blank?)
                   (distinct))
@@ -340,7 +332,7 @@
        (when-not (and (vector? form)
                       (= (first form) "Custom")
                       (= (second form) "query"))
-         (when-let [page (get-page-reference form supported-formats)]
+         (when-let [page (get-page-reference form (:format block))]
            (swap! *refs conj page))
          (when-let [tag (get-tag form)]
            (let [tag (text/page-ref-un-brackets! tag)]
@@ -431,9 +423,9 @@
     (map (fn [page] (page-name->map page true db true date-formatter)) page-refs)))
 
 (defn- with-page-block-refs
-  [block with-id? supported-formats db date-formatter]
+  [block with-id? db date-formatter]
   (some-> block
-          (with-page-refs with-id? supported-formats db date-formatter)
+          (with-page-refs with-id? db date-formatter)
           with-block-refs
           block-tags->pages
           (update :refs (fn [col] (remove nil? col)))))
@@ -507,7 +499,7 @@
     (mapv macro->block @*result)))
 
 (defn with-pre-block-if-exists
-  [blocks body pre-block-properties encoded-content {:keys [supported-formats db date-formatter user-config]}]
+  [blocks body pre-block-properties encoded-content {:keys [db date-formatter user-config]}]
   (let [first-block (first blocks)
         first-block-start-pos (get-in first-block [:block/meta :start_pos])
 
@@ -539,7 +531,7 @@
                                 :block/macros (extract-macros-from-ast body)
                                 :block/body body}
                          {:keys [tags refs]}
-                         (with-page-block-refs {:body body :refs property-refs} false supported-formats db date-formatter)]
+                         (with-page-block-refs {:body body :refs property-refs} false db date-formatter)]
                      (cond-> block
                              tags
                              (assoc :block/tags tags)
@@ -557,7 +549,7 @@
     properties))
 
 (defn- construct-block
-  [block properties timestamps body encoded-content format pos-meta with-id? {:keys [block-pattern supported-formats db date-formatter]}]
+  [block properties timestamps body encoded-content format pos-meta with-id? {:keys [block-pattern db date-formatter]}]
   (let [id (get-custom-id-or-new-id properties)
         ref-pages-in-properties (->> (:page-refs properties)
                                      (remove string/blank?))
@@ -594,7 +586,7 @@
                 (merge block (timestamps->scheduled-and-deadline timestamps))
                 block)
         block (assoc block :body body)
-        block (with-page-block-refs block with-id? supported-formats db date-formatter)
+        block (with-page-block-refs block with-id? db date-formatter)
         block (update block :refs concat (:block-refs properties))
         {:keys [created-at updated-at]} (:properties properties)
         block (cond-> block
@@ -648,7 +640,7 @@
     `content`: markdown or org-mode text.
     `with-id?`: If `with-id?` equals to true, all the referenced pages will have new db ids.
     `format`: content's format, it could be either :markdown or :org-mode.
-    `options`: Options supported are :user-config, :block-pattern :supported-formats,
+    `options`: Options supported are :user-config, :block-pattern,
                :extract-macros, :date-formatter, :page-name and :db"
   [blocks content with-id? format {:keys [user-config] :as options}]
   {:pre [(seq blocks) (string? content) (boolean? with-id?) (contains? #{:markdown :org} format)]}

+ 0 - 1
deps/graph-parser/src/logseq/graph_parser/cli.cljs

@@ -48,7 +48,6 @@ TODO: Fail fast when process exits 1"
   [conn files {:keys [config] :as options}]
   (let [extract-options (merge {:date-formatter (gp-config/get-date-formatter config)
                                 :user-config config
-                                :supported-formats (gp-config/supported-formats)
                                 :filename-format (or (:file/name-format config) :legacy)
                                 :extracted-block-ids (atom #{})}
                                (select-keys options [:verbose]))]

+ 1 - 7
deps/graph-parser/src/logseq/graph_parser/config.cljs

@@ -1,7 +1,6 @@
 (ns logseq.graph-parser.config
   "App config that is shared between graph-parser and rest of app"
-  (:require [clojure.set :as set]
-            [clojure.string :as string]
+  (:require [clojure.string :as string]
             [goog.object :as gobj]))
 
 (def app-name
@@ -75,11 +74,6 @@
   []
   #{:gif :svg :jpeg :ico :png :jpg :bmp :webp})
 
-(defn supported-formats
-  []
-  (set/union (text-formats)
-             (img-formats)))
-
 (defn get-date-formatter
   [config]
   (or

+ 6 - 1
deps/graph-parser/src/logseq/graph_parser/property.cljs

@@ -51,7 +51,12 @@
   "Properties used by logseq that user can edit"
   []
   (into #{:title :icon :template :template-including-parent :public :filters :exclude-from-graph-view
-          :logseq.query/nlp-date
+          :logseq.query/nlp-date 
+          ;; view props 
+          :logseq.color
+          ;; table props
+          :logseq.table.version :logseq.table.compact :logseq.table.headers :logseq.table.hover 
+          :logseq.table.borders :logseq.table.stripes :logseq.table.max-width
           ;; org-mode only
           :macro :filetags}
         editable-linkable-built-in-properties))

+ 39 - 15
deps/graph-parser/test/logseq/graph_parser_test.cljs

@@ -117,7 +117,7 @@
                          :where
                          [?b :block/name ?name]
                          [?b :block/type "whiteboard"]]
-                    @conn)]
+                       @conn)]
         (is (= pages #{["foo"] ["bar"]}))))))
 
 (defn- test-property-order [num-properties]
@@ -153,11 +153,11 @@
                                    "- desc:: \"#foo is not a ref\""
                                    {:extract-options {:user-config {}}})
         block (->> (d/q '[:find (pull ?b [* {:block/refs [*]}])
-                       :in $
-                       :where [?b :block/properties]]
-                     @conn)
-                (map first)
-                first)]
+                          :in $
+                          :where [?b :block/properties]]
+                        @conn)
+                   (map first)
+                   first)]
     (is (= {:desc "\"#foo is not a ref\""}
            (:block/properties block))
         "Quoted value is unparsed")
@@ -274,9 +274,9 @@
                    set)
               (set refs))
            ; pre-block/page has expected refs
-           db-properties (first (map :block/refs blocks))
+        db-properties (first (map :block/refs blocks))
            ;; block has expected refs
-           block-db-properties (second (map :block/refs blocks))))))
+        block-db-properties (second (map :block/refs blocks))))))
 
 (deftest property-relationships
   (let [properties {:single-link "[[bar]]"
@@ -324,7 +324,7 @@
                 (map #(select-keys % [:block/properties :block/invalid-properties]))))
         "Has correct (in)valid page properties")))
 
-(deftest correct-page-names-created
+(deftest correct-page-names-created-from-title
   (testing "from title"
     (let [conn (ldb/start-conn)
           built-in-pages (set (map string/lower-case default-db/built-in-pages-names))]
@@ -358,17 +358,41 @@
                        @conn)
                   (map (comp :block/original-name first))
                   (remove built-in-pages)
-                  set)))))
+                  set))))))
 
-  (testing "for file and web uris"
+(deftest correct-page-names-created-from-page-refs
+  (testing "for file, mailto, web and other uris in markdown"
     (let [conn (ldb/start-conn)
           built-in-pages (set (map string/lower-case default-db/built-in-pages-names))]
       (graph-parser/parse-file conn
                                "foo.md"
-                               (str "- [Filename.txt](file:///E:/test/Filename.txt)\n"
-                                    "- [example](https://example.com)")
-                               {})
-      (is (= #{"foo"}
+                               (str "- [title]([[bar]])\n"
+                                    ;; all of the uris below do not create pages
+                                    "- ![image.png](../assets/image_1630480711363_0.png)\n"
+                                    "- [Filename.txt](file:///E:/test/Filename.txt)\n"
+                                    "- [mail](mailto:[email protected]?subject=TestSubject)\n"
+                                    "- [onenote link](onenote:https://d.docs.live.net/b2127346582e6386a/blablabla/blablabla/blablabla%20blablabla.one#Etat%202019&section-id={133DDF16-9A1F-4815-9A05-44303784442E6F94}&page-id={3AAB677F0B-328F-41D0-AFF5-66408819C085}&end)\n"
+                                    "- [lock file](deps/graph-parser/yarn.lock)"
+                                    "- [example](https://example.com)"))
+      (is (= #{"foo" "bar"}
+             (->> (d/q '[:find (pull ?b [*])
+                         :in $
+                         :where [?b :block/name]]
+                       @conn)
+                  (map (comp :block/name first))
+                  (remove built-in-pages)
+                  set)))))
+
+(testing "for web and page uris in org"
+    (let [conn (ldb/start-conn)
+          built-in-pages (set (map string/lower-case default-db/built-in-pages-names))]
+      (graph-parser/parse-file conn
+                               "foo.org"
+                               (str "* [[bar][title]]\n"
+                                    ;; all of the uris below do not create pages
+                                    "* [[https://example.com][example]]\n"
+                                    "* [[../assets/conga_parrot.gif][conga]]"))
+      (is (= #{"foo" "bar"}
              (->> (d/q '[:find (pull ?b [*])
                          :in $
                          :where [?b :block/name]]

+ 17 - 0
deps/shui/.clj-kondo/config.edn

@@ -0,0 +1,17 @@
+{ :config-in-ns
+ ;; :used-underscored-binding is turned off for components because of false positive
+ ;; for rum/defcs and _state.
+ {all-components {:linters {:used-underscored-binding {:level :off}}}}
+
+ :linters 
+ {;; Disable until it doesn't trigger false positives on rum/defcontext
+  :earmuffed-var-not-dynamic {:level :off}}
+ :hooks {:analyze-call {rum.core/defc hooks.rum/defc
+                        rum.core/defcs hooks.rum/defcs
+                        clojure.string/join hooks.path-invalid-construct/string-join}}
+ :lint-as {rum.core/defcc rum.core/defc
+           rum.core/with-context clojure.core/let
+           rum.core/defcontext clojure.core/def
+           rum.core/defc clojure.core/defn
+           rum.core/defcs clojure.core/defn
+           frontend.react/defc clojure.core/defn}}

+ 29 - 0
deps/shui/README.md

@@ -0,0 +1,29 @@
+## Description
+
+This library provides a set of UI components for use within logseq.
+
+## API
+
+This library is under the parent namespace `logseq.shui`. This library provides 
+several namespaces, all of which will be versioned, with the exception of `logseq.shui.context`.
+
+An example of a versioned namespace is the table namespace:
+
+`logseq.shui.table.v2`
+
+`root` components are exported from each versioned file to indicate the root component to be rendered:
+
+`logseq.shui.table.v2/root`
+
+Each root component should expect two arguments, `props` and `context`. 
+
+## `props`
+
+Ultimately, components in shui will need to be used by JavaScript. While it is idiomatic in clojure to
+use a list of properties, it is idiomatic in react to use a single props map. Shui components should therefore 
+stick to this convention when possible to ease the conversion between the two languages. 
+
+## `context` 
+
+Context is a set of functions that call back to the main application. These are abstracted out into a context 
+object to make it clear what is used internally, and what is used externally.

+ 1 - 0
deps/shui/deps.edn

@@ -0,0 +1 @@
+{:paths ["src"]}

+ 2 - 0
deps/shui/shui-graph/journals/2023_03_27.md

@@ -0,0 +1,2 @@
+-
+-

+ 348 - 0
deps/shui/shui-graph/logseq/config.edn

@@ -0,0 +1,348 @@
+{:meta/version 1
+
+ ;; Currently, we support either "Markdown" or "Org".
+ ;; This can overwrite your global preference so that
+ ;; maybe your personal preferred format is Org but you'd
+ ;; need to use Markdown for some projects.
+ ;; :preferred-format ""
+
+ ;; Preferred workflow style.
+ ;; Value is either ":now" for NOW/LATER style,
+ ;; or ":todo" for TODO/DOING style.
+ :preferred-workflow :now
+
+ ;; The app will ignore those directories or files.
+ ;; E.g. :hidden ["/archived" "/test.md" "../assets/archived"]
+ :hidden []
+
+ ;; When creating the new journal page, the app will use your template if there is one.
+ ;; You only need to input your template name here.
+ :default-templates
+ {:journals ""}
+
+ ;; Set a custom date format for journal page title
+ ;; Example:
+ ;; :journal/page-title-format "EEE, do MMM yyyy"
+
+ ;; Whether to enable hover on tooltip preview feature
+ ;; Default is true, you can also toggle this via setting page
+ :ui/enable-tooltip? true
+
+ ;; Show brackets around page references
+ ;; :ui/show-brackets? true
+
+ ;; Enable showing the body of blocks when referencing them.
+ :ui/show-full-blocks? false
+
+ ;; Enable Block timestamp
+ :feature/enable-block-timestamps? false
+
+ ;; Enable remove accents when searching.
+ ;; After toggle this option, please remember to rebuild your search index by press (cmd+c cmd+s).
+ :feature/enable-search-remove-accents? true
+
+ ;; Enable journals
+ ;; :feature/enable-journals? true
+
+ ;; Enable flashcards
+ ;; :feature/enable-flashcards? true
+
+ ;; Enable Whiteboards
+ ;; :feature/enable-whiteboards? true
+
+ ;; Disable the built-in Scheduled tasks and deadlines query
+ ;; :feature/disable-scheduled-and-deadline-query? true
+
+ ;; Specify the number of days in the future to display in the
+ ;; scheduled tasks and deadlines query, with a default value of 0 which
+ ;; only displays tasks for today.
+ ;; Example usage:
+ ;; Display all scheduled tasks and deadlines in the next 7 days
+ ;; :scheduled/future-days 7
+
+ ;; Specify the date on which the week starts.
+ ;; Goes from 0 to 6 (Monday to Sunday), default to 6
+ :start-of-week 6
+
+ ;; Specify a custom CSS import
+ ;; This option take precedence over your local `logseq/custom.css` file
+ ;; You may find a list of awesome logseq themes here:
+ ;; https://github.com/logseq/awesome-logseq#css-themes
+ ;; Example:
+ ;; :custom-css-url "@import url('https://cdn.jsdelivr.net/gh/dracula/logseq@master/custom.css');"
+
+ ;; Specify a custom js import
+ ;; This option take precedence over your local `logseq/custom.js` file
+ ;; :custom-js-url ""
+
+ ;; Set a custom Arweave gateway
+ ;; Default gateway: https://arweave.net
+ ;; :arweave/gateway ""
+
+ ;; Set Bullet indentation when exporting
+ ;; default option: tab
+ ;; Possible options are for `:sidebar` are
+ ;; 1. `:eight-spaces` as eight spaces
+ ;; 2. `:four-spaces` as four spaces
+ ;; 3. `:two-spaces` as two spaces
+ ;; :export/bullet-indentation :tab
+
+ ;; When :all-pages-public? true, export repo would export all pages within that repo.
+ ;; Regardless of whether you've set any page to public or not.
+ ;; Example:
+ ;; :publishing/all-pages-public? true
+
+ ;; Specify default home page and sidebar status for Logseq
+ ;; If not specified, Logseq default opens journals page on startup
+ ;; value for `:page` is name of page
+ ;; Possible options for `:sidebar` are
+ ;; 1. `"Contents"` to open up `Contents` in sidebar by default
+ ;; 2. `page name` to open up some page in sidebar
+ ;; 3. Or multiple pages in an array ["Contents" "Page A" "Page B"]
+ ;; If `:sidebar` is not set, sidebar will be hidden
+ ;; Example:
+ ;; 1. Setup page "Changelog" as home page and "Contents" in sidebar
+ ;; :default-home {:page "Changelog", :sidebar "Contents"}
+ ;; 2. Setup page "Jun 3rd, 2021" as home page without sidebar
+ ;; :default-home {:page "Jun 3rd, 2021"}
+ ;; 3. Setup page "home" as home page with multiple pages in sidebar
+ ;; :default-home {:page "home" :sidebar ["page a" "page b"]}
+ :default-home {:page "Contents"}
+
+ ;; Tell logseq to use a specific folder in the repo as a default location for notes
+ ;; if not specified, notes are stored in `pages` directory
+ ;; :pages-directory "your-directory"
+
+ ;; Tell logseq to use a specific folder in the repo as a default location for journals
+ ;; if not specified, journals are stored in `journals` directory
+ ;; :journals-directory "your-directory"
+
+ ;; Set this to true will convert
+ ;; `[[Grant Ideas]]` to `[[file:./grant_ideas.org][Grant Ideas]]` for org-mode
+ ;; For more, see https://github.com/logseq/logseq/issues/672
+ ;; :org-mode/insert-file-link? true
+
+ ;; Setup custom shortcuts under `:shortcuts` key
+ ;; Syntax:
+ ;; 1. `+` means keys pressing simultaneously. eg: `ctrl+shift+a`
+ ;; 2. ` ` empty space between keys represents key chords. eg: `t s` means press `t` followed by `s`
+ ;; 3. `mod` means `Ctrl` for Windows/Linux  and `Command` for Mac
+ ;; 4. use `false` to disable particular shortcut
+ ;; 5. you can define multiple bindings for one action, eg `["ctrl+j" "down"]`
+ ;; full list of configurable shortcuts are available below:
+ ;; https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/config.cljs
+ ;; Example:
+ ;; :shortcuts
+ ;; {:editor/new-block       "enter"
+ ;;  :editor/new-line        "shift+enter"
+ ;;  :editor/insert-link     "mod+shift+k"
+ ;;  :editor/highlight       false
+ ;;  :ui/toggle-settings     "t s"
+ ;;  :editor/up              ["ctrl+k" "up"]
+ ;;  :editor/down            ["ctrl+j" "down"]
+ ;;  :editor/left            ["ctrl+h" "left"]
+ ;;  :editor/right           ["ctrl+l" "right"]}
+ :shortcuts {}
+
+ ;; By default, pressing `Enter` in the document mode will create a new line.
+ ;; Set this to `true` so that it's the same behaviour as the usual outliner mode.
+ :shortcut/doc-mode-enter-for-new-block? false
+
+ ;; Block content larger than `block/content-max-length` will not be searchable
+ ;; or editable for performance.
+ :block/content-max-length 10000
+
+ ;; Whether to show command doc on hover
+ :ui/show-command-doc? true
+
+ ;; Whether to show empty bullets for non-document mode (the default mode)
+ :ui/show-empty-bullets? false
+
+ ;; Pre-defined :view function to use with advanced queries
+ :query/views
+ {:pprint
+  (fn [r] [:pre.code (pprint r)])}
+
+ ;; Pre-defined :result-transform function for use with advanced queries
+ :query/result-transforms
+ {:sort-by-priority
+  (fn [result] (sort-by (fn [h] (get h :block/priority "Z")) result))}
+
+ ;; The app will show those queries in today's journal page,
+ ;; the "NOW" query asks the tasks which need to be finished "now",
+ ;; the "NEXT" query asks the future tasks.
+ :default-queries
+ {:journals
+  [{:title "🔨 NOW"
+    :query [:find (pull ?h [*])
+            :in $ ?start ?today
+            :where
+            [?h :block/marker ?marker]
+            [(contains? #{"NOW" "DOING"} ?marker)]
+            [?h :block/page ?p]
+            [?p :block/journal? true]
+            [?p :block/journal-day ?d]
+            [(>= ?d ?start)]
+            [(<= ?d ?today)]]
+    :inputs [:14d :today]
+    :result-transform (fn [result]
+                        (sort-by (fn [h]
+                                   (get h :block/priority "Z")) result))
+    :collapsed? false}
+   {:title "📅 NEXT"
+    :query [:find (pull ?h [*])
+            :in $ ?start ?next
+            :where
+            [?h :block/marker ?marker]
+            [(contains? #{"NOW" "LATER" "TODO"} ?marker)]
+            [?h :block/page ?p]
+            [?p :block/journal? true]
+            [?p :block/journal-day ?d]
+            [(> ?d ?start)]
+            [(< ?d ?next)]]
+    :inputs [:today :7d-after]
+    :collapsed? false}]}
+
+ ;; Add your own commands to slash menu to speedup.
+ ;; E.g.
+ ;; :commands
+ ;; [
+ ;; ["js" "Javascript"]
+ ;; ["md" "Markdown"]
+ ;; ]
+ :commands
+ []
+
+ ;; By default, a block can only be collapsed if it has some children.
+ ;; `:outliner/block-title-collapse-enabled? true` enables a block with a title
+ ;; (multiple lines) can be collapsed too. For example:
+ ;; - block title
+ ;;   block content
+ :outliner/block-title-collapse-enabled? false
+
+ ;; Macros replace texts and will make you more productive.
+ ;; For example:
+ ;; Change the :macros value below to:
+ ;; {"poem" "Rose is $1, violet's $2. Life's ordered: Org assists you."}
+ ;; input "{{poem red,blue}}"
+ ;; becomes
+ ;; Rose is red, violet's blue. Life's ordered: Org assists you.
+ :macros {}
+
+ ;; The default level to be opened for the linked references.
+ ;; For example, if we have some example blocks like this:
+ ;; - a [[page]] (level 1)
+ ;;   - b        (level 2)
+ ;;     - c      (level 3)
+ ;;       - d    (level 4)
+ ;;
+ ;; With the default value of level 2, `b` will be collapsed.
+ ;; If we set the level's value to 3, `b` will be opened and `c` will be collapsed.
+ :ref/default-open-blocks-level 2
+
+ :ref/linked-references-collapsed-threshold 50
+
+ ;; Favorites to list on the left sidebar
+ :favorites []
+
+ ;; any number between 0 and 1 (the greater it is the faster the changes of the next-interval of card reviews) (default 0.5)
+ ;; :srs/learning-fraction 0.5
+
+ ;; the initial interval after the first successful review of a card (default 4)
+ ;; :srs/initial-interval 4
+
+ ;; hide specific properties for blocks
+ ;; E.g. :block-hidden-properties #{:created-at :updated-at}
+ ;; :block-hidden-properties #{}
+
+ ;; Enable all your properties to have corresponding pages
+ :property-pages/enabled? true
+
+ ;; Properties to exclude from having property pages
+ ;; E.g.:property-pages/excludelist #{:duration :author}
+ ;; :property-pages/excludelist
+
+ ;; By default, property value separated by commas will not be treated as
+ ;; page references. You can add properties to enable it.
+ ;; E.g. :property/separated-by-commas #{:alias :tags}
+ ;; :property/separated-by-commas #{}
+
+ ;; Properties that are ignored when parsing property values for references
+ ;; :ignored-page-references-keywords #{"author" "startup"}
+
+ ;; logbook setup
+ ;; :logbook/settings
+ ;; {:with-second-support? false ;limit logbook to minutes, seconds will be eliminated
+ ;;  :enabled-in-all-blocks true ;display logbook in all blocks after timetracking
+ ;;  :enabled-in-timestamped-blocks false ;don't display logbook at all
+ ;; }
+
+ ;; Mobile photo uploading setup
+ ;; :mobile/photo
+ ;; {:allow-editing? true
+ ;;  :quality          80}
+
+ ;; Mobile features options
+ ;; Gestures
+ ;; :mobile
+ ;; {:gestures/disabled-in-block-with-tags ["kanban"]}
+
+ ;; Extra CodeMirror options
+ ;; See https://codemirror.net/5/doc/manual.html#config for possible options
+ ;; :editor/extra-codemirror-options {:keyMap "emacs" :lineWrapping true}
+
+ ;; Enable logical outdenting
+ ;; :editor/logical-outdenting? true
+
+ ;; When both text and a file are in the clipboard, paste the file
+ ;; :editor/preferred-pasting-file? true
+
+ ;; Quick capture templates for receiving contents from other apps.
+ ;; Each template contains three elements {time}, {text} and {url}, which can be auto-expanded
+ ;; by received contents from other apps. Note: the {} cannot be omitted.
+ ;; - {time}: capture time
+ ;; - {date}: capture date using current date format, use `[[{date}]]` to get a page reference
+ ;; - {text}: text that users selected before sharing.
+ ;; - {url}: url or assets path for media files stored in Logseq.
+ ;; You can also reorder them, or even only use one or two of them in the template.
+ ;; You can also insert or format any text in the template as shown in the following examples.
+ ;; :quick-capture-templates
+ ;; {:text "[[quick capture]] **{time}**: {text} from {url}"
+ ;;  :media "[[quick capture]] **{time}**: {url}"}
+
+ ;; Quick capture options
+ ;; :quick-capture-options {:insert-today? false :redirect-page? false :default-page nil}
+
+ ;; File sync options
+ ;; Ignore these files when syncing, regexp is supported.
+ ;; :file-sync/ignore-files []
+
+ ;; dwim (do what I mean) for Enter key when editing.
+ ;; Context-awareness of Enter key makes editing more easily
+ ; :dwim/settings {
+ ;   :admonition&src?  true
+ ;   :markup?          false
+ ;   :block-ref?       true
+ ;   :page-ref?        true
+ ;   :properties?      true
+ ;   :list?            true
+ ; }
+
+ ;; Decide the way to escape the special characters in the page title.
+ ;; Warning:
+ ;;   This is a dangerous operation. If you want to change the setting,
+ ;;   should access the setting `Filename format` and follow the instructions.
+ ;;   Or you have to rename all the affected files manually then re-index on all
+ ;;   clients after the files are synced. Wrong handling may cause page titles
+ ;;   containing special characters to be messy.
+ ;; Available values:
+ ;;   :file/name-format :triple-lowbar
+ ;;     ;use triple underscore `___` for slash `/` in page title
+ ;;     ;use Percent-encoding for other invalid characters
+ :file/name-format :triple-lowbar
+ :feature/enable-whiteboards? true}
+
+ ;; specify the format of the filename for journal files
+ ;; :journal/file-name-format "yyyy_MM_dd"
+
+ 

+ 0 - 0
deps/shui/shui-graph/logseq/custom.css


+ 22 - 0
deps/shui/shui-graph/pages/About Shui.md

@@ -0,0 +1,22 @@
+- ## What is shui?
+- Shui is the component library for logseq. It has 3 main goals:
+	- 1. Provide an abstraction for specific components, separate from the main codebase
+	  2. Provide a consistent look and feel for the future of logseq
+	  3. Provide ready to use components to plugin authors to allow for a more consistent better user experience of plugin authors and users
+-
+- ## What are the general concepts of shui?
+- Shui has a few core principles:
+	- ### Focus on a native core experience
+		- We want to provide a smooth, consistent, and native feel for all logseq features, first and foremost
+	- ### Specific output, general input
+		- Components should be generally reusable by their props, however should have the user experience themselves
+		- Eventually, getting to a highly composable components is a great goal, but we should start small and focused first
+	- ### UI is a marathon, not a sprint
+		- Components in shui should be versioned, and should expect to evolve over time
+		- We need to go from highly coupled, low reused components to a loosely coupled, highly reusable library. This will take time, and means components have to be adaptable over time
+		- Versioning is at the core of shui
+-
+- ## How to contribute to shui?
+- In the logseq repo, there is a directory at `deps/shui`. Here you can find all of the shui components
+- In the logseq repo, you can find a copy of this graph at `deps/shui/shui-graph`. Here you can find and add all the test cases needed for different `shui` components
+- In the logseq repo, you can find tests under the `e2e-tests/shui`. To keep our infra streamlined, `shui` is bundled with and tested with the current CI for logseq

+ 3 - 0
deps/shui/shui-graph/pages/Page 1.md

@@ -0,0 +1,3 @@
+table-example:: true
+
+-

+ 1 - 0
deps/shui/shui-graph/pages/Page 2.md

@@ -0,0 +1 @@
+table-example:: true

+ 1 - 0
deps/shui/shui-graph/pages/Page 3.md

@@ -0,0 +1 @@
+table-example:: true

+ 4 - 0
deps/shui/shui-graph/pages/contents.md

@@ -0,0 +1,4 @@
+- [[About Shui]]
+- [[shui/components]]
+	- [[shui/components/table]]
+	-

+ 4 - 0
deps/shui/shui-graph/pages/shui___components.md

@@ -0,0 +1,4 @@
+- Below is a list of components that can be found in the shui library
+- [[shui/components/table]]
+	- The table component is used to render tabular data.
+-

+ 62 - 0
deps/shui/shui-graph/pages/shui___components___table.md

@@ -0,0 +1,62 @@
+- ### Props
+	- logseq.table.version:: 2
+	  logseq.table.hover:: row
+	  logseq.table.stripes:: true
+	  logseq.table.borders:: false
+	  | Prop Name | Description | Values |
+	  | --- | --- | --- |
+	  | `logseq.table.version` | The version of the table | 1, 2 |
+	  | `logseq.table.hover` | The hover effect of the table | cell (default), row, col, both, none |
+	  | `logseq.table.compact` | Whether to show a compact version of the data | false (default), true |
+	  | `logseq.table.headers` | The casing that should be applied to the header cols | none (default), uppercase, capitalize, capitalize-first, lowercase |
+	  | `logseq.table.borders` | Whether or not the table should have borders between all cells and rows | true (default), false |
+	  | `logseq.table.stripes` | Whether or not the table should have alternately colored table rows | false (default), true |
+	  | `logseq.table.max-width` | The maximum width (in rems) that should be applied to each column | <any number> (default 30) |
+	  | `logseq.color` | The color accent of the table | red, orange, yellow, green, blue, purple |
+- ### Examples
+	- #### Simplest possible markdown table
+	  collapsed:: true
+		- logseq.table.version:: 1
+		  | Fruit | Color |
+		  | Apples | Red |
+		  | Bananas | Yellow |
+	- #### Longer more complicated markdown table, with various widths and input types
+	  collapsed:: true
+		- logseq.table.version:: 2
+		  | Length | Text | EN | ZH |
+		  | --- | --- | --- | --- |
+		  | 70 | Logseq is a new note-taking app that has been making waves in the productivity community. | x |  |
+		  | 138 | With its unique approach to linking and organizing information, Logseq allows users to create a highly interconnected and personalized knowledge base. | x |  |
+		  | 194 | Unlike traditional note-taking apps, Logseq encourages users to embrace the power of plain text and markdown formatting, enabling them to easily manipulate and query their notes. | x |  |
+		  | 246 | From students to researchers, Logseq's flexible and intuitive interface makes it an ideal tool for anyone looking to optimize their note-taking and knowledge management workflow. | x |  |
+		  | 312 | Whether you're looking to organize your thoughts, collaborate with others, or simply streamline your note-taking process, Logseq offers a revolutionary approach that is sure to revolutionize the way you work. | x |  |
+		  | 35 | Logseq 是一款在生产力社群中备受瞩目的新型笔记应用。|  | x |
+		  | 59 | Logseq 以其独特的链接和组织信息方式,使用户能够创建高度互联且个性化的知识库。 |  | x | 86 | 不同于传统笔记应用,Logseq 鼓励用户采用纯文本和 Markdown 格式,使其能够轻松地操作和查询笔记。 |  | x |
+		  | 123 | 从学生到研究人员,Logseq 灵活直观的界面使其成为任何想要优化笔记和知识管理工作流程的人的理想工具。|  | x |
+		  | 152 | 无论您是想整理自己的思路、与他人合作,还是简化笔记流程,Logseq 提供的革命性方法肯定会改变您的工作方式。|  | x |
+	- #### Query table for blocks
+		- logseq.table.version:: 2
+		  query-table:: true
+		  query-properties:: [:block]
+		  logseq.table.borders:: false
+		  {{query #table-example/block}}
+		-
+		- #### data
+			- Block 1 #table-example/block
+			  table-example:: true
+			- Block 2 #table-example/block
+			  table-example:: true
+			- Block 3 #table-example/block
+			  table-example:: true
+	- #### Query table for pages
+		- {{query (page-property "table-example" "true")}}
+		  logseq.table.version:: 2
+		- [[Page 1]]
+		- [[Page 2]]
+		- [[Page 3]]
+	- #### Query table for mixed pages and blocks
+		- {{query (property "table-example" true)}}
+		  query-table:: true
+		  logseq.table.version:: 2
+	-
+- {{query }}

+ 36 - 0
deps/shui/src/logseq/shui/context.cljs

@@ -0,0 +1,36 @@
+(ns logseq.shui.context)
+
+(defn inline->inline-block [inline block-config]
+  (fn [_context item]
+    (inline block-config item)))
+
+(defn inline->map-inline-block [inline block-config]
+  (let [inline* (inline->inline-block inline block-config)]
+    (fn [context col]
+      (map #(inline* context %) col))))
+
+(defn make-context [{:keys [block-config app-config inline int->local-time-2]}]
+  {;; Shui needs access to the global configuration of the application
+   :config app-config
+   ;; Until components are converted over, they need to fallback to the old inline function 
+   ;; Wrap the old inline function to allow for interception, but fallback to the old inline function
+   :inline-block (inline->inline-block inline block-config) 
+   :map-inline-block (inline->map-inline-block inline block-config)
+   ;; Currently frontend component are provided an object map containin at least the following keys:
+   ;; These will be passed through in a whitelisted fashion so as to be able to track the dependencies  
+   ;; back to the core application
+   ;; TODO: document the following
+   :block (:block block-config)  ;; the db entity of the current block
+   :block? (:block? block-config)
+   :blocks-container-id (:blocks-container-id block-config)
+   :editor-box (:editor-box block-config)
+   :id (:id block-config) 
+   :mode? (:mode? block-config)
+   :query-result (:query-result block-config)
+   :sidebar? (:sidebar? block-config)
+   :start-time (:start-time block-config)
+   :uuid (:uuid block-config)
+   :whiteboard? (:whiteboard? block-config)
+   ;; Some functions from logseq's application will be used in the shui components. To avoid circular dependencies,
+   ;; they will be provided via the context object
+   :int->local-time-2 int->local-time-2})

+ 11 - 0
deps/shui/src/logseq/shui/core.cljs

@@ -0,0 +1,11 @@
+(ns logseq.shui.core
+  (:require 
+    [logseq.shui.table.v2 :as shui.table.v2]
+    [logseq.shui.context :as shui.context]))
+
+;; table component
+(def table shui.table.v2/root)
+(def table-v2 shui.table.v2/root)
+
+;; context
+(def make-context shui.context/make-context)

+ 471 - 0
deps/shui/src/logseq/shui/table/v2.cljs

@@ -0,0 +1,471 @@
+(ns logseq.shui.table.v2
+  (:require 
+    [clojure.string :as str]
+    [logseq.shui.util :refer [use-ref-bounding-client-rect use-dom-bounding-client-rect $main-content] :as util]
+    [rum.core :as rum]))
+
+(declare table-cell)
+
+(def COLORS #{"tomato" "red" "crimson" "pink" "plum" "purple" "violet" "indigo" "blue" "sky" "cyan" "teal" "mint" "green" "grass" "lime" "yellow" "amber" "orange" "brown"})
+(def MAX_WIDTH 30 #_rem) ;; Max width in rem for a single column
+(def MIN_WIDTH 4 #_rem)  ;; Min width in rem for a single column
+
+;; in order to make sure the tailwind classes are included,
+;; the values are pulled from the classes via regex.
+;; the return values are simply the numbers in the classes.
+(def CELL_PADDING         (->> "px-[0.75rem]" (re-find #"\d+\.?\d*") js/parseFloat))
+(def CELL_PADDING_COMPACT (->> "px-[0.25rem]" (re-find #"\d+\.?\d*") js/parseFloat))
+(def BORDER_WIDTH         (->> "border-[1px]" (re-find #"\d+\.?\d*") js/parseFloat))
+
+;; -- Helpers ------------------------------------------------------------------
+
+(defn get-in-first 
+  ([obj path] (get-in obj path))
+  ([obj path & more] (get-in obj path (apply get-in-first obj more))))
+
+(defn get-in-first-fallback
+  ([obj path] (get-in obj path))
+  ([obj path fallback] (get-in obj path fallback))
+  ([obj path path-b & more] (get-in obj path (apply get-in-first-fallback obj path-b more))))
+
+(defn read-prop [value]
+  (case value 
+    "false" false 
+    "true" true 
+    value))
+
+(defn get-view-prop 
+  "Get the config for a specified item. Can be overridden in blocks, specified in config, 
+  fallback to default config, or fallback to the provided parameters"
+  ([context kw] 
+   (read-prop
+     (get-in-first context [:block :properties kw] 
+                           [:block :block/properties kw] 
+                           [:config kw])))
+  ([context kw fallback]
+   (read-prop
+     (get-in-first-fallback context [:block :properties kw] 
+                                    [:block :block/properties kw] 
+                                    [:config kw] 
+                                    fallback))))
+
+(defn color->gray [color]
+  (case color 
+    ("tomato" "red" "crimson" "pink" "plum" "purple" "violet") "mauve"
+    ("indigo" "blue" "sky" "cyan") "slate" 
+    ("teal" "mint" "green") "sage"
+    ("grass" "lime") "olive"
+    ("yellow" "amber" "orange" "brown") "sand"
+    nil))
+
+(defn rdx
+  ([color step] (str "bg-" color "-" step))
+  ([param color step] (str (name param) "-" color "-" step)))
+  ; ([color step] (str "bg-" color "dark-" step))
+  ; ([param color step] (str param "-" color "dark-" step))))
+
+    ; --ls-primary-background-color: #fff;
+    ; --ls-secondary-background-color: #f8f8f8;
+    ; --ls-tertiary-background-color: #f2f2f3;
+    ; --ls-quaternary-background-color: #ebeaea));
+
+(defn lsx 
+  "This is a temporary bridge between the radix color grading and the
+  current logseq theming variables. Should set the prop to the given css variable"
+  ([step] (lsx :bg step))
+  ([param step]
+   (case step 
+     1 ({"bg" "bg-[color:var(--ls-primary-background-color)]"} (name param))
+     2 ({"bg" "bg-[color:var(--ls-secondary-background-color)]"} (name param)) 
+     3 ({"bg" "bg-[color:var(--ls-tertiary-background-color)]"} (name param))
+     4 ({"bg" "bg-[color:var(--ls-quaternary-background-color)]"} (name param))
+     5 ({"bg" "bg-[color:var(--ls-quinary-background-color)]"} (name param)) 
+     6 ({"bg" "bg-[color:var(--ls-senary-background-color)]"} (name param))
+     7 ({"bg" "bg-[color:var(--ls-border-color)]"
+         "border" "border-[color:var(--ls-border-color)]"} (name param))
+     11 ({"text" "text-[color:var(--ls-secondary-text-color)]"} (name param))
+     12 ({"text" "text-[color:var(--ls-primary-text-color)]"} (name param)))))
+
+(defn varc [color step]
+  (str "var(--color-" color "-" step ")"))
+
+(defn last-str 
+  "Given an inline AST, return the last string element you can walk to" 
+  [inline]
+  (cond 
+    (keyword? inline) (name inline)
+    (string? inline) inline
+    (coll? inline) (last-str (last inline))
+    :else (pr-str inline)))
+
+(comment
+  (last-str "A")
+  (last-str ["Plain" "A"])
+  (last-str [["Plain" "A"]])
+  (last-str [["Plain" "A"] 
+             [["Emphasis" [["Italic"] [["Plain" "B"]]]]]]))
+
+(defn render-cell-in-context 
+  "Some instances of the table provide us with raw data, others provide us with 
+  inline ASTs. This function renders the content appropriately, passing the AST along 
+  to map-inline if necessary."
+  [{:keys [map-inline-block int->local-time-2]} cell-data]
+  (cond 
+    (sequential? cell-data) (map-inline-block [:table :v2] cell-data)
+    (string? cell-data) cell-data
+    (keyword? cell-data) (name cell-data)
+    (boolean? cell-data) (pr-str cell-data) 
+    (number? cell-data) (if-let [date (int->local-time-2 cell-data)]
+                          date cell-data)))
+
+(defn map-with-all-indices [data]
+  (let [!row-index (volatile! -1)]
+    (for [[group-index group] (map-indexed vector data) 
+          [group-row-index row] (map-indexed vector group) 
+          :let [row-index (vswap! !row-index inc)]]
+      [group-index group-row-index row-index group row])))
+
+(defn get-columns [block data]
+  (->> (or (some-> (get-in block [:block/properties :logseq.table.cols])
+                   (str/split #", ?"))
+           (map last-str (ffirst data)))
+       (map (comp str/lower-case str/trim))))
+
+(defn cell-bg-classes 
+  "We track the cell the cursor last entered and update the cells according to the configured 
+  hover preference: cell, row, col, both, or none.
+  We also have to account for the header cells and stripes cells"
+  [{:keys [row-index col-index hover header? gray color stripes? cursor]}]
+  (let [;; check how the cursor position overlaps with the current cell
+        row-highlighted?  (= row-index (second cursor))
+        col-highlighted?  (= col-index (first cursor))
+        cell-highlighted? (and row-highlighted? col-highlighted?)
+        ;; check how the cell needs to be highlighted
+        highlight-row?    (and row-highlighted? (#{"row" "both"} hover))
+        highlight-col?    (and col-highlighted? (#{"col" "both"} hover))
+        highlight-cell?   (and cell-highlighted? (#{"cell" "row" "col" "both"} hover))]
+    (cond 
+      highlight-cell? (if header? (lsx 6) (lsx 4))
+      highlight-row?  (if header? (lsx 5) (lsx 3))
+      highlight-col?  (if header? (lsx 5) (lsx 3))
+      header? (lsx 4) 
+      (and stripes? (even? row-index)) (lsx 2)
+      :else (lsx 1))))
+
+(defn cell-rounded-classes 
+  "Depending on where the cell is, and whether there is a gradient accent, we need to round specific corners 
+   The cond-> is used to account for single row or single column talbes that may have multiple rounded corners."
+  [{:keys [color row-index col-index total-rows total-cols]}]
+  (let [no-gradient-accent? (nil? color)]
+    (cond-> ""
+      (and no-gradient-accent? (= [row-index col-index] [0 0])) (str " rounded-tl")
+      (and no-gradient-accent? (= [row-index col-index] [0 (dec total-cols)])) (str " rounded-tr")
+      (= [row-index col-index] [(dec total-rows) 0]) (str " rounded-bl")
+      (= [row-index col-index] [(dec total-rows) (dec total-cols)]) (str " rounded-br"))))
+
+(defn cell-text-transform-classes [{:keys [headers header?]}]
+  (when header?
+    (cond-> (get #{"uppercase" "capitalize" "lowercase" "none" "capitalize-first"} headers "none")
+      (= headers "capitalize-first") (str " lowercase"))))
+
+(defn cell-padding-classes [{:keys [compact? header?]}]
+  (cond 
+    #_compact_th (and compact? header?) (str "px-[" CELL_PADDING_COMPACT "rem] py-0.5") 
+    #_compact_td compact?               (str "px-[" CELL_PADDING_COMPACT "rem] py-0.5")
+    #_padded_th  header?                (str "px-[" CELL_PADDING "rem] py-1.5")
+    #_padded_td  :else                  (str "px-[" CELL_PADDING "rem] py-2")))
+
+(defn cell-text-classes [{:keys [header?]}]
+  (if header?
+    (str (lsx :text 11) " text-sm tracking-wide font-bold")
+    (str (lsx :text 12) " text-base")))
+
+(defn cell-classes [table-opts]
+  (str/join " "
+    [(cell-bg-classes table-opts)
+     (cell-rounded-classes table-opts)
+     (cell-text-classes table-opts)
+     (cell-text-transform-classes table-opts)
+     (cell-padding-classes table-opts)]))
+
+;; -- Handlers -----------------------------------------------------------------
+
+(defn handle-cell-pointer-down [e {:keys [cell-focus col-index row-index]}] 
+  (when (not= cell-focus [col-index row-index])
+    (.stopPropagation e) 
+    (.preventDefault e)))
+
+(defn handle-cell-click 
+  "When a cell is clicked, we need to update the cursor position and the selected cells"
+  [e {:keys [cell-focus set-cell-focus header? col-index row-index]} cell-ref]
+  ; (.stopPropagation e) 
+  (.preventDefault e)
+  (when-not (= cell-focus [col-index row-index]) 
+    (set-cell-focus [col-index row-index])))
+    
+
+(defn handle-cell-keydown 
+  "When a cell is focused, we need to update the cursor position and the selected cells"
+  [e {:keys [cell-focus set-cell-focus header? col-index row-index total-rows total-cols]}]
+  (when (= cell-focus [col-index row-index]) 
+    (and (case (.-key e)
+           "ArrowUp"    (if (= row-index 0)
+                          (set-cell-focus [col-index row-index])
+                          (set-cell-focus [col-index (dec row-index)]))
+           "ArrowDown"  (if (= row-index (dec total-rows)) 
+                          (set-cell-focus [col-index row-index])
+                          (set-cell-focus [col-index (inc row-index)]))
+           "ArrowLeft"  (cond 
+                          ;; if we are in the top left, then do not move the focus
+                          (and (= col-index 0) (= row-index 0))
+                          (set-cell-focus [col-index row-index])
+                          ;; if we are in the first column, then move to the last column of the previous row
+                          (= col-index 0) 
+                          (set-cell-focus [(dec total-cols) (dec row-index)])
+                          ;; otherwise, move to the previous column
+                          :else
+                          (set-cell-focus [(dec col-index) row-index]))
+           "ArrowRight" (cond 
+                          ;; if we are in the bottom right, then do not move the focus
+                          (and (= col-index (dec total-cols)) (= row-index (dec total-rows))) 
+                          (set-cell-focus [col-index row-index])
+                          ;; if we are in the last column, then move to the first column of the next row
+                          (= col-index (dec total-cols)) 
+                          (set-cell-focus [0 (inc row-index)])
+                          ;; otherwise, move to the next column
+                          :else
+                          (set-cell-focus [(inc col-index) row-index]))
+           nil)
+         ;; Prevent default actions when the table handles it itself
+         (.preventDefault e)
+         (.stopPropagation e))))
+        
+
+;; -- Hooks --------------------------------------------------------------------
+
+(defn use-atom 
+  "A hook that wraps use-state to allow for interaction with 
+  the state as if it were an atom"
+  [initial-value]
+  (let [atom-ref (rum/use-ref (atom initial-value))
+        atom-current (.. atom-ref -current)
+        [state set-state] (rum/use-state initial-value)]
+    (rum/use-effect! (fn [] 
+                       (set-state @atom-current) 
+                       identity)
+                     [atom-current])
+    [state atom-current]))
+
+(defn use-dynamic-widths [data]
+  (let [[static atomic] (use-atom {})
+        add-column-width (fn [col-index width]
+                           (when (< (get @atomic col-index 0) (min MAX_WIDTH width))
+                             (swap! atomic assoc col-index (min MAX_WIDTH width))
+                             ;; rum is complaining that we can only return teardown functions
+                             identity))]
+    ;; Reset the minimum widths when the data changes
+    (rum/use-effect! (fn [] (reset! atomic {}) identity)
+                     [data])
+    [static add-column-width]))
+
+(defn use-table-flow-at-width [table-px max-cols-px]
+  (let [[overflow set-overflow] (rum/use-state false)
+        [underflow set-underflow] (rum/use-state false)
+        handle-container-width (fn [container-px]
+                                 (set-underflow (< max-cols-px container-px))
+                                 (set-overflow (< container-px table-px)))]
+    [overflow underflow handle-container-width]))
+
+;; -- Components (V2) -----------------------------------------------------------
+
+(rum/defc table-scrollable-overflow [handle-root-width-change child]
+  (let [[set-root-ref root-rect root-ref] (use-ref-bounding-client-rect)
+        main-content-rect (use-dom-bounding-client-rect ($main-content))
+        
+        left-adjustment (- (:left root-rect) (:left main-content-rect))
+        right-adjustment (- (:width main-content-rect) 
+                            (- (:right root-rect) (:left main-content-rect)))
+
+        ;; Because in a scrollable container, we need to account for the scrollbar being clicked,
+        ;; we add a handler to prevent the table from switching to the input on click. 
+        ;; This also prevents the table from switching to eiditng mode when the left or right area 
+        ;; of the table is clicked, but that feels natural to me.
+        handle-pointer-down (fn [e]
+                              (when (= root-ref (.. e -target -parentElement))
+                                (.preventDefault e)))]
+    (rum/use-effect! #(handle-root-width-change (:width root-rect)) [(:width root-rect)])
+    [:div {:ref set-root-ref}
+     [:div {:style {:width (:width main-content-rect)
+                    :margin-left (- (:left main-content-rect) (:left root-rect))
+                    :padding-left left-adjustment
+                    :padding-right right-adjustment
+                    :overflow-x "scroll"}
+            :class "mt-2"
+            :on-pointer-down handle-pointer-down}
+      child]]))
+
+(rum/defc table-gradient-accent [{:keys [color]}]
+  [:div.rounded-t.h-2.-ml-px.-mt-px.-mr-px 
+   {:style {:grid-column "1 / -1" :order -999} 
+    :class (str "grad-bg-" color "-9")
+    :data-testid "v2-table-gradient-accent"}])
+
+(rum/defc table-header-row [handle-cell-width-change cells {:keys [cell-col-map] :as opts}]
+  [:<>
+   (for [[cell-index cell] (map-indexed vector cells)
+         :let [col-index (get cell-col-map cell-index)]
+         :when col-index]
+     ^{:key cell-index}
+     (table-cell handle-cell-width-change cell (assoc opts :cell-index cell-index :col-index col-index :header? true)))])
+ 
+(rum/defc table-data-row [handle-cell-width-change cells {:keys [cell-col-map] :as opts}]
+  [:<>
+   (for [[cell-index cell] (map-indexed vector cells)
+         :let [col-index (get cell-col-map cell-index)]
+         :when col-index]
+     ^{:key cell-index}
+     (table-cell handle-cell-width-change cell (assoc opts :cell-index cell-index :col-index col-index)))])
+
+(rum/defc table-cell [handle-cell-width-change cell {:keys [row-index col-index render-cell show-separator? total-cols set-cell-hover cell-focus table-underflow?] :as opts}]
+  (let [cell-ref (rum/use-ref nil)
+        cell-order (+ (* row-index total-cols) col-index)
+        static-width (get-in opts [:static-widths col-index])
+        dynamic-width (when-not static-width 
+                        (get-in opts [:dynamic-widths col-index]))]
+    ;; Whenever the cell changes, we need to calculate new bounds for the given content 
+    ;; -innerText is used here to strip out formatting, this may turn out to not work for all given block types
+    (rum/use-layout-effect! #(->> (.. cell-ref -current -innerText) 
+                                  (count) 
+                                  (handle-cell-width-change col-index))
+                            [cell])
+
+    ;; Whenever the cell becomes focused, we set it's tabIndex. When the tabIndex is set, call focus on the element 
+    (rum/use-layout-effect! #(when (= cell-focus [col-index row-index])
+                               ; (.. cell-ref -current -tabIndex 0)
+                               (some-> cell-ref .-current .focus))
+                               ; (.execCommand js/document "selectAll"))
+                            [cell-focus])
+    [:div {:ref cell-ref
+           :class (cell-classes opts)
+           :style (cond-> {:box-sizing :border-box}
+                    (not table-underflow?) (assoc :max-width (str MAX_WIDTH "rem"))
+                    static-width  (assoc :width (str static-width "rem"))
+                    dynamic-width (assoc :min-width (str (max MIN_WIDTH dynamic-width) "rem"))
+                    cell-order    (assoc :order cell-order)
+                    show-separator? (assoc :margin-top 3))
+           :tab-index (when (= cell-focus [col-index row-index]) "-1")
+           :on-pointer-enter #(set-cell-hover [col-index row-index])
+           :on-click #(handle-cell-click % opts cell-ref)
+           :on-pointer-down #(handle-cell-pointer-down % opts)
+           ; :on-pointer-up handle-cell-interrupt
+           :on-key-down #(handle-cell-keydown % opts)}
+     (render-cell cell)]))
+
+(rum/defc table-container [{:keys [columns borders? table-overflow? total-table-width gray set-cell-hover] :as opts} & children]
+  (let [grid-template-columns (str "repeat(" (count columns) ", minmax(max-content, 1fr))")]
+    [:div.grid.border.rounded {:style {:grid-template-columns grid-template-columns
+                                       :gap (when borders? BORDER_WIDTH) 
+                                       :width (when table-overflow? total-table-width)}
+                               :class (str (lsx 7) " " (lsx :border 7))
+                               :data-testid "v2-table-container"
+                               :on-pointer-leave #(set-cell-hover [])}
+     children]))
+
+(rum/defc root
+  [{:keys [data] :as _props} {:keys [block] :as context}]
+  (let [;; In order to highlight cells in the same row or column of the hovered cell, 
+        ;; we need to know the row and column that the cursor is in
+        [[_cell-hover-x _cell-hover-y :as cell-hover] set-cell-hover] (rum/use-state [])
+        [[_cell-focus-x _cell-focus-y :as cell-focus] set-cell-focus] (rum/use-state [])
+
+        ;; Depending on the content of the table, we roughly adjust the width of the column
+        ;; to do this we need to keep track of the .innerText.length of each cell and update 
+        ;; it whenever it changes
+        [dynamic-widths handle-cell-width-change] (use-dynamic-widths data)
+
+        ;; We need to call into the view config several times, so we can memoize it
+        ;; TODO: insert global config here
+        get-view-prop* (partial get-view-prop context)
+                                     
+        ;; Most of the config options will be repeated and reused throughout the table, so store 
+        ;; all of it's state in a single map for consistency
+        table-opts {; user configurable properties (sometimes with defaults)
+                    :color    (get-view-prop* :logseq.color)
+                    :headers  (get-view-prop* :logseq.table.headers "none")
+                    :borders? (get-view-prop* :logseq.table.borders true)
+                    :compact? (get-view-prop* :logseq.table.compact false)
+                    :hover    (get-view-prop* :logseq.table.hover "cell")
+                    :stripes? (get-view-prop* :logseq.table.stripes false)
+                    :gray     (color->gray (get-in context [:config :logseq.color])) 
+                    :columns  (get-columns block data)
+
+                    ; non configurable properties
+                    :cell-hover cell-hover
+                    :cell-focus cell-focus
+                    :cursor (or (not-empty cell-focus) (not-empty cell-hover))
+                    :dynamic-widths dynamic-widths
+                    :render-cell (partial render-cell-in-context context)
+                    :set-cell-hover set-cell-hover
+                    :set-cell-focus set-cell-focus
+                    :total-rows (reduce + 0 (map count data))} 
+
+        ;; The total table width has to account for the borders and the padding 
+        ;; everything is tracked in rems, except for the border, since it's so small
+        cell-padding-width (* 2 (if (:compact? table-opts) CELL_PADDING_COMPACT CELL_PADDING))
+        total-border-width (* (count (:columns table-opts)) BORDER_WIDTH)
+        total-table-width (->> (vals dynamic-widths) 
+                               (map (partial + cell-padding-width)) 
+                               (reduce + 0)  
+                               (util/rem->px)
+                               (+ total-border-width)) 
+        total-max-col-width (-> (count (:columns table-opts))
+                                (* MAX_WIDTH)
+                                (util/rem->px)
+                                (+ total-border-width))
+
+        ;; The table is actually rendered differently when it needs to be scrollable. 
+        ;; Keep track of whether the ideal table size overflows it's container size, 
+        ;; and provide a handler to be called whenever the container width changes 
+        [table-overflow? table-underflow? handle-root-width-change] (use-table-flow-at-width total-table-width total-max-col-width)
+
+        ;; Because the data may come in a different order than it should be presented, 
+        ;; we need to distinguish between these and provide a conversion. 
+        ;; The order the data is stored in is referred to as the cell order.
+        ;; The order the data is displayed as is referred to as the col order.
+        ;; Since these are called on every render of every cell, and are not dynamic, they are computed up front
+        cell-col-map (->> (ffirst data) 
+                          (map-indexed (juxt #(identity %1) 
+                                             #(.indexOf (:columns table-opts) (.toLowerCase (last-str %2)))))
+                          (remove (comp #{-1} second))
+                          (into {}))
+
+        ;; There are a couple more computed table properties that are best calculated 
+        ;; after the initial object is creaated
+        table-opts (assoc table-opts :total-cols (count (:columns table-opts))
+                                     :total-table-width total-table-width
+                                     :table-overflow? table-overflow?
+                                     :table-underflow? table-underflow?
+                                     :cell-col-map cell-col-map)]
+    ; (js/console.log "shui table opts context" (clj->js context)) 
+    ; (js/console.log "shui table opts" (clj->js table-opts)) 
+    ; (js/console.log "shui table opts" (pr-str table-opts)) 
+    ;; Scrollable Container: if the table is larger than the container, manage the scrolling effects here
+    (table-scrollable-overflow handle-root-width-change
+     ;; Grid Container: control the outermost table related element (border radius, grid, etc)
+     (table-container table-opts
+      ;; Gradient Accent: the accent color at the top of the application
+      (when (:color table-opts)
+        (table-gradient-accent table-opts))
+      ;; Rows: the actual table rows
+      (for [[group-index group-row-index row-index _group row] (map-with-all-indices data)
+            :let [show-separator? (and (= 0 group-row-index) (< 1 group-index))
+                  opts (assoc table-opts :group-index group-index 
+                                         :group-row-index group-row-index 
+                                         :row-index row-index 
+                                         :show-separator? show-separator?)]]
+          (if (= 0 group-index)
+            ;; Table Header: Rows in the first section are rendered as headers
+            ^{:key row-index} (table-header-row handle-cell-width-change row opts)
+            ;; Table Body: The rest of the data is rendered as cells
+            ^{:key row-index} (table-data-row handle-cell-width-change row opts)))))))
+

+ 81 - 0
deps/shui/src/logseq/shui/util.cljs

@@ -0,0 +1,81 @@
+(ns logseq.shui.util
+  (:require 
+    [clojure.string :as s]
+    [rum.core :refer [use-state use-effect!] :as rum]
+    [goog.dom :as gdom]))
+
+
+;;      /--------------- app ------------\
+;;    /-------- left --------\             \
+;;  /l-side\                  \  /- r-side --\
+;;
+;; |--------|-------------------|-------------| \ head
+;; |--------|-------------------|             | /
+;; |        |                   |             |
+;; |        |                   |             |
+;; |        |                   |             |
+;; |--------|-------------------|-------------|
+
+(def $app           (partial gdom/getElement "app-container"))
+(def $left          (partial gdom/getElement "left-container"))
+(def $head          (partial gdom/getElement "head-container"))
+(def $main          (partial gdom/getElement "main-container"))
+(def $main-content  (partial gdom/getElement "main-content-container"))
+(def $left-sidebar  (partial gdom/getElement "left-sidebar"))
+(def $right-sidebar (partial gdom/getElement "right-sidebar"))
+
+(defn el->clj-rect [el]
+  (let [rect (.getBoundingClientRect el)]
+    {:top (.-top rect)
+     :left (.-left rect)
+     :bottom (.-bottom rect)
+     :right (.-right rect)
+     :width (.-width rect)
+     :height (.-height rect)
+     :x (.-x rect)
+     :y (.-y rect)}))
+
+(defn clj-rect-observer [update!]
+  (js/ResizeObserver.
+    (fn [entries] 
+      (when (.-contentRect (first (js->clj entries)))
+        (update!)))))
+
+(defn use-dom-bounding-client-rect
+  ([el] (use-dom-bounding-client-rect el nil))
+  ([el tick] 
+   (let [[rect set-rect] (rum/use-state nil)]
+     (rum/use-effect! 
+       (if el 
+         (fn [] 
+           (let [update! #(set-rect (el->clj-rect el))
+                 observer (clj-rect-observer update!)]
+             (update!)
+             (.observe observer el) 
+             #(.disconnect observer)))
+         #())
+       [el tick])
+     rect)))
+          
+(defn use-ref-bounding-client-rect 
+  ([] (use-ref-bounding-client-rect nil))
+  ([tick]
+   (let [[ref set-ref] (rum/use-state nil)
+         rect (use-dom-bounding-client-rect ref tick)]
+     [set-ref rect ref]))
+  ([ref tick] [nil (use-dom-bounding-client-rect ref tick)]))
+
+
+(defn rem->px [rem]
+  (-> js/document.documentElement
+      js/getComputedStyle
+      (.-fontSize)
+      (js/parseFloat)
+      (* rem)))
+
+(defn px->rem [px]
+  (->> js/document.documentElement
+       js/getComputedStyle
+       (.-fontSize)
+       (js/parseFloat)
+       (/ px)))

+ 21 - 2
docs/dev-practices.md

@@ -139,7 +139,17 @@ By convention, a namespace's tests are found at a corresponding namespace
 of the same name with an added `-test` suffix. For example, tests
 for `frontend.db.model` are found in `frontend.db.model-test`.
 
-There are a couple different ways to develop with tests:
+There are a couple different ways to run tests:
+
+* [Focus tests](#focus-tests) - Run one or more tests from the CLI
+* [Autorun tests](#autorun-tests) - Autorun tests from the CLI
+* [Repl tests](#repl-tests) - Run tests from REPL
+
+There a couple types of tests and they can overlap with each other:
+
+* [Database tests](#database-tests) - Tests that involve a datascript DB.
+* [Performance tests](#performance-tests) - Tests that aim to measure and enforce a performance characteristic.
+* [Async tests](#async-tests) - Tests that run async code and require some helpers.
 
 #### Focus Tests
 
@@ -166,6 +176,15 @@ To run tests automatically on file save, run `clojure -M:test watch test
 the `:ns-regexp` option e.g. `clojure -M:test watch test --config-merge
 '{:autorun true :ns-regexp "frontend.util.page-property-test"}'`.
 
+#### REPL tests
+
+Most unit tests e.g. ones that are browser compatible and don't require node libraries, can be run from the REPL. To do so:
+
+* Start a REPL for your editor. See [here for an example](https://github.com/logseq/logseq/blob/master/docs/develop-logseq.md#repl-setup).
+* Load a test namespace.
+* Run `(cljs.test/run-tests)` to run tests for the current test namespace.
+
+
 #### Database tests
 
 To write a test that uses a datascript db:
@@ -188,7 +207,7 @@ To write a performance test:
 
 For examples of these tests, see `frontend.db.query-dsl-test` and `frontend.db.model-test`.
 
-### Async Unit Testing
+#### Async Tests
 
 Async unit testing is well supported in ClojureScript.
 https://clojurescript.org/tools/testing#async-testing is a good guide for how to

+ 15 - 2
e2e-tests/basic.spec.ts

@@ -141,8 +141,8 @@ test('template', async ({ page, block }) => {
 
   await block.waitForBlocks(5)
 
-  // NOTE: use delay to type slower, to trigger auto-completion UI.
-  await block.clickNext()
+  // See-also: #9354
+  await block.enterNext()
   await block.mustType('/template')
 
   await page.click('[title="Insert a created template here"]')
@@ -154,6 +154,19 @@ test('template', async ({ page, block }) => {
   await popupMenuItem.click()
 
   await block.waitForBlocks(9)
+
+
+  await block.clickNext()
+  await block.mustType('/template')
+
+  await page.click('[title="Insert a created template here"]')
+  // type to search template name
+  await page.keyboard.type(randomTemplate.substring(0, 3), { delay: 100 })
+
+  await popupMenuItem.waitFor({ timeout: 2000 }) // wait for template search
+  await popupMenuItem.click()
+
+  await block.waitForBlocks(13) // 9 + 4
 })
 
 test('auto completion square brackets', async ({ page, block }) => {

+ 1 - 1
e2e-tests/editor.spec.ts

@@ -260,7 +260,7 @@ test('undo and redo after starting an action should not destroy text #6267', asy
 
   // And it should keep what was undone as a redo action
   await page.keyboard.press(modKey + '+Shift+z')
-  await expect(page.locator('text="text2"')).toHaveCount(1)
+  await expect(page.locator('text="text1 text2 [[]]"')).toHaveCount(1)
 })
 
 test('undo after starting an action should close the action menu #6269', async ({ page, block }) => {

+ 13 - 11
e2e-tests/fixtures.ts

@@ -21,22 +21,24 @@ export let graphDir = path.resolve(testTmpDir, "#e2e-test", repoName)
 
 // NOTE: This following is a console log watcher for error logs.
 // Save and print all logs when error happens.
-let logs: string
+let logs: string = '';
 const consoleLogWatcher = (msg: ConsoleMessage) => {
-  // console.log(msg.text())
-  const text = msg.text()
-  logs += text + '\n'
+  const text = msg.text();
 
-  expect(text, logs).not.toMatch(/^(Failed to|Uncaught)/)
+  // List of error messages to ignore
+  const ignoreErrors = [
+    /net::ERR_CONNECTION_REFUSED/,
+    /^Error with Permissions-Policy header:/
+  ];
 
-  // youtube video
-  // Error with Permissions-Policy header: Origin trial controlled feature not enabled: 'ch-ua-reduced'.
-  if (!text.match(/^Error with Permissions-Policy header:/)) {
-    expect(text, logs).not.toMatch(/^Error/)
+  // If the text matches any of the ignoreErrors, return early
+  if (ignoreErrors.some(error => text.match(error))) {
+    return;
   }
 
-  // NOTE: React warnings will be logged as error.
-  // expect(msg.type()).not.toBe('error')
+  logs += text + '\n';
+  expect(text, logs).not.toMatch(/^(Failed to|Uncaught|Assert failed)/);
+  expect(text, logs).not.toMatch(/^Error/);
 }
 
 base.beforeAll(async () => {

+ 304 - 0
e2e-tests/shui/table.spec.js

@@ -0,0 +1,304 @@
+import { expect } from '@playwright/test'
+import fs from 'fs/promises'
+import path from 'path'
+import { test } from '../fixtures'
+import { randomString, editFirstBlock, navigateToStartOfBlock, createRandomPage } from '../utils'
+
+test.setTimeout(60000)
+
+const KEY_DELAY = 100
+
+// The following function assumes that the block is currently in edit mode, 
+// and it just enters a simple table
+const inputSimpleTable = async (page) => {
+  await page.keyboard.type('| Header A | Header B |')
+  await page.keyboard.press('Shift+Enter')
+  await page.keyboard.type('| A1 | B1 |') 
+  await page.keyboard.press('Shift+Enter')
+  await page.keyboard.type('| A2 | B2 |')
+  await page.keyboard.press('Escape')
+  await page.waitForTimeout(KEY_DELAY)
+}
+
+// The following function does not assume any state, and will prepend the provided lines to the 
+// first block of the document 
+const prependPropsToFirstBlock = async (page, block, ...props) => {
+  await editFirstBlock(page) 
+  await page.waitForTimeout(KEY_DELAY) 
+  await navigateToStartOfBlock(page, block)
+  await page.waitForTimeout(KEY_DELAY) 
+
+  for (const prop of props) {
+    await page.keyboard.type(prop)
+    await page.waitForTimeout(KEY_DELAY)
+    await page.keyboard.press('Shift+Enter')
+    await page.waitForTimeout(KEY_DELAY)
+  }
+
+  await page.keyboard.press('Escape')
+  await page.waitForTimeout(KEY_DELAY)
+}
+
+const setPropInFirstBlock = async (page, block, prop, value) => {
+  await editFirstBlock(page)
+  await page.waitForTimeout(KEY_DELAY)
+  await navigateToStartOfBlock(page, block)
+  await page.waitForTimeout(KEY_DELAY)
+
+  const inputValue = await page.inputValue('textarea >> nth=0')
+
+  const match = inputValue.match(new RegExp(`${prop}::(.*)(\n|$)`))
+
+  if (!match) {
+    await page.keyboard.press('Shift+Enter')
+    await page.waitForTimeout(KEY_DELAY)
+    await page.keyboard.press('ArrowUp')
+    await page.waitForTimeout(KEY_DELAY)
+    await page.keyboard.type(`${prop}:: ${value}`)
+    // await page.waitForTimeout(1000)
+    // await page.waitForTimeout(KEY_DELAY)
+    // await page.keyboard.type(prop + ':: ' + value)
+    // await page.waitForTimeout(1000)
+    // await page.keyboard.press('Shift+Enter')
+    await page.waitForTimeout(KEY_DELAY)
+    await page.keyboard.press('Escape')
+    return await page.waitForTimeout(KEY_DELAY)
+  }
+
+  const [propLine, propValue, propTernary] = match
+  const startIndex = match.index
+  const endIndex = startIndex + propLine.length - propTernary.length
+
+  // Go to the of the prop
+  for (let i = 0; i < endIndex; i++) {
+    await page.keyboard.press('ArrowRight')
+  }
+
+  // Delete the value of the prop 
+  for (let i = 0; i < propValue.length; i++) {
+    await page.keyboard.press('Backspace')
+  }
+
+  // Input the new value of the prop
+  await page.keyboard.type(" " + value.trim())
+  await page.waitForTimeout(KEY_DELAY)
+  await page.keyboard.press('Escape')
+  return await page.waitForTimeout(KEY_DELAY)
+}
+
+
+test('table can have it\'s version changed via props', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a v1 table 
+  inputSimpleTable(page)
+
+  // find and confirm existence of first data cell
+  await expect(await page.locator('table tbody tr >> nth=0').innerHTML()).toContain('A1</td>')
+
+  // change to a version 2 table
+  await setPropInFirstBlock(page, block, 'logseq.table.version', '2')
+
+  // find and confirm existence of first data cell in new format
+  await expect(await page.getByTestId('v2-table-container').innerHTML()).toContain('A1</div>')
+})
+
+test('table can configure logseq.color::', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a v1 table 
+  await page.keyboard.type('logseq.table.version:: 2')
+  await page.keyboard.press('Shift+Enter')
+  await inputSimpleTable(page)
+
+  // check for default general config 
+  await expect(await page.getByTestId('v2-table-gradient-accent')).not.toBeVisible()
+
+  await setPropInFirstBlock(page, block, 'logseq.color', 'red')
+
+  // check for gradient accent 
+  await expect(await page.getByTestId('v2-table-gradient-accent')).toBeVisible()
+})
+
+test('table can configure logseq.table.hover::', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a v1 table 
+  await page.keyboard.type('logseq.table.version:: 2')
+  await page.keyboard.press('Shift+Enter')
+  await inputSimpleTable(page)
+
+  await page.waitForTimeout(KEY_DELAY)
+  await page.getByText('A1', { exact: true }).hover()
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-quaternary-background-color)]')
+  await expect(await page.getByText('B1', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('B2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+
+  await setPropInFirstBlock(page, block, 'logseq.table.hover', 'row')
+
+  await page.waitForTimeout(KEY_DELAY)
+  await page.getByText('A1', { exact: true }).hover()
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-quaternary-background-color)]')
+  await expect(await page.getByText('B1', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('B2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+
+  await setPropInFirstBlock(page, block, 'logseq.table.hover', 'col')
+
+  await page.waitForTimeout(KEY_DELAY)
+  await page.getByText('A1', { exact: true }).hover()
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-quaternary-background-color)]')
+  await expect(await page.getByText('B1', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('B2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+
+  await setPropInFirstBlock(page, block, 'logseq.table.hover', 'both')
+
+  await page.waitForTimeout(KEY_DELAY)
+  await page.getByText('A1', { exact: true }).hover()
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-quaternary-background-color)]')
+  await expect(await page.getByText('B1', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('B2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+
+  await setPropInFirstBlock(page, block, 'logseq.table.hover', 'none')
+
+  await page.waitForTimeout(KEY_DELAY)
+  await page.getByText('A1', { exact: true }).hover()
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-quaternary-background-color)]')
+  await expect(await page.getByText('B1', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+  await expect(await page.getByText('B2', { exact: true }).getAttribute('class')).not.toContain('bg-[color:var(--ls-tertiary-background-color)]')
+})
+
+test('table can configure logseq.table.headers', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a table
+  await page.keyboard.type('logseq.table.version:: 2')
+  await page.keyboard.press('Shift+Enter')
+  await inputSimpleTable(page)
+
+  // Check none (default)
+  await expect(await page.getByText('Header A', { exact: true })).toBeVisible()
+  await expect(await page.getByText('Header A', { exact: true }).innerText()).toEqual("Header A")
+
+  // Check none (explicit)
+  await setPropInFirstBlock(page, block, 'logseq.table.headers', 'none')
+  await expect(await page.getByText('Header A', { exact: true }).innerText()).toEqual("Header A")
+
+  // Check uppercase
+  await setPropInFirstBlock(page, block, 'logseq.table.headers', 'uppercase')
+  await expect(await page.getByText('Header A', { exact: true }).innerText()).toEqual("HEADER A")
+
+  // Check lowercase
+  await setPropInFirstBlock(page, block, 'logseq.table.headers', 'lowercase')
+  await expect(await page.getByText('Header A', { exact: true }).innerText()).toEqual("header a")
+
+  // Check capitalize
+  await setPropInFirstBlock(page, block, 'logseq.table.headers', 'capitalize')
+  await expect(await page.getByText('Header A', { exact: true }).innerText()).toEqual("Header A")
+
+  // Check capitalize-first
+  await setPropInFirstBlock(page, block, 'logseq.table.headers', 'capitalize-first')
+  await expect(await page.getByText('Header A', { exact: true }).innerText()).toEqual("Header a")
+})
+
+test('table can configure logseq.table.borders', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a table
+  await page.keyboard.type('logseq.table.version:: 2')
+  await page.keyboard.press('Shift+Enter')
+  await inputSimpleTable(page)
+
+  // Check true (default)
+  await expect(await page.getByTestId('v2-table-container')).toHaveCSS("gap", /^[1-9].*/)
+
+  // Check true (explicit)
+  await setPropInFirstBlock(page, block, 'logseq.table.borders', 'true')
+  await expect(await page.getByTestId('v2-table-container')).toHaveCSS("gap", /^[1-9].*/)
+
+  // Check false
+  await setPropInFirstBlock(page, block, 'logseq.table.borders', 'false')
+  await expect(await page.getByTestId('v2-table-container')).not.toHaveCSS("gap", /^[1-9].*/)
+})
+
+test('table can configure logseq.table.stripes', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a table
+  await page.keyboard.type('logseq.table.version:: 2')
+  await page.keyboard.press('Shift+Enter')
+  await inputSimpleTable(page)
+  await page.waitForTimeout(KEY_DELAY)
+
+  // Check false (default)
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).toContain("bg-[color:var(--ls-primary-background-color)]")
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).toContain("bg-[color:var(--ls-primary-background-color)]")
+
+  // Check false (explicit)
+  await setPropInFirstBlock(page, block, 'logseq.table.stripes', 'false')
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).toContain("bg-[color:var(--ls-primary-background-color)]")
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).toContain("bg-[color:var(--ls-primary-background-color)]")
+
+  // Check false
+  await setPropInFirstBlock(page, block, 'logseq.table.stripes', 'true')
+  await expect(await page.getByText('A1', { exact: true }).getAttribute('class')).toContain("bg-[color:var(--ls-primary-background-color)]")
+  await expect(await page.getByText('A2', { exact: true }).getAttribute('class')).toContain("bg-[color:var(--ls-secondary-background-color)]")
+})
+
+test('table can configure logseq.table.compact', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a table
+  await page.keyboard.type('logseq.table.version:: 2')
+  await page.keyboard.press('Shift+Enter')
+  await inputSimpleTable(page)
+  await page.waitForTimeout(KEY_DELAY)
+
+  // Check false (default)
+  const defaultClasses = await page.getByText('A1', { exact: true }).getAttribute('class')
+
+  // Check false (explicit)
+  await setPropInFirstBlock(page, block, 'logseq.table.compact', 'false')
+  const falseClasses = await page.getByText('A1', { exact: true }).getAttribute('class')
+
+  // Check false
+  await setPropInFirstBlock(page, block, 'logseq.table.compact', 'true')
+  const trueClasses = await page.getByText('A1', { exact: true }).getAttribute('class')
+
+  const getPX = (str) => {
+    const match = str.match(/px-\[([0-9\.]*)[a-z]*\]/)
+    return match ? parseFloat(match[1]) : null
+  }
+
+  await expect(getPX(defaultClasses)).toEqual(getPX(falseClasses))
+  await expect(getPX(defaultClasses)).toBeGreaterThan(getPX(trueClasses))
+})
+
+test('table can configure logseq.table.cols::', async ({ page, block, graphDir }) => {
+  const pageTitle = await createRandomPage(page)
+
+  // create a v1 table 
+  await page.keyboard.type('logseq.table.version:: 2')
+  await page.keyboard.press('Shift+Enter')
+  await inputSimpleTable(page)
+
+  // check for default general config 
+  await expect(await page.getByText('A1', { exact: true })).toBeVisible()
+  await expect(await page.getByText('B1', { exact: true })).toBeVisible()
+
+  await setPropInFirstBlock(page, block, 'logseq.table.cols', 'Header A, Header B')
+  await expect(await page.getByText('A1', { exact: true })).toBeVisible()
+  await expect(await page.getByText('B1', { exact: true })).toBeVisible()
+
+  await setPropInFirstBlock(page, block, 'logseq.table.cols', 'Header A')
+  await expect(await page.getByText('A1', { exact: true })).toBeVisible()
+  await expect(await page.getByText('B1', { exact: true })).not.toBeVisible()
+
+  await setPropInFirstBlock(page, block, 'logseq.table.cols', 'Header B')
+  await expect(await page.getByText('A1', { exact: true })).not.toBeVisible()
+  await expect(await page.getByText('B1', { exact: true })).toBeVisible()
+})

+ 7 - 0
e2e-tests/utils.ts

@@ -206,3 +206,10 @@ export async function getIsWebAPIClipboardSupported(page: Page): Promise<boolean
   // @ts-ignore "clipboard-write" is not included in TS's type definition for permissionName
   return await queryPermission(page, "clipboard-write") && await doesClipboardItemExists(page)
 }
+
+export async function navigateToStartOfBlock(page: Page, block: Block) {
+  const selectionStart = await block.selectionStart()
+  for (let i = 0; i < selectionStart; i++) {
+    await page.keyboard.press('ArrowLeft')
+  }
+}

+ 38 - 6
e2e-tests/whiteboards.spec.ts

@@ -83,7 +83,7 @@ test('draw a rectangle', async ({ page }) => {
   const canvas = await page.waitForSelector('.logseq-tldraw')
   const bounds = (await canvas.boundingBox())!
 
-  await page.keyboard.press('r')
+  await page.keyboard.type('wr')
 
   await page.mouse.move(bounds.x + 5, bounds.y + 5)
   await page.mouse.down()
@@ -130,7 +130,7 @@ test('connect rectangles with an arrow', async ({ page }) => {
   const canvas = await page.waitForSelector('.logseq-tldraw')
   const bounds = (await canvas.boundingBox())!
 
-  await page.keyboard.press('c')
+  await page.keyboard.type('wc')
 
   await page.mouse.move(bounds.x + 20, bounds.y + 20)
   await page.mouse.down()
@@ -159,6 +159,38 @@ test('undo the delete action', async ({ page }) => {
   await expect(page.locator('.logseq-tldraw .tl-line-container')).toHaveCount(1)
 })
 
+test('convert the first rectangle to ellipse', async ({ page }) => {
+  await page.keyboard.press('Escape')
+  await page.waitForTimeout(1000)
+  await page.click('.logseq-tldraw .tl-box-container:first-of-type')
+  await page.mouse.move(0, 0)  // move mouse to trigger a rerender of the context bar
+  await page.click('.tl-context-bar .tl-geometry-tools-pane-anchor')
+  await page.click('.tl-context-bar .tl-geometry-toolbar [data-tool=ellipse]')
+
+  await expect(page.locator('.logseq-tldraw .tl-ellipse-container')).toHaveCount(1)
+  await expect(page.locator('.logseq-tldraw .tl-box-container')).toHaveCount(1)
+})
+
+test('change the color of the ellipse', async ({ page }) => {
+  await page.click('.tl-context-bar .tl-color-bg')
+  await page.click('.tl-context-bar .tl-color-palette .bg-red-500')
+
+  await expect(page.locator('.logseq-tldraw .tl-ellipse-container ellipse:last-of-type')).toHaveAttribute('fill', 'var(--ls-wb-background-color-red)')
+})
+
+test('undo the color switch', async ({ page }) => {
+  await page.keyboard.press(modKey + '+z')
+
+  await expect(page.locator('.logseq-tldraw .tl-ellipse-container ellipse:last-of-type')).toHaveAttribute('fill', 'var(--ls-wb-background-color-default)')
+})
+
+test('undo the shape conversion', async ({ page }) => {
+  await page.keyboard.press(modKey + '+z')
+
+  await expect(page.locator('.logseq-tldraw .tl-box-container')).toHaveCount(2)
+  await expect(page.locator('.logseq-tldraw .tl-ellipse-container')).toHaveCount(0)
+})
+
 test('locked elements should not be removed', async ({ page }) => {
   await page.keyboard.press('Escape')
   await page.waitForTimeout(1000)
@@ -205,7 +237,7 @@ test('create a block', async ({ page }) => {
   const canvas = await page.waitForSelector('.logseq-tldraw')
   const bounds = (await canvas.boundingBox())!
 
-  await page.keyboard.press('s')
+  await page.keyboard.type('ws')
   await page.mouse.dblclick(bounds.x + 5, bounds.y + 5)
   await page.waitForTimeout(100)
 
@@ -240,7 +272,7 @@ test('copy/paste url to create an iFrame shape', async ({ page }) => {
   const canvas = await page.waitForSelector('.logseq-tldraw')
   const bounds = (await canvas.boundingBox())!
 
-  await page.keyboard.press('t')
+  await page.keyboard.type('wt')
   await page.mouse.move(bounds.x + 5, bounds.y + 5)
   await page.mouse.down()
   await page.waitForTimeout(100)
@@ -259,7 +291,7 @@ test('copy/paste twitter status url to create a Tweet shape', async ({ page }) =
   const canvas = await page.waitForSelector('.logseq-tldraw')
   const bounds = (await canvas.boundingBox())!
 
-  await page.keyboard.press('t')
+  await page.keyboard.type('wt')
   await page.mouse.move(bounds.x + 5, bounds.y + 5)
   await page.mouse.down()
   await page.waitForTimeout(100)
@@ -278,7 +310,7 @@ test('copy/paste youtube video url to create a Youtube shape', async ({ page })
   const canvas = await page.waitForSelector('.logseq-tldraw')
   const bounds = (await canvas.boundingBox())!
 
-  await page.keyboard.press('t')
+  await page.keyboard.type('wt')
   await page.mouse.move(bounds.x + 5, bounds.y + 5)
   await page.mouse.down()
   await page.waitForTimeout(100)

+ 4 - 4
ios/App/App.xcodeproj/project.pbxproj

@@ -515,7 +515,7 @@
 				INFOPLIST_FILE = App/Info.plist;
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
-				MARKETING_VERSION = 0.9.4;
+				MARKETING_VERSION = 0.9.6;
 				OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -542,7 +542,7 @@
 				INFOPLIST_FILE = App/Info.plist;
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
-				MARKETING_VERSION = 0.9.4;
+				MARKETING_VERSION = 0.9.6;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -567,7 +567,7 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
-				MARKETING_VERSION = 0.9.4;
+				MARKETING_VERSION = 0.9.6;
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
@@ -594,7 +594,7 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
-				MARKETING_VERSION = 0.9.4;
+				MARKETING_VERSION = 0.9.6;
 				MTL_FAST_MATH = YES;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
 				PRODUCT_NAME = "$(TARGET_NAME)";

+ 33 - 3
ios/App/App/FsWatcher.swift

@@ -46,12 +46,16 @@ public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
     }
 
     public func receivedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?) {
+        guard let baseUrl = baseUrl else {
+            // unwatch, ignore incoming
+            return
+        }
         // NOTE: Event in js {dir path content stat{mtime}}
         switch event {
         case .Unlink:
             DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                 self.notifyListeners("watcher", data: ["event": "unlink",
-                                                       "dir": self.baseUrl?.description as Any,
+                                                       "dir": baseUrl.description as Any,
                                                        "path": url.description
                 ])
             }
@@ -61,8 +65,8 @@ public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
                 content = try? String(contentsOf: url, encoding: .utf8)
             }
             self.notifyListeners("watcher", data: ["event": event.description,
-                                                   "dir": baseUrl?.description as Any,
-                                                   "path": url.description,
+                                                   "dir": baseUrl.description as Any,
+                                                   "path": url.relativePath(from: baseUrl)?.precomposedStringWithCanonicalMapping as Any,
                                                    "content": content as Any,
                                                    "stat": ["mtime": metadata?.contentModificationTimestamp ?? 0,
                                                             "ctime": metadata?.creationTimestamp ?? 0,
@@ -265,3 +269,29 @@ public class PollingWatcher {
         self.metaDb = newMetaDb
     }
 }
+
+
+extension URL {
+    func relativePath(from base: URL) -> String? {
+        // Ensure that both URLs represent files:
+        guard self.isFileURL && base.isFileURL else {
+            return nil
+        }
+
+        // Remove/replace "." and "..", make paths absolute:
+        let destComponents = self.standardizedFileURL.pathComponents
+        let baseComponents = base.standardizedFileURL.pathComponents
+
+        // Find number of common path components:
+        var i = 0
+        while i < destComponents.count && i < baseComponents.count
+                && destComponents[i] == baseComponents[i] {
+            i += 1
+        }
+
+        // Build relative path:
+        var relComponents = Array(repeating: "..", count: baseComponents.count - i)
+        relComponents.append(contentsOf: destComponents[i...])
+        return relComponents.joined(separator: "/")
+    }
+}

+ 3 - 1
package.json

@@ -93,6 +93,7 @@
         "@logseq/capacitor-file-sync": "0.0.24",
         "@logseq/diff-merge": "^0.0.2",
         "@logseq/react-tweet-embed": "1.3.1-1",
+        "@radix-ui/colors": "^0.1.8",
         "@sentry/react": "^6.18.2",
         "@sentry/tracing": "^6.18.2",
         "@tabler/icons": "1.119.0",
@@ -134,6 +135,7 @@
         "sanitize-filename": "1.6.3",
         "send-intent": "3.0.11",
         "sse.js": "^0.6.1",
+        "tailwind-capitalize-first-letter": "^1.0.4",
         "threads": "1.6.5",
         "url": "^0.11.0",
         "yargs-parser": "20.2.4"
@@ -157,4 +159,4 @@
         "pixi-graph-fork/@pixi/mixin-get-child-by-name": "6.2.0",
         "pixi-graph-fork/@pixi/math": "6.2.0"
     }
-}
+}

+ 1 - 0
public/index.html

@@ -55,6 +55,7 @@
 <script defer src="/static/js/main.js"></script>
 <script defer src="/static/js/amplify.js"></script>
 <script defer src="/static/js/tabler.min.js"></script>
+<script defer src="/static/js/tabler.ext.js"></script>
 <script defer src="/static/js/code-editor.js"></script>
 <script defer src="/static/js/tldraw.js"></script>
 <script defer src="/static/js/excalidraw.js"></script>

+ 1 - 0
resources/electron.html

@@ -56,6 +56,7 @@ const portal = new MagicPortal(worker);
 <script defer src="./js/main.js"></script>
 <script defer src="./js/amplify.js"></script>
 <script defer src="./js/tabler.min.js"></script>
+<script defer src="./js/tabler.ext.js"></script>
 <script defer src="./js/code-editor.js"></script>
 <script defer src="./js/excalidraw.js"></script>
 <script defer src="./js/tldraw.js"></script>

+ 1 - 0
resources/index.html

@@ -55,6 +55,7 @@ const portal = new MagicPortal(worker);
 <script defer src="./js/main.js"></script>
 <script defer src="./js/amplify.js"></script>
 <script defer src="./js/tabler.min.js"></script>
+<script defer src="./js/tabler.ext.js"></script>
 <script defer src="./js/code-editor.js"></script>
 <script defer src="./js/excalidraw.js"></script>
 <script defer src="./js/tldraw.js"></script>

Разница между файлами не показана из-за своего большого размера
+ 10 - 0
resources/js/tabler.ext.js


+ 1 - 1
resources/package.json

@@ -1,7 +1,7 @@
 {
   "name": "Logseq",
   "productName": "Logseq",
-  "version": "0.9.4",
+  "version": "0.9.6",
   "main": "electron.js",
   "author": "Logseq",
   "license": "AGPL-3.0",

+ 6 - 6
shadow-cljs.edn

@@ -21,8 +21,8 @@
                          :depends-on #{:main}}
                         :tldraw
                         {:entries    [frontend.extensions.tldraw]
-                         :depends-on #{:main}}
-                        }
+                         :depends-on #{:main}}}
+                        
         :output-dir       "./static/js"
         :asset-path       "/static/js"
         :release          {:asset-path "https://asset.logseq.com/static/js"}
@@ -95,8 +95,8 @@
                                 :depends-on #{:main}}
                                :tldraw
                                {:entries    [frontend.extensions.tldraw]
-                                :depends-on #{:main}}
-                               }
+                                :depends-on #{:main}}}
+                               
                :output-dir       "./static/js/publishing"
                :asset-path       "static/js"
                :closure-defines  {frontend.config/PUBLISHING true
@@ -109,5 +109,5 @@
                                                        :redef false}}
                :devtools         {:before-load frontend.core/stop
                                   :after-load  frontend.core/start
-                                  :preloads    [devtools.preload]}}
-  }}
+                                  :preloads    [devtools.preload]}}}}
+  

+ 76 - 66
src/main/frontend/components/block.cljs

@@ -23,8 +23,8 @@
             [frontend.context.i18n :refer [t]]
             [frontend.date :as date]
             [frontend.db :as db]
-            [frontend.db-mixins :as db-mixins]
             [frontend.db.model :as model]
+            [frontend.db-mixins :as db-mixins]
             [frontend.extensions.highlight :as highlight]
             [frontend.extensions.latex :as latex]
             [frontend.extensions.lightbox :as lightbox]
@@ -53,6 +53,7 @@
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.outliner.tree :as tree]
             [frontend.security :as security]
+            [frontend.shui :refer [get-shui-component-version make-shui-context]]
             [frontend.state :as state]
             [frontend.template :as template]
             [frontend.ui :as ui]
@@ -74,6 +75,7 @@
             [logseq.graph-parser.util.block-ref :as block-ref]
             [logseq.graph-parser.util.page-ref :as page-ref]
             [logseq.graph-parser.whiteboard :as gp-whiteboard]
+            [logseq.shui.core :as shui]
             [medley.core :as medley]
             [promesa.core :as p]
             [reitit.frontend.easy :as rfe]
@@ -293,8 +295,8 @@
                          (js/setTimeout #(reset! *resizing-image? false) 200)))
           :onClick (fn [e]
                      (when @*resizing-image? (util/stop e)))}
-          (and (:width metadata) (not (util/mobile?)))
-          (assoc :style {:width (:width metadata)}))
+         (and (:width metadata) (not (util/mobile?)))
+         (assoc :style {:width (:width metadata)}))
         {})
       [:div.asset-container {:key "resize-asset-container"}
        [:img.rounded-sm.relative
@@ -938,9 +940,8 @@
              inner)])
         [:span.warning.mr-1 {:title "Block ref invalid"}
          (block-ref/->block-ref id)]))
-  [:span.warning.mr-1 {:title "Block ref invalid"}
-    (block-ref/->block-ref id)]
-))
+    [:span.warning.mr-1 {:title "Block ref invalid"}
+      (block-ref/->block-ref id)]))
 
 (defn inline-text
   ([format v]
@@ -1119,8 +1120,8 @@
         {:href      (path/path-join "file://" path)
          :data-href path
          :target    "_blank"}
-         title
-         (assoc :title title))
+        title
+        (assoc :title title))
        (map-inline config label)))
 
     :else
@@ -1213,8 +1214,8 @@
            (cond->
             {:href (ar-url->http-url href)
              :target "_blank"}
-             title
-             (assoc :title title))
+            title
+            (assoc :title title))
            (map-inline config label))
 
           :else
@@ -1223,11 +1224,11 @@
            (cond->
             {:href href
              :target "_blank"}
-             title
-             (assoc :title title))
+            title
+            (assoc :title title))
            (map-inline config label)))))))
 
-(declare ->hiccup)
+(declare ->hiccup inline)
 
 (defn wrap-query-components
   [config]
@@ -1236,7 +1237,8 @@
           :->elem ->elem
           :page-cp page-cp
           :inline-text inline-text
-          :map-inline map-inline}))
+          :map-inline map-inline
+          :inline inline}))
 
 ;;;; Macro component render functions
 (defn- macro-query-cp
@@ -1751,7 +1753,7 @@
         order-list?        (boolean own-number-list?)
         order-list-idx     (:own-order-list-index config)
         collapsable?       (editor-handler/collapsable? uuid {:semantic? true})]
-    [:div.block-control-wrap.mr-1.flex.flex-row.items-center.sm:mr-2
+    [:div.block-control-wrap.flex.flex-row.items-center
      {:class (util/classnames [{:is-order-list order-list?
                                 :bullet-closed collapsed?}])}
      (when (or (not fold-button-right?) collapsable?)
@@ -2222,7 +2224,10 @@
                 (editor-handler/clear-selection!)
                 (editor-handler/unhighlight-blocks!)
                 (let [f #(let [block (or (db/pull [:block/uuid (:block/uuid block)]) block)
-                               cursor-range (util/caret-range (gdom/getElement block-id))
+                               cursor-range (some-> (gdom/getElement block-id)
+                                                    (dom/by-class "block-content-wrapper")
+                                                    first
+                                                    util/caret-range)
                                {:block/keys [content format]} block
                                content (->> content
                                             (property/remove-built-in-properties format)
@@ -2260,7 +2265,7 @@
              (and (not block-content?)
                   (seq (:block/children block))
                   (= move-to :nested)))
-          (dnd-separator move-to block-content?))))))
+         (dnd-separator move-to block-content?))))))
 
 (defn clock-summary-cp
   [block body]
@@ -2331,19 +2336,19 @@
         content (if (string? content) (string/trim content) "")
         mouse-down-key (if (util/ios?)
                          :on-click
-                         :on-mouse-down ; TODO: it seems that Safari doesn't work well with on-mouse-down
-                         )
+                         :on-mouse-down) ; TODO: it seems that Safari doesn't work well with on-mouse-down
+
         attrs (cond->
                {:blockid       (str uuid)
                 :data-type (name block-type)
                 :style {:width "100%" :pointer-events (when stop-events? "none")}}
 
-                (not (string/blank? (:hl-color properties)))
-                (assoc :data-hl-color (:hl-color properties))
+               (not (string/blank? (:hl-color properties)))
+               (assoc :data-hl-color (:hl-color properties))
 
-                (not block-ref?)
-                (assoc mouse-down-key (fn [e]
-                                        (block-content-on-mouse-down e block block-id content edit-input-id))))]
+               (not block-ref?)
+               (assoc mouse-down-key (fn [e]
+                                       (block-content-on-mouse-down e block block-id content edit-input-id))))]
     [:div.block-content.inline
      (cond-> {:id (str "block-content-" uuid)
               :on-mouse-up (fn [e]
@@ -3021,8 +3026,8 @@
          :li
          (cond->
           {:checked checked?}
-           number
-           (assoc :value number))
+          number
+          (assoc :value number))
          (vec-cat
           [(->elem
             :p
@@ -3040,52 +3045,55 @@
 
 (defn table
   [config {:keys [header groups col_groups]}]
-  (let [tr (fn [elm cols]
-             (->elem
-              :tr
-              (mapv (fn [col]
-                      (->elem
-                       elm
-                       {:scope "col"
-                        :class "org-left"}
-                       (map-inline config col)))
-                    cols)))
-        tb-col-groups (try
-                        (mapv (fn [number]
-                                (let [col-elem [:col {:class "org-left"}]]
-                                  (->elem
-                                   :colgroup
-                                   (repeat number col-elem))))
-                              col_groups)
-                        (catch :default _e
-                          []))
-        head (when header
-               [:thead (tr :th header)])
-        groups (mapv (fn [group]
-                       (->elem
-                        :tbody
-                        (mapv #(tr :td %) group)))
-                     groups)]
-    [:div.table-wrapper
-     (->elem
-      :table
-      {:class "table-auto"
-       :border 2
-       :cell-spacing 0
-       :cell-padding 6
-       :rules "groups"
-       :frame "hsides"}
-      (vec-cat
-       tb-col-groups
-       (cons head groups)))]))
+  (case (get-shui-component-version :table config)
+    2 (shui/table-v2 {:data (concat [[header]] groups)}
+                     (make-shui-context config inline))
+    1 (let [tr (fn [elm cols]
+                 (->elem
+                  :tr
+                  (mapv (fn [col]
+                          (->elem
+                           elm
+                           {:scope "col"
+                            :class "org-left"}
+                           (map-inline config col)))
+                        cols)))
+            tb-col-groups (try
+                            (mapv (fn [number]
+                                    (let [col-elem [:col {:class "org-left"}]]
+                                      (->elem
+                                       :colgroup
+                                       (repeat number col-elem))))
+                                  col_groups)
+                            (catch :default _e
+                              []))
+            head (when header
+                   [:thead (tr :th header)])
+            groups (mapv (fn [group]
+                           (->elem
+                            :tbody
+                            (mapv #(tr :td %) group)))
+                         groups)]
+        [:div.table-wrapper
+         (->elem
+          :table
+          {:class "table-auto"
+           :border 2
+           :cell-spacing 0
+           :cell-padding 6
+           :rules "groups"
+           :frame "hsides"}
+          (vec-cat
+           tb-col-groups
+           (cons head groups)))])))
 
 (defn logbook-cp
   [log]
   (let [clocks (filter #(string/starts-with? % "CLOCK:") log)
-        clocks (reverse (sort-by str clocks))
+        clocks (reverse (sort-by str clocks))]
         ;; TODO: display states change log
         ; states (filter #(not (string/starts-with? % "CLOCK:")) log)
-        ]
+
     (when (seq clocks)
       (let [tr (fn [elm cols] (->elem :tr
                                       (mapv (fn [col] (->elem elm col)) cols)))
@@ -3110,6 +3118,8 @@
   [config col]
   (map #(inline config %) col))
 
+(declare ->hiccup)
+
 (rum/defc src-cp < rum/static
   [config options html-export?]
   (when options

+ 9 - 4
src/main/frontend/components/block.css

@@ -201,11 +201,14 @@
 }
 
 .block-control-wrap {
-  height: 24px;
-  margin-top: 0;
+  @apply h-[24px] mt-0 pr-[6px];
 
   &.is-order-list {
-    @apply relative right-0 mr-0;
+    @apply mr-0 pr-0;
+
+    .bullet-link-wrap {
+      @apply relative left-[-3px];
+    }
   }
 }
 
@@ -531,12 +534,14 @@
   }
 
   &.as-order-list {
-    @apply w-[28px] whitespace-nowrap justify-start;
+    @apply w-[22px] whitespace-nowrap justify-center pl-[3px];
   }
 
   .bullet {
     @apply rounded-full w-[6px] h-[6px];
 
+    font-size: 14px;
+
     background-color: var(--ls-block-bullet-color, #394b59);
     transition: transform 0.2s;
 

+ 2 - 2
src/main/frontend/components/datetime.cljs

@@ -46,7 +46,7 @@
   (let [show? (rum/react *show-repeater?)]
     (if (or show? (and num duration kind))
       [:div.w.full.flex.flex-row.justify-left
-       [:input#repeater-num.form-input.mt-1.w-8.px-1.sm:w-20.sm:px-2.text-center
+       [:input#repeater-num.form-input.w-8.mr-2.px-1.sm:w-20.sm:px-2.text-center
         {:default-value num
          :on-change (fn [event]
                       (let [value (util/evalue event)]
@@ -66,7 +66,7 @@
           (swap! *timestamp assoc-in [:repeater :duration] value))
         nil)
 
-       [:a.ml-1.self-center {:on-click (fn []
+       [:a.ml-2.self-center {:on-click (fn []
                                          (reset! *show-repeater? false)
                                          (swap! *timestamp assoc :repeater {}))}
         svg/close]]

+ 1 - 1
src/main/frontend/components/file_sync.cljs

@@ -588,7 +588,7 @@
          (async/go
            (set-loading? true)
            (try
-             (let [files (async/<! (file-sync-handler/fetch-page-file-versions graph-uuid page-entity))]
+             (let [files (async/<! (file-sync-handler/<fetch-page-file-versions graph-uuid page-entity))]
                (set-version-files files)
                (set-page-fn (first files))
                (set-list-ready? true))

+ 27 - 17
src/main/frontend/components/page.cljs

@@ -38,7 +38,9 @@
             [logseq.graph-parser.util :as gp-util]
             [medley.core :as medley]
             [reitit.frontend.easy :as rfe]
-            [rum.core :as rum]))
+            [rum.core :as rum]
+            [logseq.graph-parser.util.page-ref :as page-ref]
+            [logseq.graph-parser.mldoc :as gp-mldoc]))
 
 (defn- get-page-name
   [state]
@@ -261,7 +263,7 @@
                     :else
                     (state/set-modal! (confirm-fn)))
                   (util/stop e))]
-    [:span.absolute.inset-0.edit-input-wrapper
+    [:span.absolute.inset-0.edit-input-wrapper.z-10
      {:class (util/classnames [{:editing @*edit?}])}
      [:input.edit-input
       {:type          "text"
@@ -296,7 +298,8 @@
            (assoc state ::title-value (atom (nth (:rum/args state) 2))))}
   [state page-name icon title _format fmt-journal?]
   (when title
-    (let [*title-value (get state ::title-value)
+    (let [page (when page-name (db/entity [:block/name page-name]))
+          *title-value (get state ::title-value)
           *edit? (get state ::edit?)
           *input-value (get state ::input-value)
           repo (state/get-current-repo)
@@ -305,7 +308,9 @@
           untitled? (and whiteboard-page? (parse-uuid page-name)) ;; normal page cannot be untitled right?
           title (if hls-page?
                   [:a.asset-ref (pdf-utils/fix-local-asset-pagename title)]
-                  (if fmt-journal? (date/journal-title->custom-format title) title))
+                  (if fmt-journal?
+                    (date/journal-title->custom-format title)
+                    title))
           old-name (or title page-name)]
       [:h1.page-title.flex.cursor-pointer.gap-1.w-full
        {:class (when-not whiteboard-page? "title")
@@ -313,16 +318,17 @@
                          (when (util/right-click? e)
                            (state/set-state! :page-title/context {:page page-name})))
         :on-click (fn [e]
-                    (.preventDefault e)
-                    (if (gobj/get e "shiftKey")
-                      (when-let [page (db/pull repo '[*] [:block/name page-name])]
-                        (state/sidebar-add-block!
-                         repo
-                         (:db/id page)
-                         :page))
-                      (when (and (not hls-page?) (not fmt-journal?) (not config/publishing?))
-                        (reset! *input-value (if untitled? "" old-name))
-                        (reset! *edit? true))))}
+                    (when-not (= (.-nodeName (.-target e)) "INPUT")
+                      (.preventDefault e)
+                      (if (gobj/get e "shiftKey")
+                        (when-let [page (db/pull repo '[*] [:block/name page-name])]
+                          (state/sidebar-add-block!
+                           repo
+                           (:db/id page)
+                           :page))
+                        (when (and (not hls-page?) (not fmt-journal?) (not config/publishing?))
+                          (reset! *input-value (if untitled? "" old-name))
+                          (reset! *edit? true)))))}
        (when (not= icon "") [:span.page-icon icon])
        [:div.page-title-sizer-wrapper.relative
         (when @*edit?
@@ -338,9 +344,13 @@
          {:data-value @*input-value
           :data-ref   page-name
           :style      {:opacity (when @*edit? 0)}}
-         (cond @*edit? [:span {:style {:white-space "pre"}} (rum/react *input-value)]
-               untitled? [:span.opacity-50 (t :untitled)]
-               :else title)]]])))
+         (let [nested? (and (string/includes? title page-ref/left-brackets)
+                            (string/includes? title page-ref/right-brackets))]
+           (cond @*edit? [:span {:style {:white-space "pre"}} (rum/react *input-value)]
+                 untitled? [:span.opacity-50 (t :untitled)]
+                 nested? (component-block/map-inline {} (gp-mldoc/inline->edn title (gp-mldoc/default-config
+                                                                                     (:block/format page))))
+                 :else title))]]])))
 
 (defn- page-mouse-over
   [e *control-show? *all-collapsed?]

+ 2 - 1
src/main/frontend/components/page.css

@@ -249,6 +249,7 @@
     box-shadow: none;
     padding-left: 5px;
     padding-top: 5px;
+    padding-bottom: 4px;
 
     &-wrapper {
       @apply rounded;
@@ -276,7 +277,7 @@ a.page-title {
   }
 
   > .title {
-    @apply w-full pointer-events-none overflow-hidden overflow-ellipsis;
+    @apply w-full overflow-hidden overflow-ellipsis;
   }
 
   .edit-input {

+ 10 - 0
src/main/frontend/components/plugins.css

@@ -836,6 +836,16 @@
     > .injected-ui-item-pagebar {
       @apply pr-3 opacity-30 hover:opacity-100 transition-opacity;
     }
+
+    > .list-wrap {
+      @apply flex items-center flex-nowrap overflow-x-hidden pt-[14px];
+    }
+
+    a.button {
+      @apply flex items-center;
+
+      color: var(--ls-primary-text-color);
+    }
   }
 
   .toolbar-plugins-manager {

+ 4 - 3
src/main/frontend/components/query.cljs

@@ -50,7 +50,7 @@
            view-f
            result
            group-by-page?]}]
-  (let [{:keys [->hiccup ->elem inline-text page-cp map-inline]} config
+  (let [{:keys [->hiccup ->elem inline-text page-cp map-inline inline]} config
         *query-error query-error-atom
         only-blocks? (:block/uuid (first result))
         blocks-grouped-by-page? (and group-by-page?
@@ -59,6 +59,7 @@
                                      (:block/name (ffirst result))
                                      (:block/uuid (first (second (first result))))
                                      true)]
+    (println "this should be a function" inline)
     (if @*query-error
       (do
         (log/error :exception @*query-error)
@@ -77,10 +78,10 @@
            (util/hiccup-keywordize result))
 
          page-list?
-         (query-table/result-table config current-block result {:page? true} map-inline page-cp ->elem inline-text)
+         (query-table/result-table config current-block result {:page? true} map-inline page-cp ->elem inline-text inline)
 
          table?
-         (query-table/result-table config current-block result {:page? false} map-inline page-cp ->elem inline-text)
+         (query-table/result-table config current-block result {:page? false} map-inline page-cp ->elem inline-text inline)
 
          (and (seq result) (or only-blocks? blocks-grouped-by-page?))
          (->hiccup result

+ 79 - 54
src/main/frontend/components/query_table.cljs

@@ -3,13 +3,15 @@
             [frontend.date :as date]
             [frontend.db :as db]
             [frontend.db.query-dsl :as query-dsl]
+            [frontend.format.block :as block]
             [frontend.handler.common :as common-handler]
             [frontend.handler.editor.property :as editor-property]
+            [frontend.shui :refer [get-shui-component-version make-shui-context]]
             [frontend.state :as state]
             [frontend.util :as util]
             [frontend.util.clock :as clock]
             [frontend.util.property :as property]
-            [frontend.format.block :as block]
+            [logseq.shui.core :as shui]
             [medley.core :as medley]
             [rum.core :as rum]
             [logseq.graph-parser.text :as text]))
@@ -42,9 +44,9 @@
 (defn- locale-compare
   "Use locale specific comparison for strings and general comparison for others."
   [x y]
-    (if (and (number? x) (number? y))
-      (< x y)
-      (.localeCompare (str x) (str y) (state/sub :preferred-language) #js {:numeric true})))
+  (if (and (number? x) (number? y))
+    (< x y)
+    (.localeCompare (str x) (str y) (state/sub :preferred-language) #js {:numeric true})))
 
 (defn- sort-result [result {:keys [sort-by-column sort-desc? sort-nlp-date? page?]}]
   (if (some? sort-by-column)
@@ -98,7 +100,7 @@
         keys (if page? (distinct (concat keys [:created-at :updated-at])) keys)]
     keys))
 
-(defn- get-columns [current-block result {:keys [page?]}]
+(defn get-columns [current-block result {:keys [page?]}]
   (let [query-properties (some-> (get-in current-block [:block/properties :query-properties] "")
                                  (common-handler/safe-read-string "Parsing query properties failed"))
         query-properties (if page? (remove #{:block} query-properties) query-properties)
@@ -152,10 +154,21 @@
                    ;; Fallback to original properties for page blocks
                    (get-in row [:block/properties column])))]))
 
+(defn build-column-text [row column]
+  (case column 
+    :page  (or (get-in row [:block/page :block/original-name])
+               (get-in row [:block/original-name])
+               (get-in row [:block/content]))
+    :block (or (get-in row [:block/original-name]) 
+               (get-in row [:block/content])) 
+           (or (get-in row [:block/properties column])
+               (get-in row [:block/properties-text-values column])
+               (get-in row [(keyword :block column)]))))
+
 (rum/defcs result-table < rum/reactive
   (rum/local false ::select?)
   (rum/local false ::mouse-down?)
-  [state config current-block result {:keys [page?]} map-inline page-cp ->elem inline-text]
+  [state config current-block result {:keys [page?]} map-inline page-cp ->elem inline-text inline]
   (when current-block
     (let [select? (get state ::select?)
           *mouse-down? (::mouse-down? state)
@@ -168,53 +181,65 @@
           ;; as user needs to know if there result is sorted
           sort-state (get-sort-state current-block)
           sort-result (sort-result result (assoc sort-state :page? page?))
-          property-separated-by-commas? (partial text/separated-by-commas? (state/get-config))]
-      [:div.overflow-x-auto {:on-mouse-down (fn [e] (.stopPropagation e))
-                             :style {:width "100%"}
-                             :class (when-not page? "query-table")}
-       [:table.table-auto
-        [:thead
-         [:tr.cursor
-          (for [column columns]
-            (let [title (if (and (= column :clock-time) (integer? clock-time-total))
-                             (util/format "clock-time(total: %s)" (clock/seconds->days:hours:minutes:seconds
-                                                                   clock-time-total))
-                             (name column))]
-              (sortable-title title column sort-state (:block/uuid current-block))))]]
-        [:tbody
-         (for [row sort-result]
-           (let [format (:block/format row)]
+          property-separated-by-commas? (partial text/separated-by-commas? (state/get-config))
+          table-version (get-shui-component-version :table config)
+          result-as-text (for [row sort-result]
+                           (for [column columns] 
+                             (build-column-text row column)))
+          render-column-value (fn [row-format cell-format value]
+                                (cond 
+                                  ;; elements should be rendered as they are provided
+                                  (= :element cell-format) value 
+                                  ;; collections are treated as a comma separated list of page-cps
+                                  (coll? value) (->> (map #(page-cp {} {:block/name %}) value)
+                                                     (interpose [:span ", "]))
+                                  ;; boolean values need to first be stringified
+                                  (boolean? value) (str value) 
+                                  ;; string values will attempt to be rendered as pages, falling back to 
+                                  ;; inline-text when no page entity is found
+                                  (string? value) (if-let [page (db/entity [:block/name (util/page-name-sanity-lc value)])]
+                                                    (page-cp {} page) 
+                                                    (inline-text row-format value))
+                                  ;; anything else should just be rendered as provided
+                                  :else value))]
+                      
+      (case table-version
+        2 (shui/table-v2 {:data (conj [[columns]] result-as-text)} 
+                         (make-shui-context config inline))
+        1 [:div.overflow-x-auto {:on-mouse-down (fn [e] (.stopPropagation e))
+                                 :style {:width "100%"}
+                                 :class (when-not page? "query-table")}
+           [:table.table-auto
+            [:thead
              [:tr.cursor
               (for [column columns]
-                (let [value (build-column-value row
-                                                column
-                                                {:page? page?
-                                                 :->elem ->elem
-                                                 :map-inline map-inline
-                                                 :config config
-                                                 :comma-separated-property? (property-separated-by-commas? column)})]
-                  [:td.whitespace-nowrap {:on-mouse-down (fn []
-                                                           (reset! *mouse-down? true)
-                                                           (reset! select? false))
-                                          :on-mouse-move (fn [] (reset! select? true))
-                                          :on-mouse-up (fn []
-                                                         (when (and @*mouse-down? (not @select?))
-                                                           (state/sidebar-add-block!
-                                                            (state/get-current-repo)
-                                                            (:db/id row)
-                                                            :block-ref)
-                                                           (reset! *mouse-down? false)))}
-                   (when value
-                     (if (= :element (first value))
-                       (second value)
-                       (let [value (second value)]
-                         (if (coll? value)
-                           (let [vals (for [row value]
-                                        (page-cp {} {:block/name row}))]
-                             (interpose [:span ", "] vals))
-                           (cond
-                             (boolean? value) (str value)
-                             (string? value) (if-let [page (db/entity [:block/name (util/page-name-sanity-lc value)])]
-                                               (page-cp {} page)
-                                               (inline-text format value))
-                             :else value)))))]))]))]]])))
+                (let [title (if (and (= column :clock-time) (integer? clock-time-total))
+                              (util/format "clock-time(total: %s)" (clock/seconds->days:hours:minutes:seconds
+                                                                    clock-time-total))
+                              (name column))]
+                  (sortable-title title column sort-state (:block/uuid current-block))))]]
+            [:tbody
+             (for [row sort-result]
+               (let [format (:block/format row)]
+                 [:tr.cursor
+                  (for [column columns]
+                    (let [value (build-column-value row
+                                                    column
+                                                    {:page? page?
+                                                     :->elem ->elem
+                                                     :map-inline map-inline
+                                                     :config config
+                                                     :comma-separated-property? (property-separated-by-commas? column)})]
+                      [:td.whitespace-nowrap {:on-mouse-down (fn []
+                                                               (reset! *mouse-down? true)
+                                                               (reset! select? false))
+                                              :on-mouse-move (fn [] (reset! select? true))
+                                              :on-mouse-up (fn []
+                                                             (when (and @*mouse-down? (not @select?))
+                                                               (state/sidebar-add-block!
+                                                                (state/get-current-repo)
+                                                                (:db/id row)
+                                                                :block-ref)
+                                                               (reset! *mouse-down? false)))}
+                       (when value
+                         (apply render-column-value format value))]))]))]]]))))

+ 12 - 8
src/main/frontend/components/settings.cljs

@@ -668,14 +668,20 @@
   []
   [:div.panel-wrap
    [:div.text-sm.my-4
+    (ui/admonition
+     :tip
+     [:p "If you have Logseq Sync enabled, you can view a page's edit history directly. This section is for tech-savvy only."])
     [:span.text-sm.opacity-50.my-4
-     "You can view a page's edit history by clicking the three horizontal dots "
-     "in the top-right corner and selecting \"View page history\". "
-     "Logseq uses "]
+     "To view page's edit history, click the three horizontal dots in the top-right corner and select \"View page history\"."]
+    [:br][:br]
+    [:span.text-sm.opacity-50.my-4
+     "For professional users, Logseq also supports using "]
     [:a {:href "https://git-scm.com/" :target "_blank"}
      "Git"]
     [:span.text-sm.opacity-50.my-4
-     " for version control."]]
+     " for version control."]
+    [:span.text-sm.opacity-50.my-4
+     "Use Git at your own risk as general Git issues are not supported by the Logseq team"]]
    [:br]
    (switch-git-auto-commit-row t)
    (git-auto-commit-seconds t)
@@ -878,9 +884,7 @@
               [[:general "general" (t :settings-page/tab-general) (ui/icon "adjustments")]
                [:editor "editor" (t :settings-page/tab-editor) (ui/icon "writing")]
 
-               (when (and
-                      (util/electron?)
-                      (not (file-sync-handler/synced-file-graph? current-repo)))
+               (when (util/electron?)
                  [:git "git" (t :settings-page/tab-version-control) (ui/icon "history")])
 
                ;; (when (util/electron?)
@@ -890,7 +894,7 @@
 
                [:ai "ai" "AI" (ui/icon "wand")]
 
-               [:features "features" (t :settings-page/tab-features) (ui/icon "square-asterisk")]
+               [:features "features" (t :settings-page/tab-features) (ui/icon "app-feature")]
 
                (when plugins-of-settings
                  [:plugins-setting "plugins" (t :settings-of-plugins) (ui/icon "puzzle")])]]

+ 8 - 10
src/main/frontend/db/model.cljs

@@ -770,7 +770,11 @@ independent of format as format specific heading characters are stripped"
                                         :include-start? true
                                         :scoped-block-id scoped-block-id}))
 
-      (contains? #{:save-block :delete-blocks} outliner-op)
+      (and (= :delete-blocks outliner-op)
+           (<= (count @result) initial-blocks-length)) ; load more blocks
+      nil
+
+      (= :save-block outliner-op)
       @result
 
       (contains? #{:insert-blocks :collapse-expand-blocks :move-blocks} outliner-op)
@@ -845,6 +849,7 @@ independent of format as format specific heading characters are stripped"
                                                         (db-utils/pull repo-url pull-keys id))) block-eids)
                                              (db-utils/pull-many repo-url pull-keys block-eids))
                                     blocks (remove (fn [b] (nil? (:block/content b))) blocks)]
+
                                 (map (fn [b] (assoc b :block/page bare-page-map)) blocks)))}
                  nil)
         react)))))
@@ -957,15 +962,8 @@ independent of format as format specific heading characters are stripped"
   "Doesn't include nested children."
   [repo block-uuid]
   (when-let [db (conn/get-db repo)]
-    (-> (d/q
-         '[:find [(pull ?b [*]) ...]
-           :in $ ?parent-id
-           :where
-           [?parent :block/uuid ?parent-id]
-           [?b :block/parent ?parent]]
-         db
-         block-uuid)
-        (sort-by-left (db-utils/entity [:block/uuid block-uuid])))))
+    (when-let [parent (db-utils/entity repo [:block/uuid block-uuid])]
+      (sort-by-left (:block/_parent parent) parent))))
 
 (defn get-block-children
   "Including nested children."

+ 1 - 1
src/main/frontend/db/react.cljs

@@ -16,7 +16,7 @@
 ;;; keywords specs for reactive query, used by `react/q` calls
 ;; ::block
 ;; pull-block react-query
-(s/def ::block (s/tuple #(= ::block %) uuid?))
+(s/def ::block (s/tuple #(= ::block %) int?))
 ;; ::page-blocks
 ;; get page-blocks react-query
 (s/def ::page-blocks (s/tuple #(= ::page-blocks %) int?))

+ 125 - 1
src/main/frontend/dicts.cljc

@@ -372,6 +372,8 @@
 
         :file-sync/other-user-graph "Current local graph is bound to other user's remote graph. So can't start syncing."
         :file-sync/graph-deleted "The current remote graph has been deleted"
+        :file-sync/rsapi-cannot-upload-err "Unable to start synchronization, please check if the local time is correct."
+
 
         :notification/clear-all "Clear all"}
 
@@ -1684,6 +1686,7 @@
 
            :file-sync/other-user-graph "当前本地图谱绑定在其他用户的远程图谱上。因此无法启动同步。"
            :file-sync/graph-deleted "当前远程图谱已经删除"
+           :file-sync/rsapi-cannot-upload-err "无法同步,请检查本机时间是否准确"
 
            :notification/clear-all "清除全部通知"}
 
@@ -2728,7 +2731,116 @@
            :asset/maximize "Maksimer bilde"
            :asset/open-in-browser "Åpne bilde i nettleser"
            :asset/show-in-folder "Vis bilde i mappe"
-           :linked-references/filter-search "Søk i lenkede referanser"}
+           :linked-references/filter-search "Søk i lenkede referanser"
+           :all-whiteboards "Alle whiteboard"
+           :auto-heading "Automatisk overskrift"
+           :heading "Overskrift {1}"
+           :new-whiteboard "Nytt whiteboard"
+           :remove-heading "Fjern overskrift"
+           :untitled "Uten navn"
+           :accessibility/skip-to-main-content "Hopp til hovedinnhold"
+           :color/blue "Blå"
+           :color/gray "Grå"
+           :color/green "Grønn"
+           :color/pink "Rosa"
+           :color/purple "Lilla"
+           :color/red "Rød"
+           :color/yellow "Gul"
+           :content/copy-block-url "Kopier blokk URL"
+           :content/copy-export-as "Kopier / Eksporter som.."
+           :content/copy-ref "Kopier denne referansen"
+           :content/delete-ref "Slett denne referansen"
+           :content/replace-with-embed "Erstatt med innebygging"
+           :content/replace-with-text "Erstatt med tekst"
+           :context-menu/input-template-name "Hva heter malen?"
+           :context-menu/make-a-flashcard "Lag et Flashcard"
+           :context-menu/make-a-template "Lag en Mal"
+           :context-menu/preview-flashcard "Forhåndsvis Flashcard"
+           :context-menu/template-exists-warning "Malen eksisterer allerde!"
+           :context-menu/template-include-parent-block "Inkluder overordnet blokk i malen?"
+           :context-menu/toggle-number-list "Veksle nummerliste"
+           :dev/show-block-ast "(Dev) Vis blokk AST"
+           :dev/show-block-data "(Dev) Vis blokk data"
+           :dev/show-page-ast "(Dev) Vis side AST"
+           :dev/show-page-data "(Dev) Vis side data"
+           :editor/collapse-block-children "Skjul alle"
+           :editor/cycle-todo "Roterer TODO statusen for gjeldende element"
+           :editor/delete-selection "Slett valgte blokker"
+           :editor/expand-block-children "Utvid alle"
+           :file/validate-existing-file-error "Siden eksisterer allerede i en annen fil: {1}, nåværen..."
+           :file-rn/all-action "Utfør alle Handlinger!"
+           :file-rn/apply-rename "Utfør omdøping av filen"
+           :file-rn/close-panel "Lukk Panel"
+           :file-rn/confirm-proceed "Oppdater format!"
+           :file-rn/filename-desc-1 "Denne innstillingen konfigurerer hvordan en side blir lagret til en ..."
+           :file-rn/filename-desc-2 "Noen tegn som \"/\" eller \"?\" er ikke gyldige for en..."
+           :file-rn/filename-desc-3 "Logseq erstatter ugyldige tegn med deres URL ..."
+           :file-rn/filename-desc-4 "Skilletegnet for navnerom \"/\" brukes også av \"_..."
+           :file-rn/format-deprecated "Du bruker for øyeblikket et utdatert format. Oppdat..."
+           :file-rn/instruct-1 "Det er en to-trinns prosess å oppdatere formatet for filnavn:"
+           :file-rn/instruct-2 "1. Klikk "
+           :file-rn/instruct-3 "2. Følg instruksjonene under for å gi filen et nytt navn..."
+           :file-rn/legend "🟢 Valgfri omdøping; 🟡 Omdøping kreves..."
+           :file-rn/need-action "Omdøping av fil er anbefalt for å matche de nye..."
+           :file-rn/no-action "Bra jobba! Well done! Ingen ytterligere tiltak kreves."
+           :file-rn/optional-rename "Forslag: "
+           :file-rn/or-select-actions " eller gi filer nytt navn individuelt under, så "
+           :file-rn/or-select-actions-2 ". Disse handlingene er ikke tilgjengelige når du lukker ..."
+           :file-rn/otherwise-breaking "Eller tittelen vil bli"
+           :file-rn/re-index "Re-indksering er sterkt anbefalt etter at filene er..."
+           :file-rn/rename "Omdøp fil \"{1}\" til \"{2}\""
+           :file-rn/select-confirm-proceed "Dev: skriv format"
+           :file-rn/select-format "(Uviklermodus Operasjon, Farlig!) Velg filenav..."
+           :file-rn/suggest-rename "Handling kreves: "
+           :file-rn/unreachable-title "Advarsel! Navnet på siden vil bli {1} under nåvære.."
+           :left-side-bar/create "Opprett"
+           :left-side-bar/new-whiteboard "Nytt whiteboard"
+           :notification/clear-all "Fjern alt"
+           :on-boarding/tour-whiteboard-home "{1} Hjem for dine whiteboards"
+           :on-boarding/tour-whiteboard-home-description "Whiteboards har sin egen seksjon i appen hvo..."
+           :on-boarding/tour-whiteboard-new "{1} Lag nytt whiteboard"
+           :on-boarding/tour-whiteboard-new-description "Det er mange måter å lage et nytt whiteboard på..."
+           :on-boarding/welcome-whiteboard-modal-description "Whiteboards er et fantastisk verktøy for brainstorming og ..."
+           :on-boarding/welcome-whiteboard-modal-skip "Hopp over"
+           :on-boarding/welcome-whiteboard-modal-start "Start med whiteboard"
+           :on-boarding/welcome-whiteboard-modal-title "Et nytt lerret for dine tanker."
+           :page/logseq-is-having-a-problem "Logseq har et problem. Prøver å få den tilbake ..."
+           :page/show-whiteboards "Vis whiteboards"
+           :page/something-went-wrong "Noe gikk galt"
+           :page/step "Steg {1}"
+           :page/try "Prøv"
+           :pdf/doc-metadata "Dokument metadata"
+           :pdf/hl-block-colored "Farget merkelapp for å  label for utheve blokk"
+           :plugin/found-n-updates "Fant {1} oppdatering"
+           :plugin/found-updates "Nye oppateringer"
+           :plugin/update-all-selected "Oppdater alle valgte"
+           :plugin/updates-downloading "Laster ned oppdateringer"
+           :plugin.install-from-file/menu-title "Installer fra plugins.edn"
+           :plugin.install-from-file/notice "Følgende plugins vil erstatte dine plugins:"
+           :plugin.install-from-file/success "Alle plugins er installert!"
+           :plugin.install-from-file/title "Installer plugins fra plugins.edn"
+           :right-side-bar/history "(Dev) Angre/Gjør om logg"
+           :right-side-bar/whiteboards "Whiteboards"
+           :search/items "elementer"
+           :search-item/block "Blokk"
+           :search-item/file "Fil"
+           :search-item/page "Side"
+           :search-item/whiteboard "Whiteboard"
+           :select/default-select-multiple "Velg en eller flere"
+           :settings-page/alpha-features "Alpha funksjoner"
+           :settings-page/auto-expand-block-refs "Utvid blokkreferanser automatisk når zoomet inn..."
+           :settings-page/beta-features "Beta funksjoner"
+           :settings-page/clear-cache-warning "Tømming av hurtigbufferen vil forkaste dine åpne grafer. Du m..."
+           :settings-page/custom-date-format-warning "Re-indeksering kreves! Eksisterernde dagbokreferanse vi..."
+           :settings-page/disable-sentry-desc "Logseq vil aldri samle inn dine lokale graf sin databas..."
+           :settings-page/edit-setting "Rediger"
+           :settings-page/enable-whiteboards "Whiteboards"
+           :settings-page/filename-format "Filnavn format"
+           :settings-page/login-prompt "For å få tilgang til nye funksjoner før alle andre må du..."
+           :settings-page/preferred-pasting-file "Foretrekk innliming av fil"
+           :settings-page/show-full-blocks "Vis alle linjer av en blokkreferanse"
+           :settings-page/tab-assets "Ressurser"
+           :whiteboard/link-whiteboard-or-block "Lenk whiteboard/side/blokk"}
 
    :pt-BR {:on-boarding/demo-graph "Esse é um grafo de demonstração, mudanças não serão salvas enquanto uma pasta local não for aberta."
            :on-boarding/add-graph "Adicionar grafo"
@@ -2811,6 +2923,7 @@
            :settings-page/spell-checker "Verificador ortográfico"
            :settings-page/disable-sentry "Enviar dados de utilização e diagnósticos para Logseq"
            :settings-page/preferred-outdenting "Ativar dedentação lógica"
+           :settings-page/auto-expand-block-refs "Expandir as referências de bloco automaticamente ao aumentar o zoom"
            :settings-page/custom-date-format "Formato de data preferido"
            :settings-page/preferred-file-format "Formato de Arquivo preferido"
            :settings-page/preferred-workflow "Fluxo de trabalho preferido"
@@ -3138,6 +3251,10 @@
            :left-side-bar/new-whiteboard "Novo quadro branco"
            :left-side-bar/nav-favorites "Favoritos"
            :left-side-bar/nav-recent-pages "Recente"
+           :page/something-went-wrong "Algo deu errado"
+           :page/logseq-is-having-a-problem "Logseq está tendo um problema. Para tentar colocá-lo de volta em um estado de funcionamento, por favor tente os seguintes passos seguros em ordem:"
+           :page/step "Passo {1}"
+           :page/try "Tentar"
            :page/presentation-mode "Modo de apresentação"
            :page/delete-confirmation "Tem a certeza de que quer apagar esta página e o respetivo ficheiro?"
            :page/open-in-finder "Abrir em pasta"
@@ -3206,8 +3323,12 @@
            :color/pink "Rosa"
            :editor/copy "Copiar"
            :editor/cut "Cortar"
+           :content/copy-export-as "Copiar / Exportar como.."
+           :content/copy-block-url "Copiar URL do bloco"
            :content/copy-block-ref "Copiar referência do bloco"
            :content/copy-block-emebed "Copiar bloco para incorporar"
+           :content/copy-ref "Copiar esta referência"
+           :content/delete-ref "Apagar esta referência"
            :content/open-in-sidebar "Abrir na barra lateral"
            :content/click-to-edit "Clicar para editar"
            :settings-page/git-confirm "É necessário reiniciar a aplicação após atualizar as definições do Git."
@@ -3391,12 +3512,14 @@
 
            :command-palette/prompt "Introduza um comando"
            :select/default-prompt "Selecione um"
+           :select/default-select-multiple "Selecione um ou vários"
            :select.graph/prompt "Selecione um grafo"
            :select.graph/empty-placeholder-description "Sem grafos correspondentes. Quer adicionar outro?"
            :select.graph/add-graph "Sim, adicionar outro grafo"
 
            :file-sync/other-user-graph "O grafo local atual está ligado ao grafo remoto de outro utilizador. Portanto, a sincronização não pode ser iniciada."
            :file-sync/graph-deleted "O grafo remoto atual foi apagado"
+           :file-sync/rsapi-cannot-upload-err "Não foi possível iniciar a sincronização, verifique se a hora local está correta."
 
            :notification/clear-all "Limpar tudo"}
 
@@ -4410,6 +4533,7 @@
         :content/open-in-sidebar "Kenar çubuğunda aç"
         :content/click-to-edit "Düzenlemek için tıklayın"
         :context-menu/make-a-flashcard "Bilgi Kartı Oluştur"
+        :context-menu/toggle-number-list "Numaralı liste olarak değiştir"
         :context-menu/preview-flashcard "Bilgi Kartını Önizle"
         :context-menu/make-a-template "Bir Şablon Oluştur"
         :context-menu/input-template-name "Şablonun adı nedir?"

+ 0 - 2
src/main/frontend/format/block.cljs

@@ -9,7 +9,6 @@
             [frontend.handler.notification :as notification]
             [frontend.state :as state]
             [logseq.graph-parser.block :as gp-block]
-            [logseq.graph-parser.config :as gp-config]
             [logseq.graph-parser.property :as gp-property]
             [logseq.graph-parser.mldoc :as gp-mldoc]
             [lambdaisland.glogi :as log]))
@@ -23,7 +22,6 @@ and handles unexpected failure."
     (gp-block/extract-blocks blocks content with-id? format
                              {:user-config (state/get-config)
                               :block-pattern (config/get-block-pattern format)
-                              :supported-formats (gp-config/supported-formats)
                               :db (db/get-db (state/get-current-repo))
                               :date-formatter (state/get-date-formatter)
                               :page-name page-name})

+ 1 - 2
src/main/frontend/fs/capacitor_fs.cljs

@@ -238,8 +238,7 @@
              (when-not contents-matched?
                (backup-file repo-dir :backup-dir fpath disk-content))
              (db/set-file-last-modified-at! repo rpath mtime)
-             (p/let [content content]
-               (db/set-file-content! repo rpath content))
+             (db/set-file-content! repo rpath content)
              (when ok-handler
                (ok-handler repo fpath result))
              result)

+ 10 - 1
src/main/frontend/fs/sync.cljs

@@ -1680,6 +1680,10 @@
   [r]
   (some->> (ex-cause r) str (re-find #"graph-not-exist")))
 
+(defn- stop-sync-by-rsapi-response?
+  [r]
+  (some->> (ex-cause r) str (re-find #"Request is not yet valid")))
+
 
 ;; type = "change" | "add" | "unlink"
 (deftype FileChangeEvent [type dir path stat checksum]
@@ -2594,10 +2598,15 @@
               (do (println :graph-has-been-deleted r*)
                   {:graph-has-been-deleted true})
 
+              (stop-sync-by-rsapi-response? r*)
+              (do (println :stop-sync-caused-by-rsapi-err-response r*)
+                  (notification/show! (t :file-sync/rsapi-cannot-upload-err) :warning false)
+                  {:stop true})
+
               paused?
               {:pause true}
 
-              succ?                   ; succ
+              succ?                     ; succ
               (do
                 (println "sync-local->remote! update txid" r*)
                 ;; persist txid

+ 2 - 8
src/main/frontend/handler/block.cljs

@@ -6,7 +6,6 @@
    [frontend.db :as db]
    [frontend.db.model :as db-model]
    [frontend.db.react :as react]
-   [frontend.db.utils :as db-utils]
    [frontend.mobile.haptics :as haptics]
    [frontend.modules.outliner.core :as outliner-core]
    [frontend.modules.outliner.transaction :as outliner-tx]
@@ -70,14 +69,9 @@
                                    (util/distinct-by :db/id))))))
 
 (defn indentable?
-  [{:block/keys [parent] :as block}]
+  [{:block/keys [parent left]}]
   (when parent
-    (let [parent-block (db-utils/pull (:db/id parent))
-          first-child (first
-                       (db-model/get-block-immediate-children
-                        (state/get-current-repo)
-                        (:block/uuid parent-block)))]
-      (not= (:db/id block) (:db/id first-child)))))
+    (not= parent left)))
 
 (defn outdentable?
   [{:block/keys [level] :as _block}]

+ 3 - 5
src/main/frontend/handler/common/file.cljs

@@ -5,7 +5,6 @@
             [frontend.db :as db]
             [logseq.graph-parser :as graph-parser]
             [logseq.graph-parser.util :as gp-util]
-            [logseq.graph-parser.config :as gp-config]
             [frontend.fs.diff-merge :as diff-merge]
             [frontend.fs :as fs]
             [frontend.context.i18n :refer [t]]
@@ -75,13 +74,13 @@
      :fs/reset-event - the event that triggered the file update
        :fs/local-file-change - file changed on local disk
        :fs/remote-file-change - file changed on remote"
-  [repo-url file content {:fs/keys [event] :as options}]
+  [repo-url file-path content {:fs/keys [event] :as options}]
   (let [db-conn (db/get-db repo-url false)]
     (case event
       ;; the file is already in db, so we can use the existing file's blocks
       ;; to do the diff-merge
       :fs/local-file-change
-      (graph-parser/parse-file db-conn file content (assoc-in options [:extract-options :resolve-uuid-fn] diff-merge-uuids-2ways))
+      (graph-parser/parse-file db-conn file-path content (assoc-in options [:extract-options :resolve-uuid-fn] diff-merge-uuids-2ways))
 
       ;; TODO Junyi: 3 ways to handle remote file change
       ;; The file is on remote, so we should have 
@@ -91,7 +90,7 @@
       ;;   2. a "remote version" just fetched from remote
 
       ;; default to parse the file
-      (graph-parser/parse-file db-conn file content options))))
+      (graph-parser/parse-file db-conn file-path content options))))
 
 (defn reset-file!
   "Main fn for updating a db with the results of a parsed file"
@@ -107,7 +106,6 @@
                                            {:user-config (state/get-config)
                                             :date-formatter (state/get-date-formatter)
                                             :block-pattern (config/get-block-pattern (gp-util/get-format file-path))
-                                            :supported-formats (gp-config/supported-formats)
                                             :filename-format (state/get-filename-format repo-url)}
                                            ;; To avoid skipping the `:or` bounds for keyword destructuring
                                            (when (some? extracted-block-ids) {:extracted-block-ids extracted-block-ids})

+ 55 - 43
src/main/frontend/handler/editor.cljs

@@ -426,7 +426,7 @@
                    (not has-children?))]
     (outliner-tx/transact!
      {:outliner-op :insert-blocks}
-      (save-current-block! {:current-block current-block})
+     (save-current-block! {:current-block current-block})
      (outliner-core/insert-blocks! [new-block] current-block {:sibling? sibling?
                                                               :keep-uuid? keep-uuid?
                                                               :replace-empty-target? replace-empty-target?}))))
@@ -753,30 +753,28 @@
        (outliner-core/delete-blocks! [block] {:children? children?})))))
 
 (defn- move-to-prev-block
-  ([repo sibling-block format id value]
-   (move-to-prev-block repo sibling-block format id value true))
-  ([repo sibling-block format id value edit?]
-   (when (and repo sibling-block)
-     (when-let [sibling-block-id (dom/attr sibling-block "blockid")]
-       (when-let [block (db/pull repo '[*] [:block/uuid (uuid sibling-block-id)])]
-         (let [original-content (util/trim-safe (:block/content block))
-               value' (-> (property/remove-built-in-properties format original-content)
-                          (drawer/remove-logbook))
-               new-value (str value' value)
-               tail-len (count value)
-               pos (max
-                    (if original-content
-                      (gobj/get (utf8/encode original-content) "length")
-                      0)
-                    0)]
-           (when edit?
-             (edit-block! block pos id
-                          {:custom-content new-value
-                           :tail-len tail-len
-                           :move-cursor? false}))
-           {:prev-block block
-            :new-content new-value
-            :pos pos}))))))
+  [repo sibling-block format id value move?]
+  (when (and repo sibling-block)
+    (when-let [sibling-block-id (dom/attr sibling-block "blockid")]
+      (when-let [block (db/pull repo '[*] [:block/uuid (uuid sibling-block-id)])]
+        (let [original-content (util/trim-safe (:block/content block))
+              value' (-> (property/remove-built-in-properties format original-content)
+                         (drawer/remove-logbook))
+              new-value (str value' value)
+              tail-len (count value)
+              pos (max
+                   (if original-content
+                     (gobj/get (utf8/encode original-content) "length")
+                     0)
+                   0)
+              f (fn [] (edit-block! block pos id
+                                    {:custom-content new-value
+                                     :tail-len tail-len
+                                     :move-cursor? false}))]
+          (when move? (f))
+          {:prev-block block
+           :new-content new-value
+           :move-fn f})))))
 
 (declare save-block!)
 
@@ -802,7 +800,7 @@
                (when block-parent-id
                  (let [block-parent (gdom/getElement block-parent-id)
                        sibling-block (util/get-prev-block-non-collapsed-non-embed block-parent)
-                       {:keys [prev-block new-content]} (move-to-prev-block repo sibling-block format id value)
+                       {:keys [prev-block new-content move-fn]} (move-to-prev-block repo sibling-block format id value false)
                        concat-prev-block? (boolean (and prev-block new-content))
                        transact-opts (cond->
                                        {:outliner-op :delete-block}
@@ -812,7 +810,8 @@
                    (outliner-tx/transact! transact-opts
                      (when concat-prev-block?
                        (save-block! repo prev-block new-content))
-                     (delete-block-aux! block delete-children?))))))))))
+                     (delete-block-aux! block delete-children?))
+                   (move-fn)))))))))
    (state/set-editor-op! nil)))
 
 (defn delete-blocks!
@@ -829,7 +828,8 @@
         (move-to-prev-block repo sibling-block
                             (:block/format block)
                             (dom/attr sibling-block "id")
-                            "")))))
+                            ""
+                            true)))))
 
 (defn- set-block-property-aux!
   [block-or-id key value]
@@ -1231,10 +1231,17 @@
 
 (defn save-block!
   ([repo block-or-uuid content]
+    (save-block! repo block-or-uuid content {}))
+  ([repo block-or-uuid content {:keys [properties] :or {}}]
    (let [block (if (or (uuid? block-or-uuid)
                        (string? block-or-uuid))
                  (db-model/query-block-by-uuid block-or-uuid) block-or-uuid)]
-     (save-block! {:block block :repo repo} content)))
+     (save-block!
+       {:block block :repo repo}
+       (if (seq properties)
+          (property/insert-properties (:block/format block) content properties)
+        content)
+     )))
   ([{:keys [block repo] :as _state} value]
    (let [repo (or repo (state/get-current-repo))]
      (when (db/entity repo [:block/uuid (:block/uuid block)])
@@ -1244,8 +1251,8 @@
   [blocks]
   (outliner-tx/transact!
    {:outliner-op :save-block}
-    (doseq [[block value] blocks]
-      (save-block-if-changed! block value))))
+   (doseq [[block value] blocks]
+     (save-block-if-changed! block value))))
 
 (defn save-current-block!
   "skip-properties? if set true, when editing block is likely be properties, skip saving"
@@ -1832,8 +1839,9 @@
       (and (= content "1. ") (= last-input-char " ") input-id edit-block
            (not (own-order-number-list? edit-block)))
       (do
-        (state/pub-event! [:editor/toggle-own-number-list edit-block])
-        (state/set-edit-content! input-id ""))
+        (state/set-edit-content! input-id "")
+        (-> (p/delay 10)
+            (p/then #(state/pub-event! [:editor/toggle-own-number-list edit-block]))))
 
       (and (= last-input-char (state/get-editor-command-trigger))
            (or (re-find #"(?m)^/" (str (.-value input))) (start-of-new-word? input pos)))
@@ -1964,7 +1972,7 @@
            :or {exclude-properties []
                 edit? true}}]
   (let [editing-block (when-let [editing-block (state/get-edit-block)]
-                        (some-> (db/pull (:db/id editing-block))
+                        (some-> (db/pull [:block/uuid (:block/uuid editing-block)])
                                 (assoc :block/content (state/get-edit-content))))
         has-unsaved-edits (and editing-block
                                (not= (:block/content (db/pull (:db/id editing-block)))
@@ -2455,8 +2463,9 @@
             :else
             (profile
              "Insert block"
-             (do (save-current-block!)
-                 (insert-new-block! state)))))))))
+             (outliner-tx/transact! {:outliner-op :insert-blocks}
+               (save-current-block!)
+               (insert-new-block! state)))))))))
 
 (defn- inside-of-single-block
   "When we are in a single block wrapper, we should always insert a new line instead of new block"
@@ -2610,15 +2619,18 @@
         ^js input (state/get-input)
         current-pos (cursor/pos input)
         value (gobj/get input "value")
-        right (outliner-core/get-right-node (outliner-core/block current-block))
+        right (outliner-core/get-right-sibling (:db/id current-block))
         current-block-has-children? (db/has-children? (:block/uuid current-block))
         collapsed? (util/collapsed? current-block)
         first-child (:data (tree/-get-down (outliner-core/block current-block)))
         next-block (if (or collapsed? (not current-block-has-children?))
-                     (:data right)
+                     (when right (db/pull (:db/id right)))
                      first-child)]
     (cond
-      (and collapsed? right (db/has-children? (tree/-get-id right)))
+      (nil? next-block)
+      nil
+
+      (and collapsed? right (db/has-children? (:block/uuid right)))
       nil
 
       (and (not collapsed?) first-child (db/has-children? (:block/uuid first-child)))
@@ -2760,7 +2772,7 @@
       (outliner-tx/transact!
        {:outliner-op :move-blocks
         :real-outliner-op :indent-outdent}
-        (outliner-core/indent-outdent-blocks! [block] indent?)))
+       (outliner-core/indent-outdent-blocks! [block] indent?)))
     (state/set-editor-op! :nil)))
 
 (defn keydown-tab-handler
@@ -3148,7 +3160,7 @@
     (state/selection?)
     (shortcut-delete-selection e)
 
-    (whiteboard?)
+    (and (whiteboard?) (not (state/editing?)))
     (.deleteShapes (.-api ^js (state/active-tldraw-app)))
 
     :else
@@ -3203,9 +3215,9 @@
           ;; if the move is to cross block boundary, select the whole block
          (or (and (= direction :up) (cursor/textarea-cursor-rect-first-row? cursor-rect))
              (and (= direction :down) (cursor/textarea-cursor-rect-last-row? cursor-rect)))
-          (select-block-up-down direction)
+         (select-block-up-down direction)
           ;; simulate text selection
-          (cursor/select-up-down input direction anchor cursor-rect)))
+         (cursor/select-up-down input direction anchor cursor-rect)))
       (select-block-up-down direction))))
 
 (defn open-selected-block!

+ 4 - 12
src/main/frontend/handler/events.cljs

@@ -70,8 +70,7 @@
             [logseq.db.schema :as db-schema]
             [logseq.graph-parser.config :as gp-config]
             [promesa.core :as p]
-            [rum.core :as rum]
-            [logseq.common.path :as path]))
+            [rum.core :as rum]))
 
 ;; TODO: should we move all events here?
 
@@ -609,10 +608,9 @@
               (plugin/open-waiting-updates-modal!))
             (plugin-handler/set-auto-checking! false))))))
 
-(defmethod handle :plugin/hook-db-tx [[_ {:keys [blocks tx-data tx-meta] :as payload}]]
+(defmethod handle :plugin/hook-db-tx [[_ {:keys [blocks tx-data] :as payload}]]
   (when-let [payload (and (seq blocks)
-                          (merge payload {:tx-data (map #(into [] %) tx-data)
-                                          :tx-meta (dissoc tx-meta :editor-cursor)}))]
+                          (merge payload {:tx-data (map #(into [] %) tx-data)}))]
     (plugin-handler/hook-plugin-db :changed payload)
     (plugin-handler/hook-plugin-block-changes payload)))
 
@@ -624,13 +622,7 @@
 
 (defmethod handle :mobile-file-watcher/changed [[_ ^js event]]
   (let [type (.-event event)
-        payload (js->clj event :keywordize-keys true)
-        dir (:dir payload)
-        payload (-> payload
-                    (update :path
-                           (fn [path]
-                             (when (string? path)
-                               (path/relative-path dir path)))))]
+        payload (js->clj event :keywordize-keys true)]
     (fs-watcher/handle-changed! type payload)
     (when (file-sync-handler/enable-sync?)
      (sync/file-watch-handler type payload))))

+ 26 - 27
src/main/frontend/handler/export/text.cljs

@@ -164,33 +164,32 @@
 
 (defn- block-table
   [{:keys [header groups]}]
-  (when (seq header)
-    (let [level    (dec (get *state* :current-level 1))
-          sep-line (raw-text "|" (string/join "|" (repeat (count header) "---")) "|")
-          header-line
-          (concatv (mapcatv
-                    (fn [h] (concatv [space (raw-text "|") space] (mapcatv inline-ast->simple-ast h)))
-                    header)
-                   [space (raw-text "|")])
-          group-lines
-          (mapcatv
-           (fn [group]
-             (mapcatv
-              (fn [row]
-                (concatv [(indent-with-2-spaces level)]
-                         (mapcatv
-                          (fn [col]
-                            (concatv [(raw-text "|") space]
-                                     (mapcatv inline-ast->simple-ast col)
-                                     [space]))
-                          row)
-                         [(raw-text "|") (newline* 1)]))
-              group))
-           groups)]
-      (concatv [(newline* 1) (indent-with-2-spaces level)]
-               header-line
-               [(newline* 1) (indent-with-2-spaces level) sep-line (newline* 1)]
-               group-lines))))
+  (let [level    (dec (get *state* :current-level 1))
+        sep-line (raw-text "|" (string/join "|" (repeat (count header) "---")) "|")
+        header-line
+        (concatv (mapcatv
+                  (fn [h] (concatv [space (raw-text "|") space] (mapcatv inline-ast->simple-ast h)))
+                  header)
+                 [space (raw-text "|")])
+        group-lines
+        (mapcatv
+         (fn [group]
+           (mapcatv
+            (fn [row]
+              (concatv [(indent-with-2-spaces level)]
+                       (mapcatv
+                        (fn [col]
+                          (concatv [(raw-text "|") space]
+                                   (mapcatv inline-ast->simple-ast col)
+                                   [space]))
+                        row)
+                       [(raw-text "|") (newline* 1)]))
+            group))
+         groups)]
+    (concatv [(newline* 1) (indent-with-2-spaces level)]
+             (when (seq header) header-line)
+             (when (seq header) [(newline* 1) (indent-with-2-spaces level) sep-line (newline* 1)])
+             group-lines)))
 
 (defn- block-comment
   [s]

+ 11 - 17
src/main/frontend/handler/file_sync.cljs

@@ -16,7 +16,6 @@
             [cljs-time.coerce :as tc]
             [cljs-time.core :as t]
             [frontend.storage :as storage]
-            [logseq.graph-parser.util :as gp-util]
             [lambdaisland.glogi :as log]))
 
 (def *beta-unavailable? (volatile! false))
@@ -163,24 +162,19 @@
               version-file-paths)
              (remove nil?))))))))
 
-(defn fetch-page-file-versions [graph-uuid page]
+(defn <fetch-page-file-versions [graph-uuid page]
   []
   (let [file-id (:db/id (:block/file page))]
-    (when-let [path (:file/path (db/entity file-id))]
-      (let [base-path (config/get-repo-dir (state/get-current-repo))
-            base-path (if (string/starts-with? base-path "file://")
-                        (gp-util/safe-decode-uri-component base-path)
-                        base-path)
-            path*     (string/replace-first (string/replace-first path base-path "") #"^/" "")]
-        (go
-          (let [version-list       (:VersionList
-                                    (<! (sync/<get-remote-file-versions sync/remoteapi graph-uuid path*)))
-                local-version-list (<! (<list-file-local-versions page))
-                all-version-list   (->> (concat version-list local-version-list)
-                                        (sort-by #(or (:CreateTime %)
-                                                      (:create-time %))
-                                                 >))]
-            all-version-list))))))
+    (go
+      (when-let [path (:file/path (db/entity file-id))]
+        (let [version-list       (:VersionList
+                                  (<! (sync/<get-remote-file-versions sync/remoteapi graph-uuid path)))
+              local-version-list (<! (<list-file-local-versions page))
+              all-version-list   (->> (concat version-list local-version-list)
+                                      (sort-by #(or (:CreateTime %)
+                                                    (:create-time %))
+                                               >))]
+          all-version-list)))))
 
 
 (defn init-remote-graph

+ 3 - 1
src/main/frontend/handler/paste.cljs

@@ -87,7 +87,9 @@
 ;; See https://developer.chrome.com/blog/web-custom-formats-for-the-async-clipboard-api/
 ;; for a similar example
 (defn get-copied-blocks []
-  (p/let [clipboard-items (when (and js/window (gobj/get js/window "navigator") js/navigator.clipboard)
+  ;; NOTE: Avoid using navigator clipboard API on Android, it will report a permission error
+  (p/let [clipboard-items (when (and (not (mobile-util/native-android?))
+                                     js/window (gobj/get js/window "navigator") js/navigator.clipboard)
                             (js/navigator.clipboard.read))
           blocks-blob ^js (when clipboard-items
                             (let [types (.-types ^js (first clipboard-items))]

+ 10 - 7
src/main/frontend/handler/whiteboard.cljs

@@ -89,11 +89,12 @@
   (let [assets (js->clj-keywordize (.getCleanUpAssets app))
         new-shapes (.-shapes tl-page)
         shapes-index (map #(gobj/get % "id") new-shapes)
+        shape-id->index (zipmap shapes-index (range (.-length new-shapes)))
         upsert-shapes (->> (set/difference new-id-nonces db-id-nonces)
                            (map (fn [{:keys [id]}]
                                   (-> (.-serialized ^js (.getShapeById tl-page id))
                                       js->clj-keywordize
-                                      (assoc :index (.indexOf shapes-index id)))))
+                                      (assoc :index (get shape-id->index id)))))
                            (set))
         old-ids (set (map :id db-id-nonces))
         new-ids (set (map :id new-id-nonces))
@@ -134,13 +135,15 @@
 (defn transact-tldr-delta! [page-name ^js app replace?]
   (let [tl-page ^js (second (first (.-pages app)))
         shapes (.-shapes ^js tl-page)
-        shapes-index (map #(gobj/get % "id") shapes)
-        new-id-nonces (set (map (fn [shape]
+        page-block (model/get-page page-name)
+        prev-shapes-index (get-in page-block [:block/properties :logseq.tldraw.page :shapes-index])
+        shape-id->prev-index (zipmap prev-shapes-index (range (count prev-shapes-index)))
+        new-id-nonces (set (map-indexed (fn [idx shape]
                                   (let [id (.-id shape)]
-                                   {:id id
-                                    :nonce (if (= shape.id (.indexOf shapes-index id))
-                                             (.-nonce shape)
-                                             (.getTime (js/Date.)))})) shapes))
+                                    {:id id
+                                     :nonce (if (= idx (get shape-id->prev-index id))
+                                              (.-nonce shape)
+                                              (js/Date.now))})) shapes))
         repo (state/get-current-repo)
         db-id-nonces (or
                       (get-in @*last-shapes-nonce [repo page-name])

+ 73 - 53
src/main/frontend/modules/editor/undo_redo.cljs

@@ -44,7 +44,7 @@
     [txs]
     (filterv (fn [[_ a & y]]
                (= :block/content a))
-      txs))
+             txs))
 
   (defn get-content-from-stack
     "For test."
@@ -60,22 +60,21 @@
     (when-let [stack @undo-stack]
       (when (seq stack)
         (let [removed-e (peek stack)
-              popped-stack (pop stack)
-              prev-e (peek popped-stack)]
+              popped-stack (pop stack)]
           (reset! undo-stack popped-stack)
-          [removed-e prev-e])))))
+          removed-e)))))
 
 (defn push-redo
   [txs]
   (let [redo-stack (get-redo-stack)]
-   (swap! redo-stack conj txs)))
+    (swap! redo-stack conj txs)))
 
 (defn pop-redo
   []
   (let [redo-stack (get-redo-stack)]
-   (when-let [removed-e (peek @redo-stack)]
-     (swap! redo-stack pop)
-     removed-e)))
+    (when-let [removed-e (peek @redo-stack)]
+      (swap! redo-stack pop)
+      removed-e)))
 
 (defn page-pop-redo
   [page-id]
@@ -119,7 +118,7 @@
                        (and redo? (not add?)) :db/retract
                        (and (not redo?) (not add?)) :db/add)]
               [op id attr value tx]))
-      txs)))
+          txs)))
 
 ;;;; Invokes
 
@@ -128,7 +127,7 @@
   (let [conn (conn/get-db false)]
     (d/transact! conn txs tx-meta)))
 
-(defn page-pop-undo
+(defn- page-pop-undo
   [page-id]
   (let [undo-stack (get-undo-stack)]
     (when-let [stack @undo-stack]
@@ -144,7 +143,7 @@
                   others (vec (concat before after))]
               (reset! undo-stack others)
               (prn "[debug] undo remove: " (nth stack idx'))
-              [(nth stack idx') others])))))))
+              (nth stack idx'))))))))
 
 (defn- smart-pop-undo
   []
@@ -154,56 +153,77 @@
       (pop-undo))
     (pop-undo)))
 
+(defn- set-editor-content!
+  "Prevent block auto-save during undo/redo."
+  []
+  (when-let [block (state/get-edit-block)]
+    (state/set-edit-content! (state/get-edit-input-id)
+                             (:block/content (db/entity (:db/id block))))))
+
+(defn- get-next-tx-editor-cursor
+  [tx-id]
+  (let [result (->> (sort (keys (:history/tx->editor-cursor @state/state)))
+                    (split-with #(not= % tx-id))
+                    second)]
+    (when (> (count result) 1)
+      (when-let [next-tx-id (nth result 1)]
+        (get-in @state/state [:history/tx->editor-cursor next-tx-id])))))
+
+(defn- get-previous-tx-id
+  [tx-id]
+  (let [result (->> (sort (keys (:history/tx->editor-cursor @state/state)))
+                    (split-with #(not= % tx-id))
+                    first)]
+    (when (>= (count result) 1)
+      (last result))))
+
+(defn- get-previous-tx-editor-cursor
+  [tx-id]
+  (when-let [prev-tx-id (get-previous-tx-id tx-id)]
+    (get-in @state/state [:history/tx->editor-cursor prev-tx-id])))
+
 (defn undo
   []
-  (let [[e prev-e] (smart-pop-undo)]
-    (when e
-      (let [{:keys [txs tx-meta]} e
-            new-txs (get-txs false txs)
-            undo-delete-concat-block? (and (= :delete-block (:outliner-op tx-meta))
-                                           (seq (:concat-data tx-meta)))
-            editor-cursor (cond
-                            undo-delete-concat-block?
-                            (let [data (:concat-data tx-meta)]
-                              (assoc (:editor-cursor e)
-                                     :last-edit-block {:block/uuid (:last-edit-block data)}
-                                     :pos (if (:end? data) :max 0)))
-
-                            ;; same block
-                            (= (get-in e [:editor-cursor :last-edit-block :block/uuid])
-                               (get-in prev-e [:editor-cursor :last-edit-block :block/uuid]))
-                            (:editor-cursor prev-e)
-
-                            :else
-                            (:editor-cursor e))]
-
-        (push-redo e)
-        (transact! new-txs (merge {:undo? true}
-                                  tx-meta
-                                  (select-keys e [:pagination-blocks-range])))
-
-        (when undo-delete-concat-block?
-          (when-let [block (state/get-edit-block)]
-            (state/set-edit-content! (state/get-edit-input-id)
-                                     (:block/content (db/entity (:db/id block))))))
-
-        (when (:whiteboard/transact? tx-meta)
-          (state/pub-event! [:whiteboard/undo e]))
-        (assoc e
-               :txs-op new-txs
-               :editor-cursor editor-cursor)))))
+  (when-let [e (smart-pop-undo)]
+    (let [{:keys [txs tx-meta tx-id]} e
+          new-txs (get-txs false txs)
+          current-editor-cursor (get-in @state/state [:history/tx->editor-cursor tx-id])
+          save-block? (= (:outliner-op tx-meta) :save-block)
+          prev-editor-cursor (get-previous-tx-editor-cursor tx-id)
+          editor-cursor (if (and save-block?
+                                 (= (:block/uuid (:last-edit-block prev-editor-cursor))
+                                    (:block/uuid (state/get-edit-block))))
+                          prev-editor-cursor
+                          current-editor-cursor)]
+      (push-redo e)
+      (transact! new-txs (merge {:undo? true}
+                                tx-meta
+                                (select-keys e [:pagination-blocks-range])))
+      (set-editor-content!)
+      (when (:whiteboard/transact? tx-meta)
+        (state/pub-event! [:whiteboard/undo e]))
+      (assoc e
+             :txs-op new-txs
+             :editor-cursor editor-cursor))))
 
 (defn redo
   []
-  (when-let [{:keys [txs tx-meta] :as e} (smart-pop-redo)]
-    (let [new-txs (get-txs true txs)]
+  (when-let [{:keys [txs tx-meta tx-id] :as e} (smart-pop-redo)]
+    (let [new-txs (get-txs true txs)
+          current-editor-cursor (get-in @state/state [:history/tx->editor-cursor tx-id])
+          editor-cursor (if (= (:outliner-op tx-meta) :save-block)
+                          current-editor-cursor
+                          (get-next-tx-editor-cursor tx-id))]
       (push-undo e)
       (transact! new-txs (merge {:redo? true}
                                 tx-meta
                                 (select-keys e [:pagination-blocks-range])))
+      (set-editor-content!)
       (when (:whiteboard/transact? tx-meta)
         (state/pub-event! [:whiteboard/redo e]))
-      (assoc e :txs-op new-txs))))
+      (assoc e
+             :txs-op new-txs
+             :editor-cursor editor-cursor))))
 
 (defn toggle-undo-redo-mode!
   []
@@ -231,14 +251,14 @@
                    #{:block/created-at :block/updated-at})))
     (reset-redo)
     (if (:replace? tx-meta)
-      (let [[removed-e _prev-e] (pop-undo)
+      (let [removed-e (pop-undo)
             entity (update removed-e :txs concat tx-data)]
         (push-undo entity))
       (let [updated-blocks (db-report/get-blocks tx-report)
-            entity {:blocks updated-blocks
+            entity {:tx-id (get-in tx-report [:tempids :db/current-tx])
+                    :blocks updated-blocks
                     :txs tx-data
                     :tx-meta tx-meta
-                    :editor-cursor (:editor-cursor tx-meta)
                     :pagination-blocks-range (get-in [:ui/pagination-blocks-range (get-in tx-report [:db-after :max-tx])] @state/state)
                     :app-state (select-keys @state/state
                                             [:route-match

+ 53 - 37
src/main/frontend/modules/outliner/core.cljs

@@ -16,8 +16,7 @@
             [logseq.graph-parser.util :as gp-util]
             [cljs.spec.alpha :as s]))
 
-(s/def ::block-map (s/keys :req [:db/id]
-                           :opt [:block/page :block/left :block/parent]))
+(s/def ::block-map (s/keys :opt [:db/id :block/uuid :block/page :block/left :block/parent]))
 
 (s/def ::block-map-or-entity (s/or :entity de/entity?
                                    :map ::block-map))
@@ -26,8 +25,14 @@
 
 (defn block
   [m]
-  (assert (map? m) (util/format "block data must be map, got: %s %s" (type m) m))
-  (->Block m))
+  (assert (or (map? m) (de/entity? m)) (util/format "block data must be map or entity, got: %s %s" (type m) m))
+  (if (de/entity? m)
+    (->Block {:db/id (:db/id m)
+              :block/uuid (:block/uuid m)
+              :block/page (:block/page m)
+              :block/left (:block/left m)
+              :block/parent (:block/parent m)})
+    (->Block m)))
 
 (defn get-data
   [block]
@@ -145,42 +150,49 @@
               m)
           other-tx (:db/other-tx m)
           id (:db/id (:data this))
-          block-entity (db/entity id)
-          remove-self-page #(remove (fn [b]
-                                      (= (:db/id b) (:db/id (:block/page block-entity)))) %)
-          old-refs (remove-self-page (:block/refs block-entity))
-          new-refs (remove-self-page (:block/refs m))]
+          block-entity (db/entity id)]
       (when (seq other-tx)
         (swap! txs-state (fn [txs]
                            (vec (concat txs other-tx)))))
 
       (when id
+        ;; Retract attributes to prepare for tx which rewrites block attributes
         (swap! txs-state (fn [txs]
                            (vec
                             (concat txs
                                     (map (fn [attribute]
                                            [:db/retract id attribute])
-                                      db-schema/retract-attributes)))))
+                                         db-schema/retract-attributes)))))
 
+        ;; Update block's page attributes
         (when-let [e (:block/page block-entity)]
-          (let [m' {:db/id (:db/id e)
-                   :block/updated-at (util/time-ms)}
-                m' (if (:block/created-at e)
-                    m'
-                    (assoc m' :block/created-at (util/time-ms)))
-                m' (if (or (:block/pre-block? block-entity)
-                           (:block/pre-block? m))
-                     (let [properties (:block/properties m)
-                           alias (set (:alias properties))
-                           tags (set (:tags properties))
-                           alias (map (fn [p] {:block/name (util/page-name-sanity-lc p)}) alias)
-                           tags (map (fn [p] {:block/name (util/page-name-sanity-lc p)}) tags)]
-                       (assoc m'
-                              :block/alias alias
-                              :block/tags tags
-                              :block/properties properties))
-                     m')]
-            (swap! txs-state conj m'))
+          (let [m' (cond-> {:db/id (:db/id e)
+                            :block/updated-at (util/time-ms)}
+                     (not (:block/created-at e))
+                     (assoc :block/created-at (util/time-ms)))
+                txs (if (or (:block/pre-block? block-entity)
+                            (:block/pre-block? m))
+                      (let [properties (:block/properties m)
+                            alias (set (:alias properties))
+                            tags (set (:tags properties))
+                            alias (map (fn [p] {:block/name (util/page-name-sanity-lc p)}) alias)
+                            tags (map (fn [p] {:block/name (util/page-name-sanity-lc p)}) tags)
+                            deleteable-page-attributes {:block/alias alias
+                                                        :block/tags tags
+                                                        :block/properties properties
+                                                        :block/properties-text-values (:block/properties-text-values m)}
+                            ;; Retract page attributes to allow for deletion of page attributes
+                            page-retractions
+                            (mapv #(vector :db/retract (:db/id e) %) (keys deleteable-page-attributes))]
+                        (conj page-retractions (merge m' deleteable-page-attributes)))
+                      [m'])]
+            (swap! txs-state into txs)))
+
+        ;; Remove orphaned refs from block
+        (let [remove-self-page #(remove (fn [b]
+                                          (= (:db/id b) (:db/id (:block/page block-entity)))) %)
+              old-refs (remove-self-page (:block/refs block-entity))
+              new-refs (remove-self-page (:block/refs m))]
           (remove-orphaned-page-refs! (:db/id block-entity) txs-state old-refs new-refs)))
 
       (swap! txs-state conj (dissoc m :db/other-tx))
@@ -228,11 +240,6 @@
           children (db-model/get-block-immediate-children (state/get-current-repo) parent-id)]
       (map block children))))
 
-(defn get-right-node
-  [node]
-  {:pre [(tree/satisfied-inode? node)]}
-  (tree/-get-right node))
-
 (defn get-right-sibling
   [db-id]
   (when db-id
@@ -404,7 +411,7 @@
   (let [level-blocks (blocks-with-level blocks)]
     (filter (fn [b] (= 1 (:block/level b))) level-blocks)))
 
-(defn get-right-siblings
+(defn- get-right-siblings
   "Get `node`'s right siblings."
   [node]
   {:pre [(tree/satisfied-inode? node)]}
@@ -474,7 +481,7 @@
                         (:db/id target-block))
         get-new-id (fn [block lookup]
                      (cond
-                       (or (map? lookup) (vector? lookup))
+                       (or (map? lookup) (vector? lookup) (de/entity? lookup))
                        (when-let [uuid (if (and (vector? lookup) (= (first lookup) :block/uuid))
                                          (get uuids (last lookup))
                                          (get id->new-uuid (:db/id lookup)))]
@@ -507,6 +514,13 @@
                          (dissoc :db/id)))))
                  blocks)))
 
+(defn- get-target-block
+  [target-block]
+  (if (:db/id target-block)
+    (db/pull (:db/id target-block))
+    (when (:block/uuid target-block)
+      (db/pull [:block/uuid (:block/uuid target-block)]))))
+
 (defn insert-blocks
   "Insert blocks as children (or siblings) of target-node.
   Args:
@@ -524,7 +538,7 @@
   [blocks target-block {:keys [sibling? keep-uuid? outliner-op replace-empty-target?] :as opts}]
   {:pre [(seq blocks)
          (s/valid? ::block-map-or-entity target-block)]}
-  (let [target-block' (db/pull (:db/id target-block))
+  (let [target-block' (get-target-block target-block)
         _ (assert (some? target-block') (str "Invalid target: " target-block))
         sibling? (if (page-block? target-block') false sibling?)
         move? (contains? #{:move-blocks :move-blocks-up-down :indent-outdent-blocks} outliner-op)
@@ -710,7 +724,9 @@
   [blocks target-block {:keys [sibling? outliner-op]}]
   [:pre [(seq blocks)
          (s/valid? ::block-map-or-entity target-block)]]
-  (let [non-consecutive-blocks? (seq (db-model/get-non-consecutive-blocks blocks))
+  (let [target-block (get-target-block target-block)
+        _ (assert (some? target-block) (str "Invalid target: " target-block))
+        non-consecutive-blocks? (seq (db-model/get-non-consecutive-blocks blocks))
         original-position? (move-to-original-position? blocks target-block sibling? non-consecutive-blocks?)]
     (when (and (not (contains? (set (map :db/id blocks)) (:db/id target-block)))
                (not original-position?))

+ 16 - 4
src/main/frontend/modules/outliner/datascript.cljc

@@ -3,6 +3,7 @@
   #?(:cljs (:require-macros [frontend.modules.outliner.datascript]))
   #?(:cljs (:require [datascript.core :as d]
                      [frontend.db.conn :as conn]
+                     [frontend.db :as db]
                      [frontend.modules.outliner.pipeline :as pipelines]
                      [frontend.modules.editor.undo-redo :as undo-redo]
                      [frontend.state :as state]
@@ -45,9 +46,14 @@
                                           v)))
                        x))))))
 
+#?(:cljs
+   (defn get-tx-id
+     [tx-report]
+     (get-in tx-report [:tempids :db/current-tx])))
+
 #?(:cljs
    (defn transact!
-     [txs opts]
+     [txs opts before-editor-cursor]
      (let [txs (remove-nil-from-transaction txs)
            txs (map (fn [m] (if (map? m)
                               (dissoc m
@@ -65,9 +71,15 @@
          (try
            (let [repo (get opts :repo (state/get-current-repo))
                  conn (conn/get-db repo false)
-                 editor-cursor (state/get-current-edit-block-and-position)
-                 meta (merge opts {:editor-cursor editor-cursor})
-                 rs (d/transact! conn txs (assoc meta :outliner/transact? true))]
+                 rs (d/transact! conn txs (assoc opts :outliner/transact? true))
+                 tx-id (get-tx-id rs)]
+             (swap! state/state assoc-in [:history/tx->editor-cursor tx-id] before-editor-cursor)
+
+             ;; update the current edit block to include full information
+             (when-let [block (state/get-edit-block)]
+               (when (and (:block/uuid block) (not (:db/id block)))
+                 (state/set-state! :editor/block (db/pull [:block/uuid (:block/uuid block)]))))
+
              (when true                 ; TODO: add debug flag
                (let [eids (distinct (mapv first (:tx-data rs)))
                      left&parent-list (->>

+ 3 - 2
src/main/frontend/modules/outliner/transaction.cljc

@@ -27,7 +27,8 @@
   `(let [transact-data# frontend.modules.outliner.core/*transaction-data*
          opts# (if transact-data#
                  (assoc ~opts :nested-transaction? true)
-                 ~opts)]
+                 ~opts)
+         before-editor-cursor# (frontend.state/get-current-edit-block-and-position)]
      (if transact-data#
        (do ~@body)
        (binding [frontend.modules.outliner.core/*transaction-data* (transient [])]
@@ -40,7 +41,7 @@
                opts## (merge (dissoc opts# :additional-tx) tx-meta#)]
            (when (seq all-tx#) ;; If it's empty, do nothing
              (when-not (:nested-transaction? opts#) ; transact only for the whole transaction
-               (let [result# (frontend.modules.outliner.datascript/transact! all-tx# opts##)]
+               (let [result# (frontend.modules.outliner.datascript/transact! all-tx# opts## before-editor-cursor#)]
                  {:tx-report result#
                   :tx-data all-tx#
                   :tx-meta tx-meta#}))))))))

+ 11 - 11
src/main/frontend/modules/shortcut/config.cljs

@@ -72,34 +72,34 @@
    :pdf/find                     {:binding "alt+f"
                                   :fn      pdf-utils/open-finder}
 
-   :whiteboard/select            {:binding ["1" "s"]
+   :whiteboard/select            {:binding ["1" "w s"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "select")}
 
-   :whiteboard/pan               {:binding ["2" "p"]
+   :whiteboard/pan               {:binding ["2" "w p"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "move")}
 
-   :whiteboard/portal            {:binding "3"
+   :whiteboard/portal            {:binding ["3" "w b"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "logseq-portal")}
 
-   :whiteboard/pencil            {:binding ["4" "d"]
+   :whiteboard/pencil            {:binding ["4" "w d"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "pencil")}
 
-   :whiteboard/highlighter       {:binding ["5" "h"]
+   :whiteboard/highlighter       {:binding ["5" "w h"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "highlighter")}
 
-   :whiteboard/eraser            {:binding ["6" "e"]
+   :whiteboard/eraser            {:binding ["6" "w e"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "erase")}
 
-   :whiteboard/connector         {:binding ["7" "c"]
+   :whiteboard/connector         {:binding ["7" "w c"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "line")}
 
-   :whiteboard/text              {:binding ["8" "t"]
+   :whiteboard/text              {:binding ["8" "w t"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "text")}
 
-   :whiteboard/rectangle         {:binding ["9" "r"]
+   :whiteboard/rectangle         {:binding ["9" "w r"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "box")}
 
-   :whiteboard/ellipse           {:binding "o"
+   :whiteboard/ellipse           {:binding ["o" "w o"]
                                   :fn      #(.selectTool ^js (state/active-tldraw-app) "ellipse")}
 
    :whiteboard/reset-zoom        {:binding "shift+0"
@@ -141,7 +141,7 @@
    :whiteboard/ungroup           {:binding "mod+shift+g"
                                   :fn      #(.unGroup (.-api ^js (state/active-tldraw-app)))}
 
-   :whiteboard/toggle-grid       {:binding "shift+g"
+   :whiteboard/toggle-grid       {:binding "t g"
                                   :fn      #(.toggleGrid (.-api ^js (state/active-tldraw-app)))}
 
    :auto-complete/complete       {:binding "enter"

+ 67 - 1
src/main/frontend/modules/shortcut/dicts.cljc

@@ -1140,7 +1140,47 @@
              :command.ui/goto-plugins "Gå til dashbord for utvidelser"
              ;;  :command.ui/open-new-window "Åpne et nytt vindu"
              :command.ui/select-theme-color "Velg tilgjengelige temafarger"
-             :command.ui/toggle-cards "Veksle kort"}
+             :command.ui/toggle-cards "Veksle kort"
+             :command.dev/show-block-ast "(Dev) Vis blokk AST"
+             :command.dev/show-block-data "(Dev) Vis blokk data"
+             :command.dev/show-page-ast "(Dev) Vis side AST"
+             :command.dev/show-page-data "(Dev) Vis side data"
+             :command.editor/copy-page-url "Kopier side url"
+             :command.editor/new-whiteboard "Nytt whiteboard"
+             :command.editor/select-parent "Velg overordnet blokk"
+             :command.editor/toggle-number-list "Veksle nummerliste"
+             :command.editor/toggle-undo-redo-mode "Veksle angremodus (global eller kun side)"
+             :command.go/whiteboards "Gå til whiteboards"
+             :command.graph/export-as-html "Eksporter offentlig graf som html"
+             :command.pdf/find "Pdf: Søk tekst i nåværende pdf doc"
+             :command.sidebar/close-top "Lukker øverste objekt i høyre sidestolpe"
+             :command.ui/clear-all-notifications "Fjern alle varsler"
+             :command.ui/install-plugins-from-file "Installer plugins fra plugins.edn"
+             :command.whiteboard/bring-forward "Flytt fremover"
+             :command.whiteboard/bring-to-front "Flytt fremst"
+             :command.whiteboard/connector "Koblingsverktøy"
+             :command.whiteboard/ellipse "Ellipseverktøy"
+             :command.whiteboard/eraser "Sletteverktøy"
+             :command.whiteboard/group "Velg gruppe"
+             :command.whiteboard/highlighter "Merkepenn"
+             :command.whiteboard/lock "Lås seleksjon"
+             :command.whiteboard/pan "Panoreringsverktøy"
+             :command.whiteboard/pencil "Blyantverktøy"
+             :command.whiteboard/portal "Portalverktøy"
+             :command.whiteboard/rectangle "Rektangelverktøy"
+             :command.whiteboard/reset-zoom "Tilbakestill zoom"
+             :command.whiteboard/select "Valg-verktøy"
+             :command.whiteboard/send-backward "Flytt bakover"
+             :command.whiteboard/send-to-back "Flytt bakerst"
+             :command.whiteboard/text "Tekst-verktøy"
+             :command.whiteboard/toggle-grid "Veksle rutenett på lerretet"
+             :command.whiteboard/ungroup "Del opp gruppe"
+             :command.whiteboard/unlock "Lås opp seleksjon"
+             :command.whiteboard/zoom-in "Zoom inn"
+             :command.whiteboard/zoom-out "Zoom ut"
+             :command.whiteboard/zoom-to-fit "Zoom til tegning"
+             :command.whiteboard/zoom-to-selection "Zoom for å passe seleksjonen"
+             :shortcut.category/whiteboard "Whiteboard"}
 
    :pt-PT   {:shortcut.category/formatting            "Formatação"
              :shortcut.category/basics                "Básico"
@@ -1660,6 +1700,7 @@
              :shortcut.category/block-command-editing "Blok düzenleme komutuları"
              :shortcut.category/block-selection "Blok seçimi (seçimden çıkmak için Esc tuşuna basın)"
              :shortcut.category/toggle "Aç/Kapat"
+             :shortcut.category/whiteboard "Beyaz tahta"
              :shortcut.category/others "Diğer"
              :command.date-picker/complete         "Tarih seçici: Seçilen günü seç"
              :command.date-picker/prev-day         "Tarih seçici: Önceki günü seç"
@@ -1732,6 +1773,31 @@
              :command.editor/zoom-in                 "Düzenlenen bloğu yakınlaştır / Aksi takdirde ileri git"
              :command.editor/zoom-out                "Düzenlenen bloğu uzaklaştır / Aksi takdirde geri git"
              :command.editor/toggle-undo-redo-mode   "Geri alma / yineleme modunu değiştir (yalnızca sayfa veya genel)"
+             :command.editor/toggle-number-list      "Numaralı liste olarak değiştir"
+             :command.whiteboard/select              "Seçim aracı"
+             :command.whiteboard/pan                 "Kaydırma aracı"
+             :command.whiteboard/portal              "Portal aracı"
+             :command.whiteboard/pencil              "Kalem aracı"
+             :command.whiteboard/highlighter         "Vurgulayıcı aracı"
+             :command.whiteboard/eraser              "Silgi aracı"
+             :command.whiteboard/connector           "Bağlayıcı aracı"
+             :command.whiteboard/text                "Metin aracı"
+             :command.whiteboard/rectangle           "Dikdörtgen aracı"
+             :command.whiteboard/ellipse             "Elips aracı"
+             :command.whiteboard/reset-zoom          "Yakınlaştırmayı sıfırla"
+             :command.whiteboard/zoom-to-fit         "Çizimi yakınlaştır"
+             :command.whiteboard/zoom-to-selection   "Seçimi sığacak kadar yakınlaştır"
+             :command.whiteboard/zoom-out            "Uzaklaştır"
+             :command.whiteboard/zoom-in             "Yakınlaştır"
+             :command.whiteboard/send-backward       "Geriye git"
+             :command.whiteboard/send-to-back        "Geriye taşı"
+             :command.whiteboard/bring-forward       "İleriye git"
+             :command.whiteboard/bring-to-front      "Öne taşı"
+             :command.whiteboard/lock                "Seçimi kilitle"
+             :command.whiteboard/unlock              "Seçimin Kilidini aç"
+             :command.whiteboard/group               "Seçimi gruplandır"
+             :command.whiteboard/ungroup             "Seçimi gruptan çıkar"
+             :command.whiteboard/toggle-grid         "Tuval ızgarasını değiştir"
              :command.ui/toggle-brackets             "Köşeli ayraçların görüntülenip görüntülenmeyeceğini değiştir"
              :command.go/search-in-page              "Geçerli sayfada ara"
              :command.go/electron-find-in-page       "Sayfada bul"

+ 6 - 6
src/main/frontend/page.cljs

@@ -52,11 +52,11 @@
            [:div.flex.flex-col.items-start
             [:div.text-2xs.font-bold.uppercase.toned-down (t :page/step "1")]
             [:div [:span.highlighted.font-bold "Rebuild"] [:span.toned-down " search index"]]]
-             [:div
-              (ui/button (t :page/try)
-                         :small? true
-                         :on-click (fn []
-                                     (search-handler/rebuild-indices! true)))]]
+           [:div
+            (ui/button (t :page/try)
+                       :small? true
+                       :on-click (fn []
+                                   (search-handler/rebuild-indices! true)))]]
           [:div.flex.flex-row.justify-between.align-items.mb-2.items-center.separator-top.py-4
            [:div.flex.flex-col.items-start
             [:div.text-2xs.font-bold.uppercase.toned-down (t :page/step "2")]
@@ -92,7 +92,7 @@
                    (ui/inject-dynamic-style-node!)
                    (quick-tour/init)
                    (plugin-handler/host-mounted!)
-                   (assoc state ::teardown (setup-fns!) ))
+                   (assoc state ::teardown (setup-fns!)))
    :will-unmount (fn [state]
                    (when-let [teardown (::teardown state)]
                      (teardown)))}

+ 1 - 1
src/main/frontend/rum.cljs

@@ -92,7 +92,7 @@
       (fn []
          (rum/set-ref! *mounted true)
          #(rum/set-ref! *mounted false))
-       [])
+      [])
     #(rum/deref *mounted)))
 
 (defn use-bounding-client-rect

+ 1 - 0
src/main/frontend/schema/handler/common_config.cljc

@@ -62,6 +62,7 @@
               :string]]
     [:ref/default-open-blocks-level :int]
     [:ref/linked-references-collapsed-threshold :int]
+    [:graph/settings [:map-of :keyword :boolean]]
     [:favorites [:vector :string]]
     ;; There isn't a :float yet
     [:srs/learning-fraction float?]

+ 25 - 0
src/main/frontend/shui.cljs

@@ -0,0 +1,25 @@
+(ns frontend.shui
+  "Glue between frontend code and deps/shui for convenience"
+  (:require 
+    [frontend.date :refer [int->local-time-2]]
+    [frontend.state :as state]
+    [logseq.shui.context :refer [make-context]]))
+
+(def default-versions {:logseq.table.version 1})
+
+(defn get-shui-component-version 
+  "Returns the version of the shui component, checking first 
+  the block properties, then the global config, then the defaults."
+  [component-name block-config]
+  (let [version-key (keyword (str "logseq." (name component-name) ".version"))]
+    (js/parseFloat
+      (or (get-in block-config [:block :block/properties version-key])
+          (get-in (state/get-config) [version-key])
+          (get-in default-versions [version-key])
+          1))))
+
+(defn make-shui-context [block-config inline]
+  (make-context {:block-config block-config 
+                 :app-config (state/get-config) 
+                 :inline inline 
+                 :int->local-time-2 int->local-time-2}))

+ 6 - 3
src/main/frontend/state.cljs

@@ -259,9 +259,9 @@
      ;;                :file-sync/progress {}
      ;;                :file-sync/start-time {}
      ;;                :file-sync/last-synced-at {}}
-     :file-sync/graph-state                 {:current-graph-uuid nil
+     :file-sync/graph-state                 {:current-graph-uuid nil}
                                              ;; graph-uuid -> ...
-                                             }
+
      :user/info                             {:UserGroups (storage/get :user-groups)}
      :encryption/graph-parsing?             false
 
@@ -285,7 +285,10 @@
      :chat/current-conversation             nil
      :ai/preferred-translate-target-lang    (storage/get :ai/preferred-translate-target-lang)
      :ai/engines                            {}
-     :ai/current-service                    "Built-in OpenAI"})))
+     :ai/current-service                    "Built-in OpenAI"
+
+     ;; db tx-id -> editor cursor
+     :history/tx->editor-cursor             {}})))
 
 ;; Block ast state
 ;; ===============

+ 24 - 24
src/main/frontend/ui.cljs

@@ -730,26 +730,26 @@
   [state {:keys [on-mouse-down header title-trigger? collapsed?]}]
   (let [control? (get state ::control?)]
     [:div.content
-    [:div.flex-1.flex-row.foldable-title (cond->
-                                           {:on-mouse-over #(reset! control? true)
-                                            :on-mouse-out  #(reset! control? false)}
-                                           title-trigger?
-                                           (assoc :on-mouse-down on-mouse-down
-                                                  :class "cursor"))
-     [:div.flex.flex-row.items-center
-      (when-not (mobile-util/native-platform?)
-        [:a.block-control.opacity-50.hover:opacity-100.mr-2
-         (cond->
-           {:style    {:width       14
-                       :height      16
-                       :margin-left -30}}
-           (not title-trigger?)
-           (assoc :on-mouse-down on-mouse-down))
-         [:span {:class (if (or @control? @collapsed?) "control-show cursor-pointer" "control-hide")}
-          (rotating-arrow @collapsed?)]])
-      (if (fn? header)
-        (header @collapsed?)
-        header)]]]))
+     [:div.flex-1.flex-row.foldable-title (cond->
+                                            {:on-mouse-over #(reset! control? true)
+                                             :on-mouse-out  #(reset! control? false)}
+                                            title-trigger?
+                                            (assoc :on-mouse-down on-mouse-down
+                                                   :class "cursor"))
+      [:div.flex.flex-row.items-center
+       (when-not (mobile-util/native-platform?)
+         [:a.block-control.opacity-50.hover:opacity-100.mr-2
+          (cond->
+            {:style    {:width       14
+                        :height      16
+                        :margin-left -30}}
+            (not title-trigger?)
+            (assoc :on-mouse-down on-mouse-down))
+          [:span {:class (if (or @control? @collapsed?) "control-show cursor-pointer" "control-hide")}
+           (rotating-arrow @collapsed?)]])
+       (if (fn? header)
+         (header @collapsed?)
+         header)]]]))
 
 (rum/defcs foldable < db-mixins/query rum/reactive
   (rum/local false ::collapsed?)
@@ -852,10 +852,10 @@
       [:option (cond->
                 {:key   label
                  :value (or value label)} ;; NOTE: value might be an empty string, `or` is safe here
-                 disabled
-                 (assoc :disabled disabled)
-                 selected
-                 (assoc :selected selected))
+                disabled
+                (assoc :disabled disabled)
+                selected
+                (assoc :selected selected))
        label])]))
 
 (rum/defc radio-list

+ 1 - 1
src/main/frontend/util.cljc

@@ -1274,7 +1274,7 @@
 #?(:cljs
    (defn scroll-editor-cursor
      [^js/HTMLElement el & {:keys [to-vw-one-quarter?]}]
-     (when (and el (or (mobile-util/native-platform?) mobile?))
+     (when (and el (or (mobile-util/native-platform?) (mobile?)))
        (let [box-rect    (.getBoundingClientRect el)
              box-top     (.-top box-rect)
              box-bottom  (.-bottom box-rect)

+ 1 - 1
src/main/frontend/util/cursor.cljs

@@ -226,7 +226,7 @@
 
 (defn- move-cursor-up-down
   [input direction]
-    (move-cursor-to input (next-cursor-pos-up-down direction (get-caret-pos input))))
+  (move-cursor-to input (next-cursor-pos-up-down direction (get-caret-pos input))))
 
 (defn move-cursor-up [input]
   (move-cursor-up-down input :up))

+ 10 - 8
src/main/frontend/util/text.cljs

@@ -3,7 +3,8 @@
   a good ns to be in yet"
   (:require [clojure.string :as string]
             [goog.string :as gstring]
-            [frontend.util :as util]))
+            [frontend.util :as util]
+            [logseq.common.path :as path]))
 
 (defonce between-re #"\(between ([^\)]+)\)")
 
@@ -142,10 +143,11 @@
 ;; FIXME: distinguish from get-repo-name
 (defn get-graph-name-from-path
   [path]
-  (when (string? path)
-    (let [parts (->> (string/split path #"/")
-                     (take-last 2))]
-      (-> (if (not= (first parts) "0")
-            (util/string-join-path parts)
-            (last parts))
-          js/decodeURIComponent))))
+  (let [path (if (path/is-file-url? path)
+               (path/url-to-path path)
+               path)
+        parts (->> (string/split path #"/")
+                   (take-last 2))]
+    (if (not= (first parts) "0")
+      (util/string-join-path parts)
+      (last parts))))

+ 1 - 1
src/main/frontend/version.cljs

@@ -1,3 +1,3 @@
 (ns ^:no-doc frontend.version)
 
-(defonce version "0.9.4")
+(defonce version "0.9.6")

+ 9 - 24
src/main/logseq/api.cljs

@@ -43,7 +43,8 @@
             [frontend.handler.shell :as shell]
             [frontend.modules.layout.core]
             [frontend.handler.code :as code-handler]
-            [frontend.handler.search :as search-handler]))
+            [frontend.handler.search :as search-handler]
+            [logseq.api.block :as api-block]))
 
 ;; Alert: this namespace shouldn't invoke any reactive queries
 
@@ -651,13 +652,13 @@
       nil)))
 
 (def ^:export update_block
-  (fn [block-uuid content ^js _opts]
+  (fn [block-uuid content ^js opts]
     (let [repo       (state/get-current-repo)
           edit-input (state/get-edit-input-id)
           editing?   (and edit-input (string/ends-with? edit-input (str block-uuid)))]
       (if editing?
         (state/set-edit-content! edit-input content)
-        (editor-handler/save-block! repo (sdk-utils/uuid-or-throw-error block-uuid) content))
+        (editor-handler/save-block! repo (sdk-utils/uuid-or-throw-error block-uuid) content (bean/->clj opts)))
       nil)))
 
 (def ^:export move_block
@@ -676,24 +677,7 @@
           target-block (db-model/query-block-by-uuid (sdk-utils/uuid-or-throw-error target-block-uuid))]
       (editor-dnd-handler/move-blocks nil [src-block] target-block move-to) nil)))
 
-(def ^:export get_block
-  (fn [id-or-uuid ^js opts]
-    (when-let [block (cond
-                       (number? id-or-uuid) (db-utils/pull id-or-uuid)
-                       (string? id-or-uuid) (db-model/query-block-by-uuid (sdk-utils/uuid-or-throw-error id-or-uuid)))]
-      (when-not (contains? block :block/name)
-        (when-let [uuid (:block/uuid block)]
-          (let [{:keys [includeChildren]} (bean/->clj opts)
-                repo  (state/get-current-repo)
-                block (if includeChildren
-                        ;; nested children results
-                        (first (outliner-tree/blocks->vec-tree
-                                 (db-model/get-block-and-children repo uuid) uuid))
-                        ;; attached shallow children
-                        (assoc block :block/children
-                                     (map #(list :uuid (get-in % [:data :block/uuid]))
-                                          (db/get-block-immediate-children repo uuid))))]
-            (bean/->js (sdk-utils/normalize-keyword-for-json block))))))))
+(def ^:export get_block api-block/get_block)
 
 (def ^:export get_current_block
   (fn [^js opts]
@@ -703,7 +687,7 @@
                                 (gdom/getElement (state/get-editing-block-dom-id)))
                             (.getAttribute "blockid")
                             (db-model/get-block-by-uuid)))]
-      (get_block (:db/id block) opts))))
+      (get_block (:block/uuid block) opts))))
 
 (def ^:export get_previous_sibling_block
   (fn [block-uuid]
@@ -715,8 +699,9 @@
 (def ^:export get_next_sibling_block
   (fn [block-uuid]
     (when-let [block (db-model/query-block-by-uuid (sdk-utils/uuid-or-throw-error block-uuid))]
-      (when-let [right-siblings (outliner/get-right-siblings (outliner/->Block block))]
-        (bean/->js (sdk-utils/normalize-keyword-for-json (:data (first right-siblings))))))))
+      (when-let [right-sibling (outliner/get-right-sibling (:db/id block))]
+        (let [block (db/pull (:id right-sibling))]
+          (bean/->js (sdk-utils/normalize-keyword-for-json block)))))))
 
 (def ^:export set_block_collapsed
   (fn [block-uuid ^js opts]

+ 28 - 0
src/main/logseq/api/block.cljs

@@ -0,0 +1,28 @@
+(ns logseq.api.block
+  "Block related apis"
+  (:require [frontend.db.model :as db-model]
+            [frontend.db.utils :as db-utils]
+            [cljs-bean.core :as bean]
+            [frontend.state :as state]
+            [frontend.modules.outliner.tree :as outliner-tree]
+            [frontend.db :as db]
+            [logseq.sdk.utils :as sdk-utils]))
+
+(defn get_block
+  [id-or-uuid ^js opts]
+  (when-let [block (if (number? id-or-uuid)
+                     (db-utils/pull id-or-uuid)
+                     (db-model/query-block-by-uuid (sdk-utils/uuid-or-throw-error id-or-uuid)))]
+    (when-not (contains? block :block/name)
+      (when-let [uuid (:block/uuid block)]
+        (let [{:keys [includeChildren]} (bean/->clj opts)
+              repo  (state/get-current-repo)
+              block (if includeChildren
+                      ;; nested children results
+                      (first (outliner-tree/blocks->vec-tree
+                              (db-model/get-block-and-children repo uuid) uuid))
+                      ;; attached shallow children
+                      (assoc block :block/children
+                             (map #(list :uuid (:block/uuid %))
+                               (db/get-block-immediate-children repo uuid))))]
+          (bean/->js (sdk-utils/normalize-keyword-for-json block)))))))

+ 16 - 0
src/test/frontend/db/model_test.cljs

@@ -163,6 +163,22 @@ foo:: bar"}])
                 (catch :default e
                   (ex-message e)))))))
 
+(deftest get-block-immediate-children
+  (load-test-files [{:file/path "pages/page1.md"
+                     :file/content "\n
+- parent
+  - child 1
+    - grandchild 1
+  - child 2
+    - grandchild 2
+  - child 3"}])
+  (let [parent (-> (d/q '[:find (pull ?b [*]) :where [?b :block/content "parent"]]
+                        (conn/get-db test-helper/test-db))
+                   ffirst)]
+    (is (= ["child 1" "child 2" "child 3"]
+           (map :block/content
+                (model/get-block-immediate-children test-helper/test-db (:block/uuid parent)))))))
+
 (deftest get-property-values
   (load-test-files [{:file/path "pages/Feature.md"
                      :file/content "type:: [[Class]]"}

+ 3 - 2
src/test/frontend/fs_test.cljs

@@ -2,6 +2,7 @@
   (:require [clojure.test :refer [is use-fixtures]]
             [frontend.test.fixtures :as fixtures]
             [frontend.test.helper :as test-helper :include-macros true :refer [deftest-async]]
+            [frontend.test.node-helper :as test-node-helper]
             [frontend.fs :as fs]
             [promesa.core :as p]
             ["fs" :as fs-node]
@@ -11,7 +12,7 @@
 
 (deftest-async create-if-not-exists-creates-correctly
   ;; dir needs to be an absolute path for fn to work correctly
-  (let [dir (node-path/resolve (test-helper/create-tmp-dir))
+  (let [dir (node-path/resolve (test-node-helper/create-tmp-dir))
         some-file (node-path/join dir "something.txt")]
 
     (->
@@ -29,7 +30,7 @@
         (fs-node/rmdirSync dir))))))
 
 (deftest-async create-if-not-exists-does-not-create-correctly
-  (let [dir (node-path/resolve (test-helper/create-tmp-dir))
+  (let [dir (node-path/resolve (test-node-helper/create-tmp-dir))
         some-file (node-path/join dir "something.txt")]
     (fs-node/writeFileSync some-file "OLD")
 

+ 43 - 1
src/test/frontend/handler/editor_test.cljs

@@ -1,9 +1,15 @@
 (ns frontend.handler.editor-test
   (:require [frontend.handler.editor :as editor]
-            [clojure.test :refer [deftest is testing are]]
+            [frontend.db :as db]
+            [clojure.test :refer [deftest is testing are use-fixtures]]
+            [datascript.core :as d]
+            [frontend.test.helper :as test-helper :refer [load-test-files]]
+            [frontend.db.model :as model]
             [frontend.state :as state]
             [frontend.util.cursor :as cursor]))
 
+(use-fixtures :each test-helper/start-and-destroy-db)
+
 (deftest extract-nearest-link-from-text-test
   (testing "Page, block and tag links"
     (is (= "page1"
@@ -213,3 +219,39 @@
         "No page search within backticks"))
   ;; Reset state
   (state/set-editor-action! nil))
+
+(deftest save-block-aux!
+  (load-test-files [{:file/path "pages/page1.md"
+                     :file/content "\n
+- b1 #foo"}])
+  (testing "updating block's content changes content and preserves path-refs"
+   (let [conn (db/get-db test-helper/test-db false)
+         block (->> (d/q '[:find (pull ?b [* {:block/path-refs [:block/name]}])
+                           :where [?b :block/content "b1 #foo"]]
+                         @conn)
+                    ffirst)
+         prev-path-refs (set (map :block/name (:block/path-refs block)))
+         _ (assert (= #{"page1" "foo"} prev-path-refs)
+                   "block has expected :block/path-refs")
+         ;; Use same options as edit-box-on-change!
+         _ (editor/save-block-aux! block "b12 #foo" {:skip-properties? true})
+         updated-block (d/pull @conn '[* {:block/path-refs [:block/name]}] [:block/uuid (:block/uuid block)])]
+     (is (= "b12 #foo" (:block/content updated-block)) "Content updated correctly")
+     (is (= prev-path-refs
+            (set (map :block/name (:block/path-refs updated-block))))
+         "Path-refs remain the same"))))
+
+(deftest save-block!
+  (testing "Saving blocks with and without properties"
+    (test-helper/load-test-files [{:file/path "foo.md"
+                                   :file/content "# foo"}])
+    (let [repo test-helper/test-db
+          block-uuid (:block/uuid (model/get-block-by-page-name-and-block-route-name repo "foo" "foo"))]
+      (editor/save-block! repo block-uuid "# bar")
+      (is (= "# bar" (:block/content (model/query-block-by-uuid block-uuid))))
+
+      (editor/save-block! repo block-uuid "# foo" {:properties {:foo "bar"}})
+      (is (= "# foo\nfoo:: bar" (:block/content (model/query-block-by-uuid block-uuid))))
+
+      (editor/save-block! repo block-uuid "# bar")
+      (is (= "# bar" (:block/content (model/query-block-by-uuid block-uuid)))))))

+ 2 - 1
src/test/frontend/handler/plugin_config_test.cljs

@@ -1,6 +1,7 @@
 (ns frontend.handler.plugin-config-test
   (:require [clojure.test :refer [is use-fixtures testing deftest]]
             [frontend.test.helper :as test-helper :include-macros true :refer [deftest-async]]
+            [frontend.test.node-helper :as test-node-helper]
             [frontend.test.fixtures :as fixtures]
             [frontend.handler.plugin-config :as plugin-config-handler]
             [frontend.handler.global-config :as global-config-handler]
@@ -17,7 +18,7 @@
 
 (defn- create-global-config-dir
   []
-  (let [dir (test-helper/create-tmp-dir "config")
+  (let [dir (test-node-helper/create-tmp-dir "config")
         root-dir (node-path/dirname dir)]
     (reset! global-config-handler/root-dir root-dir)
     dir))

+ 1 - 1
src/test/frontend/handler/repo_conversion_test.cljs

@@ -98,7 +98,7 @@
   ;; only increase over time as the docs graph rarely has deletions
   (testing "Counts"
     (is (= 211 (count files)) "Correct file count")
-    (is (= 42312 (count (d/datoms db :eavt))) "Correct datoms count")
+    (is (= 42304 (count (d/datoms db :eavt))) "Correct datoms count")
 
     (is (= 3600
            (ffirst

+ 1 - 8
src/test/frontend/handler/repo_test.cljs

@@ -2,7 +2,6 @@
   (:require [cljs.test :refer [deftest use-fixtures testing is]]
             [frontend.handler.repo :as repo-handler]
             [frontend.test.helper :as test-helper :refer [load-test-files]]
-            [frontend.state :as state]
             [logseq.graph-parser.cli :as gp-cli]
             [logseq.graph-parser.test.docs-graph-helper :as docs-graph-helper]
             [logseq.graph-parser.util.block-ref :as block-ref]
@@ -12,13 +11,7 @@
             ["path" :as node-path]
             ["fs" :as fs]))
 
-(use-fixtures :each {:before (fn []
-                               ;; Set current-repo explicitly since it's not the default
-                               (state/set-current-repo! test-helper/test-db)
-                               (test-helper/start-test-db!))
-                     :after (fn []
-                              (state/set-current-repo! nil)
-                              (test-helper/destroy-test-db!))})
+(use-fixtures :each test-helper/start-and-destroy-db)
 
 (deftest ^:integration parse-and-load-files-to-db
   (let [graph-dir "src/test/docs-0.9.2"

+ 58 - 1
src/test/frontend/modules/outliner/core_test.cljs

@@ -10,7 +10,7 @@
             [clojure.walk :as walk]
             [logseq.graph-parser.block :as gp-block]
             [datascript.core :as d]
-            [frontend.test.helper :as test-helper]
+            [frontend.test.helper :as test-helper :refer [load-test-files]]
             [clojure.set :as set]))
 
 (def test-db test-helper/test-db)
@@ -440,6 +440,63 @@
          '(16 17)
          (map :block/uuid (tree/get-sorted-block-and-children test-db (:db/id (get-block 16))))))))
 
+(defn- save-block!
+  [block]
+  (outliner-tx/transact! {:graph test-db}
+                         (outliner-core/save-block! block)))
+
+(deftest save-test
+  (load-test-files [{:file/path "pages/page1.md"
+                     :file/content "alias:: foo, bar
+tags:: tag1, tag2
+- block #blarg #bar"}])
+  (testing "save deletes a page's tags"
+      (let [conn (db/get-db test-helper/test-db false)
+            pre-block (->> (d/q '[:find (pull ?b [*])
+                                  :where [?b :block/pre-block? true]]
+                                @conn)
+                           ffirst)
+            _ (save-block! (-> pre-block
+                               (update :block/properties dissoc :tags)
+                               (update :block/properties-text-values dissoc :tags)))
+            updated-page (-> (d/q '[:find (pull ?bp [* {:block/alias [*]}])
+                                    :where [?b :block/pre-block? true]
+                                    [?b :block/page ?bp]]
+                                  @conn)
+                             ffirst)]
+        (is (nil? (:block/tags updated-page))
+            "Page's tags are deleted")
+        (is (= #{"foo" "bar"} (set (map :block/name (:block/alias updated-page))))
+            "Page's aliases remain the same")
+        (is (= {:block/properties {:alias #{"foo" "bar"}}
+                :block/properties-text-values {:alias "foo, bar"}}
+               (select-keys updated-page [:block/properties :block/properties-text-values]))
+            "Page property attributes are correct")
+        (is (= {:block/properties {:alias #{"foo" "bar"}}
+                :block/properties-text-values {:alias "foo, bar"}}
+               (-> (d/q '[:find (pull ?b [*])
+                          :where [?b :block/pre-block? true]]
+                        @conn)
+                   ffirst
+                   (select-keys [:block/properties :block/properties-text-values])))
+            "Pre-block property attributes are correct")))
+
+  (testing "save deletes orphaned pages when a block's refs change"
+    (let [conn (db/get-db test-helper/test-db false)
+          pages (set (map first (d/q '[:find ?bn :where [?b :block/name ?bn]] @conn)))
+          _ (assert (set/subset? #{"blarg" "bar"} pages) "Pages from block exist")
+          block-with-refs (ffirst (d/q '[:find (pull ?b [* {:block/refs [*]}])
+                                         :where [?b :block/content "block #blarg #bar"]]
+                                       @conn))
+          _ (save-block! (-> block-with-refs
+                             (assoc :block/content "block"
+                                    :block/refs [])))
+          updated-pages (set (map first (d/q '[:find ?bn :where [?b :block/name ?bn]] @conn)))]
+      (is (not (contains? updated-pages "blarg"))
+          "Deleted, orphaned page no longer exists")
+      (is (contains? updated-pages "bar")
+          "Deleted but not orphaned page still exists"))))
+
 ;;; Fuzzy tests
 
 (def init-id (atom 100))

+ 1 - 8
src/test/frontend/modules/outliner/pipeline_test.cljs

@@ -1,18 +1,11 @@
 (ns frontend.modules.outliner.pipeline-test
   (:require [cljs.test :refer [deftest is use-fixtures testing]]
             [datascript.core :as d]
-            [frontend.state :as state]
             [frontend.db :as db]
             [frontend.modules.outliner.pipeline :as pipeline]
             [frontend.test.helper :as test-helper :refer [load-test-files]]))
 
-(use-fixtures :each {:before (fn []
-                               ;; Set current-repo explicitly since it's not the default
-                               (state/set-current-repo! test-helper/test-db)
-                               (test-helper/start-test-db!))
-                     :after (fn []
-                              (state/set-current-repo! nil)
-                              (test-helper/destroy-test-db!))})
+(use-fixtures :each test-helper/start-and-destroy-db)
 
 (defn- get-blocks [db]
   (->> (d/q '[:find (pull ?b [* {:block/path-refs [:block/name :db/id]}])

+ 13 - 15
src/test/frontend/test/helper.cljs

@@ -1,9 +1,8 @@
 (ns frontend.test.helper
   "Common helper fns for tests"
   (:require [frontend.handler.repo :as repo-handler]
-            [frontend.db.conn :as conn]
-            ["path" :as node-path]
-            ["fs" :as fs-node]))
+            [frontend.state :as state]
+            [frontend.db.conn :as conn]))
 
 (defonce test-db "test-db")
 
@@ -25,15 +24,14 @@ This can be called in synchronous contexts as no async fns should be invoked"
    ;; Set :refresh? to avoid creating default files in after-parse
    {:re-render? false :verbose false :refresh? true}))
 
-(defn create-tmp-dir
-  "Creates a temporary directory under tmp/. If a subdir is given, creates an
-  additional subdirectory under the newly created temp directory."
-  ([] (create-tmp-dir nil))
-  ([subdir]
-   (when-not (fs-node/existsSync "tmp") (fs-node/mkdirSync "tmp"))
-   (let [dir (fs-node/mkdtempSync (node-path/join "tmp" "unit-test-"))]
-     (if subdir
-       (do
-         (fs-node/mkdirSync (node-path/join dir subdir))
-         (node-path/join dir subdir))
-       dir))))
+(defn start-and-destroy-db
+  "Sets up a db connection and current repo like fixtures/reset-datascript. It
+  also seeds the db with the same default data that the app does and destroys a db
+  connection when done with it."
+  [f]
+  ;; Set current-repo explicitly since it's not the default
+  (state/set-current-repo! test-db)
+  (start-test-db!)
+  (f)
+  (state/set-current-repo! nil)
+  (destroy-test-db!))

+ 17 - 0
src/test/frontend/test/node_helper.cljs

@@ -0,0 +1,17 @@
+(ns frontend.test.node-helper
+  "Common helper fns for node tests"
+  (:require ["path" :as node-path]
+            ["fs" :as fs-node]))
+
+(defn create-tmp-dir
+  "Creates a temporary directory under tmp/. If a subdir is given, creates an
+  additional subdirectory under the newly created temp directory."
+  ([] (create-tmp-dir nil))
+  ([subdir]
+   (when-not (fs-node/existsSync "tmp") (fs-node/mkdirSync "tmp"))
+   (let [dir (fs-node/mkdtempSync (node-path/join "tmp" "unit-test-"))]
+     (if subdir
+       (do
+         (fs-node/mkdirSync (node-path/join dir subdir))
+         (node-path/join dir subdir))
+       dir))))

+ 40 - 0
src/test/logseq/api_test.cljs

@@ -0,0 +1,40 @@
+(ns logseq.api-test
+  (:require [cljs.test :refer [use-fixtures deftest is]]
+            [frontend.test.helper :as test-helper]
+            [frontend.db :as db]
+            [logseq.api.block :as api-block]
+            [frontend.state :as state]
+            [cljs-bean.core :as bean]))
+
+(use-fixtures :each {:before test-helper/start-test-db!
+                     :after test-helper/destroy-test-db!})
+
+(deftest get-block
+  (with-redefs [state/get-current-repo (constantly test-helper/test-db)]
+    (db/transact! test-helper/test-db
+      [{:db/id 10000
+        :block/uuid #uuid "4406f839-6410-43b5-87db-25e9b8f54cc0"
+        :block/content "1"}
+       {:db/id 10001
+        :block/uuid #uuid "d9b7b45f-267f-4794-9569-f43d1ce77172"
+        :block/content "2"}
+       {:db/id 10002
+        :block/uuid #uuid "adae3006-f03e-4814-a1f5-f17f15b86556"
+        :block/parent 10001
+        :block/left 10001
+        :block/content "3"}
+       {:db/id 10003
+        :block/uuid #uuid "0c3053c3-2dab-4769-badd-14ce16d8ba8d"
+        :block/parent 10002
+        :block/left 10002
+        :block/content "4"}])
+
+    (is (= (:content (bean/->clj (api-block/get_block 10000 #js {}))) "1"))
+    (is (= (:content (bean/->clj (api-block/get_block "d9b7b45f-267f-4794-9569-f43d1ce77172" #js {}))) "2"))
+    (is (= (:content (bean/->clj (api-block/get_block #uuid "d9b7b45f-267f-4794-9569-f43d1ce77172" #js {}))) "2"))
+    (is (= {:id 10001, :content "2", :uuid "d9b7b45f-267f-4794-9569-f43d1ce77172", :children [["uuid" "adae3006-f03e-4814-a1f5-f17f15b86556"]]}
+           (bean/->clj (api-block/get_block 10001 #js {:includeChildren false}))))
+    (is (= {:content "2", :uuid "d9b7b45f-267f-4794-9569-f43d1ce77172", :id 10001, :children [{:content "3", :left {:id 10001}, :parent {:id 10001}, :uuid "adae3006-f03e-4814-a1f5-f17f15b86556", :id 10002, :level 1, :children [{:content "4", :left {:id 10002}, :parent {:id 10002}, :uuid "0c3053c3-2dab-4769-badd-14ce16d8ba8d", :id 10003, :level 2, :children []}]}]}
+           (bean/->clj (api-block/get_block 10001 #js {:includeChildren true}))))))
+
+#_(cljs.test/run-tests)

Некоторые файлы не были показаны из-за большого количества измененных файлов