Преглед изворни кода

Merge pull request #5341 from logseq/whiteboards

feat: whiteboards support
Tienson Qin пре 3 година
родитељ
комит
bc568f6d5b
100 измењених фајлова са 3069 додато и 703 уклоњено
  1. 3 0
      .carve/ignore
  2. 2 2
      .github/workflows/e2e.yml
  3. 1 0
      .gitignore
  4. 3 0
      deps/db/src/logseq/db/schema.cljs
  5. 2 0
      deps/graph-parser/.carve/ignore
  6. 32 25
      deps/graph-parser/src/logseq/graph_parser.cljs
  7. 3 3
      deps/graph-parser/src/logseq/graph_parser/block.cljs
  8. 9 1
      deps/graph-parser/src/logseq/graph_parser/config.cljs
  9. 45 1
      deps/graph-parser/src/logseq/graph_parser/extract.cljc
  10. 2 1
      deps/graph-parser/src/logseq/graph_parser/property.cljs
  11. 32 2
      deps/graph-parser/src/logseq/graph_parser/util.cljs
  12. 82 0
      deps/graph-parser/src/logseq/graph_parser/whiteboard.cljs
  13. 22 0
      deps/graph-parser/test/logseq/graph_parser/extract_test.cljs
  14. 90 4
      deps/graph-parser/test/logseq/graph_parser_test.cljs
  15. 3 3
      e2e-tests/page-rename.spec.ts
  16. 65 0
      e2e-tests/whiteboards.spec.ts
  17. 2 0
      externs.js
  18. 1 4
      libs/package.json
  19. 3 1
      package.json
  20. 5 1
      resources/css/common.css
  21. 57 15
      resources/css/tabler-extension.css
  22. 1 0
      resources/electron.html
  23. BIN
      resources/fonts/tabler-icons-extension.woff2
  24. 1 0
      resources/index.html
  25. 13 3
      shadow-cljs.edn
  26. 2 2
      src/electron/electron/core.cljs
  27. 1 1
      src/electron/electron/handler.cljs
  28. 48 33
      src/electron/electron/utils.js
  29. 7 7
      src/main/frontend/commands.cljs
  30. 185 107
      src/main/frontend/components/block.cljs
  31. 14 0
      src/main/frontend/components/command_palette.css
  32. 8 5
      src/main/frontend/components/editor.cljs
  33. 3 3
      src/main/frontend/components/header.css
  34. 2 2
      src/main/frontend/components/onboarding/index.css
  35. 2 2
      src/main/frontend/components/onboarding/setups.cljs
  36. 186 118
      src/main/frontend/components/page.cljs
  37. 2 1
      src/main/frontend/components/page.css
  38. 5 5
      src/main/frontend/components/plugins.css
  39. 1 1
      src/main/frontend/components/repo.cljs
  40. 3 1
      src/main/frontend/components/right_sidebar.cljs
  41. 77 25
      src/main/frontend/components/search.cljs
  42. 38 5
      src/main/frontend/components/settings.cljs
  43. 1 1
      src/main/frontend/components/settings.css
  44. 99 46
      src/main/frontend/components/sidebar.cljs
  45. 57 10
      src/main/frontend/components/sidebar.css
  46. 259 0
      src/main/frontend/components/whiteboard.cljs
  47. 157 0
      src/main/frontend/components/whiteboard.css
  48. 5 0
      src/main/frontend/config.cljs
  49. 49 28
      src/main/frontend/db/model.cljs
  50. 20 2
      src/main/frontend/dicts.cljc
  51. 105 0
      src/main/frontend/extensions/tldraw.cljs
  52. 1 1
      src/main/frontend/format/block.cljs
  53. 6 0
      src/main/frontend/handler.cljs
  54. 32 37
      src/main/frontend/handler/common/file.cljs
  55. 81 69
      src/main/frontend/handler/editor.cljs
  56. 67 1
      src/main/frontend/handler/events.cljs
  57. 1 1
      src/main/frontend/handler/file.cljs
  58. 14 9
      src/main/frontend/handler/page.cljs
  59. 9 0
      src/main/frontend/handler/paste.cljs
  60. 25 8
      src/main/frontend/handler/repo.cljs
  61. 15 2
      src/main/frontend/handler/route.cljs
  62. 4 1
      src/main/frontend/handler/search.cljs
  63. 1 0
      src/main/frontend/handler/web/nfs.cljs
  64. 207 0
      src/main/frontend/handler/whiteboard.cljs
  65. 1 1
      src/main/frontend/mobile/index.css
  66. 14 6
      src/main/frontend/modules/file/core.cljs
  67. 1 1
      src/main/frontend/modules/outliner/core.cljs
  68. 32 4
      src/main/frontend/modules/outliner/file.cljs
  69. 5 3
      src/main/frontend/modules/outliner/tree.cljs
  70. 8 5
      src/main/frontend/modules/shortcut/before.cljs
  71. 12 0
      src/main/frontend/modules/shortcut/config.cljs
  72. 2 1
      src/main/frontend/modules/shortcut/data_helper.cljs
  73. 2 0
      src/main/frontend/modules/shortcut/dicts.cljc
  74. 14 5
      src/main/frontend/routes.cljs
  75. 37 0
      src/main/frontend/rum.cljs
  76. 56 10
      src/main/frontend/state.cljs
  77. 89 67
      src/main/frontend/ui.cljs
  78. 32 0
      src/main/frontend/ui.css
  79. 4 0
      src/main/logseq/api.cljs
  80. 1 0
      tailwind.all.css
  81. 19 0
      tldraw/.editorconfig
  82. 3 0
      tldraw/.eslintignore
  83. 19 0
      tldraw/.eslintrc
  84. 2 0
      tldraw/.gitattributes
  85. 17 0
      tldraw/.gitignore
  86. 17 0
      tldraw/.npmignore
  87. 11 0
      tldraw/.prettierrc
  88. 21 0
      tldraw/LICENSE.md
  89. 29 0
      tldraw/README.md
  90. 3 0
      tldraw/apps/tldraw-logseq/README.md
  91. 23 0
      tldraw/apps/tldraw-logseq/build.mjs
  92. 46 0
      tldraw/apps/tldraw-logseq/package.json
  93. 3 0
      tldraw/apps/tldraw-logseq/postcss.config.js
  94. 123 0
      tldraw/apps/tldraw-logseq/src/app.tsx
  95. 53 0
      tldraw/apps/tldraw-logseq/src/components/ActionBar/ActionBar.tsx
  96. 1 0
      tldraw/apps/tldraw-logseq/src/components/ActionBar/index.ts
  97. 18 0
      tldraw/apps/tldraw-logseq/src/components/AppUI.tsx
  98. 7 0
      tldraw/apps/tldraw-logseq/src/components/Button/Button.tsx
  99. 1 0
      tldraw/apps/tldraw-logseq/src/components/Button/index.ts
  100. 65 0
      tldraw/apps/tldraw-logseq/src/components/ContextBar/ContextBar.tsx

+ 3 - 0
.carve/ignore

@@ -21,6 +21,9 @@ frontend.extensions.age-encryption/encrypt-with-user-passphrase
 frontend.extensions.age-encryption/decrypt-with-user-passphrase
 ;; Lazily loaded
 frontend.extensions.excalidraw/draw
+;; Lazily loaded
+frontend.extensions.tldraw/tldraw-app
+frontend.extensions.tldraw/generate-preview
 ;; Referenced in commented TODO
 frontend.extensions.pdf.utils/get-page-bounding
 ;; For repl

+ 2 - 2
.github/workflows/e2e.yml

@@ -80,10 +80,10 @@ jobs:
         env:
           PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true
 
-      # NOTE: require the app to be build in debug mode
+      # NOTE: require the app to be build with DEV-RELEASE flag
       - name: Prepare E2E test build
         run: |
-          yarn gulp:build && clojure -M:cljs release app electron --debug
+          yarn gulp:build && clojure -M:cljs release app electron --config-merge "{:closure-defines {frontend.config/DEV-RELEASE true}}" --debug
 
       # NOTE: should include .shadow-cljs if in dev mode(compile)
       - name: Create Archive for build

+ 1 - 0
.gitignore

@@ -43,6 +43,7 @@ ios/App/App/capacitor.config.json
 
 startup.png
 
+/src/main/frontend/tldraw-logseq.js
 /src/test/docs
 ~*~
 

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

@@ -14,6 +14,9 @@
 
    :recent/pages {}
 
+   ;; :block/type is a string type of the current block
+   ;; "whiteboard" for whiteboards
+   ;; "macros" for macro
    :block/type {}
    :block/uuid {:db/unique :db.unique/identity}
    :block/parent {:db/valueType :db.type/ref

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

@@ -30,3 +30,5 @@ logseq.graph-parser.util.page-ref/page-ref-re
 logseq.graph-parser.property/->block-content
 ;; API
 logseq.graph-parser.property/property-value-from-content
+;; API
+logseq.graph-parser.whiteboard/page-block->tldr-page

+ 32 - 25
deps/graph-parser/src/logseq/graph_parser.cljs

@@ -19,31 +19,38 @@
   (let [format (gp-util/get-format file)
         file-content [{:file/path file}]
         {:keys [tx ast]}
-        (if (contains? gp-config/mldoc-support-formats format)
-          (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
-                                        {:db @conn})
-                {:keys [pages blocks ast]}
-                (extract/extract file content extract-options')
-                delete-blocks (delete-blocks-fn (first pages) file)
-                block-ids (map (fn [block] {:block/uuid (:block/uuid block)}) blocks)
-                block-refs-ids (->> (mapcat :block/refs blocks)
-                                    (filter (fn [ref] (and (vector? ref)
-                                                           (= :block/uuid (first ref)))))
-                                    (map (fn [ref] {:block/uuid (second ref)}))
-                                    (seq))
-                ;; To prevent "unique constraint" on datascript
-                block-ids (set/union (set block-ids) (set block-refs-ids))
-                pages (extract/with-ref-pages pages blocks)
-                pages-index (map #(select-keys % [:block/name]) pages)]
-            ;; does order matter?
-            {:tx (concat file-content pages-index delete-blocks pages block-ids blocks)
-             :ast ast})
-          {:tx file-content})
+        (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
+                                      {:db @conn})
+              {:keys [pages blocks ast]
+               :or   {pages []
+                      blocks []
+                      ast []}}
+              (cond (contains? gp-config/mldoc-support-formats format)
+                    (extract/extract file content extract-options')
+
+                    (gp-config/whiteboard? file)
+                    (extract/extract-whiteboard-edn file content extract-options')
+
+                    :else nil)
+              delete-blocks (delete-blocks-fn (first pages) file)
+              block-ids (map (fn [block] {:block/uuid (:block/uuid block)}) blocks)
+              block-refs-ids (->> (mapcat :block/refs blocks)
+                                  (filter (fn [ref] (and (vector? ref)
+                                                         (= :block/uuid (first ref)))))
+                                  (map (fn [ref] {:block/uuid (second ref)}))
+                                  (seq))
+                   ;; To prevent "unique constraint" on datascript
+              block-ids (set/union (set block-ids) (set block-refs-ids))
+              pages (extract/with-ref-pages pages blocks)
+              pages-index (map #(select-keys % [:block/name]) pages)]
+              ;; does order matter?
+          {:tx (concat file-content pages-index delete-blocks pages block-ids blocks)
+           :ast ast})
         tx (concat tx [(cond-> {:file/path file
                                 :file/content content}
                          new?

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

@@ -63,7 +63,7 @@
                                 (not (string/starts-with? value "https:"))
                                 (not (string/starts-with? value "file:"))
                                 (not (gp-config/local-asset? value))
-                                (or (= ext :excalidraw)
+                                (or (#{:excalidraw :tldr} ext)
                                     (not (contains? supported-formats ext))))
                        value)))
 
@@ -411,7 +411,7 @@
       content
       (gp-property/->new-properties content))))
 
-(defn- get-custom-id-or-new-id
+(defn get-custom-id-or-new-id
   [properties]
   (or (when-let [custom-id (or (get-in properties [:properties :custom-id])
                                (get-in properties [:properties :custom_id])
@@ -652,7 +652,7 @@
   (let [[blocks other-blocks] (split-with
                                (fn [b]
                                  (not= "macro" (:block/type b)))
-                                blocks)
+                               blocks)
         result (loop [blocks (map (fn [block] (assoc block :block/level-spaces (:block/level block))) blocks)
                       parents [{:page/id page-id     ; db id or a map {:block/name "xxx"}
                                 :block/level 0

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

@@ -38,11 +38,19 @@
     s))
 
 (defonce default-draw-directory "draws")
+;; TODO read configurable value?
+(defonce default-whiteboards-directory "whiteboards")
 
 (defn draw?
   [path]
   (string/starts-with? path default-draw-directory))
 
+(defn whiteboard?
+  [path]
+  (and path
+       (string/includes? path (str default-whiteboards-directory "/"))
+       (string/ends-with? path ".edn")))
+
 ;; TODO: rename
 (defonce mldoc-support-formats
   #{:org :markdown :md})
@@ -54,7 +62,7 @@
 (defn text-formats
   []
   #{:json :org :md :yml :dat :asciidoc :rst :txt :markdown :adoc :html :js :ts :edn :clj :ml :rb :ex :erl :java :php :c :css
-    :excalidraw :sh})
+    :excalidraw :tldr :sh})
 
 (defn img-formats
   []

+ 45 - 1
deps/graph-parser/src/logseq/graph_parser/extract.cljc

@@ -15,7 +15,17 @@
             [logseq.graph-parser.property :as gp-property]
             [logseq.graph-parser.config :as gp-config]
             #?(:org.babashka/nbb [logseq.graph-parser.log :as log]
-               :default [lambdaisland.glogi :as log])))
+               :default [lambdaisland.glogi :as log])
+            [logseq.graph-parser.whiteboard :as gp-whiteboard]))
+
+(defn- filepath->page-name
+  [filepath]
+  (when-let [file-name (last (string/split filepath #"/"))]
+    (let [result (first (gp-util/split-last "." file-name))
+          ext (string/lower-case (gp-util/get-file-ext filepath))]
+      (if (or (gp-config/mldoc-support? ext) (= "edn" ext))
+        (js/decodeURIComponent (string/replace result "." "/"))
+        result))))
 
 (defn- get-page-name
   "Get page name with overridden order of
@@ -203,6 +213,40 @@
          :blocks blocks
          :ast ast}))))
 
+(defn extract-whiteboard-edn
+  "Extracts whiteboard page from given edn file
+   Whiteboard page edn is a subset of page schema
+   - it will only contain a single page (for now). The page properties are stored under :logseq.tldraw.* properties and contain 'bindings' etc
+   - blocks will be adapted to tldraw shapes. All blocks's parent is the given page."
+  [file content {:keys [verbose] :or {verbose true}}]
+  (let [_ (when verbose (println "Parsing start: " file))
+        {:keys [pages blocks]} (gp-util/safe-read-string content)
+        blocks (map
+                 (fn [block]
+                   (-> block
+                       (gp-util/dissoc-in [:block/parent :block/name])
+                       (gp-util/dissoc-in [:block/left :block/name])))
+                 blocks)
+        serialized-page (first pages)
+        ;; whiteboard edn file should normally have valid :block/original-name, :block/name, :block/uuid
+        page-name (-> (or (:block/name serialized-page)
+                          (filepath->page-name file))
+                      (gp-util/page-name-sanity-lc))
+        original-name (or (:block/original-name serialized-page)
+                          page-name)
+        page-block (merge {:block/name page-name
+                           :block/original-name original-name
+                           :block/type "whiteboard"
+                           :block/file {:file/path (gp-util/path-normalize file)}}
+                          serialized-page)
+        page-block (gp-whiteboard/migrate-page-block page-block)
+        blocks (->> blocks
+                    (map gp-whiteboard/migrate-shape-block)
+                    (map #(merge % (gp-whiteboard/with-whiteboard-block-props % page-name))))
+        _ (when verbose (println "Parsing finished: " file))]
+    {:pages (list page-block)
+     :blocks blocks}))
+
 (defn- with-block-uuid
   [pages]
   (->> (gp-util/distinct-by :block/name pages)

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

@@ -60,7 +60,8 @@
    #{:id :custom-id :background-color :background_color :heading :collapsed
      :created-at :updated-at :last-modified-at :created_at :last_modified_at
      :query-table :query-properties :query-sort-by :query-sort-desc :ls-type
-     :hl-type :hl-page :hl-stamp :logseq.macro-name :logseq.macro-arguments}
+     :hl-type :hl-page :hl-stamp :logseq.macro-name :logseq.macro-arguments
+     :logseq.tldraw.page :logseq.tldraw.shape}
    (set (map keyword markers))
    @built-in-extended-properties))
 

+ 32 - 2
deps/graph-parser/src/logseq/graph_parser/util.cljs

@@ -1,9 +1,11 @@
 (ns logseq.graph-parser.util
   "Util fns shared between graph-parser and rest of app. Util fns only rely on
   clojure standard libraries."
-  (:require [clojure.walk :as walk]
+  (:require [cljs.reader :as reader]
+            [clojure.edn :as edn]
             [clojure.string :as string]
-            [clojure.edn :as edn]))
+            [clojure.walk :as walk]
+            [logseq.graph-parser.log :as log]))
 
 (defn safe-url-decode
   [string]
@@ -230,3 +232,31 @@
   (case filename-format
     :triple-lowbar (tri-lb-title-parsing file-name-body)
     (legacy-title-parsing file-name-body)))
+
+(defn safe-read-string
+  [content]
+  (try
+    (reader/read-string content)
+    (catch :default e
+      (log/error :parse/read-string-failed e)
+      {})))
+
+;; Copied from Medley
+;; https://github.com/weavejester/medley/blob/d1e00337cf6c0843fb6547aadf9ad78d981bfae5/src/medley/core.cljc#L22
+(defn dissoc-in
+  "Dissociate a value in a nested associative structure, identified by a sequence
+  of keys. Any collections left empty by the operation will be dissociated from
+  their containing structures."
+  ([m ks]
+   (if-let [[k & ks] (seq ks)]
+     (if (seq ks)
+       (let [v (dissoc-in (get m k) ks)]
+         (if (empty? v)
+           (dissoc m k)
+           (assoc m k v)))
+       (dissoc m k))
+     m))
+  ([m ks & kss]
+   (if-let [[ks' & kss] (seq kss)]
+     (recur (dissoc-in m ks) ks' kss)
+     (dissoc-in m ks))))

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

@@ -0,0 +1,82 @@
+(ns logseq.graph-parser.whiteboard
+  "Whiteboard related parser utilities" 
+  (:require [logseq.graph-parser.util :as gp-util]
+            [logseq.graph-parser.util.block-ref :as block-ref]
+            [logseq.graph-parser.util.page-ref :as page-ref]))
+
+(defn block->shape [block]
+  (get-in block [:block/properties :logseq.tldraw.shape] nil))
+
+(defn page-block->tldr-page [block]
+  (get-in block [:block/properties :logseq.tldraw.page] nil))
+
+(defn shape-block? [block]
+  (= :whiteboard-shape (get-in block [:block/properties :ls-type] nil)))
+
+;; tldraw shape props is now saved into [:block/properties :logseq.tldraw.shape]
+;; migrate
+(defn shape-block-needs-migrate? [block]
+  (let [properties (:block/properties block)]
+    (and (seq properties)
+         (and (= :whiteboard-shape (:ls-type properties))
+              (not (seq (get properties :logseq.tldraw.shape nil)))))))
+
+(defn page-block-needs-migrate? [block]
+  (let [properties (:block/properties block)]
+    (and (seq properties)
+         (and (= :whiteboard-page (:ls-type properties))
+              (not (seq (get properties :logseq.tldraw.page nil)))))))
+
+(defn migrate-shape-block [block]
+  (if (shape-block-needs-migrate? block)
+    (let [properties (:block/properties block)
+          properties (assoc properties :logseq.tldraw.shape properties)]
+      (assoc block :block/properties properties))
+    block))
+
+(defn migrate-page-block [block]
+  (if (page-block-needs-migrate? block)
+    (let [properties (:block/properties block)
+          properties (assoc properties :logseq.tldraw.page properties)]
+      (assoc block :block/properties properties))
+    block))
+
+
+(defn- get-shape-refs [shape]
+  (when (= "logseq-portal" (:type shape))
+    [(if (= (:blockType shape) "P")
+       {:block/name (gp-util/page-name-sanity-lc (:pageId shape))}
+       {:block/uuid (uuid (:pageId shape))})]))
+
+(defn- with-whiteboard-block-refs
+  [shape]
+  (let [refs (or (get-shape-refs shape) [])]
+    (merge {:block/refs refs})))
+
+(defn- with-whiteboard-content
+  "Main purpose of this function is to populate contents when shapes are used as references in outliner."
+  [shape]
+  {:block/content (case (:type shape)
+                    "text" (:text shape)
+                    "logseq-portal" (if (= (:blockType shape) "P")
+                                      (page-ref/->page-ref (:pageId shape))
+                                      (block-ref/->block-ref (:pageId shape)))
+                    "line" (str "whiteboard arrow" (when-let [label (:label shape)] (str ": " label)))
+                    (str "whiteboard " (:type shape)))})
+
+(defn with-whiteboard-block-props
+  [block page-name]
+  (let [shape? (shape-block? block)
+        shape (block->shape block)
+        default-page-ref {:block/name (gp-util/page-name-sanity-lc page-name)}]
+    (merge (if shape?
+             (merge
+              {:block/uuid (uuid (:id shape))}
+              (with-whiteboard-block-refs shape)
+              (with-whiteboard-content shape))
+
+             ;; TODO: remove?
+             {:block/unordered true})
+           (when (nil? (:block/parent block)) {:block/parent default-page-ref})
+           (when (nil? (:block/format block)) {:block/format :markdown}) ;; TODO: read from config
+           {:block/page default-page-ref})))

+ 22 - 0
deps/graph-parser/test/logseq/graph_parser/extract_test.cljs

@@ -64,3 +64,25 @@
     - line2
       - line3
      - line4"))))
+
+(def foo-edn
+  "Example exported whiteboard page as an edn exportable."
+  '{:blocks
+    ({:block/content "foo content a",
+      :block/format :markdown},
+     {:block/content "foo content b",
+      :block/format :markdown}),
+    :pages
+    ({:block/format :markdown,
+      :block/original-name "Foo"
+      :block/properties {:title "my whiteboard foo"}})})
+
+(deftest test-extract-whiteboard-edn
+  []
+  (let [{:keys [pages blocks]} (extract/extract-whiteboard-edn "/whiteboards/foo.edn" (pr-str foo-edn) {})
+        page (first pages)]
+    (is (= (get-in page [:block/file :file/path]) "/whiteboards/foo.edn"))
+    (is (= (:block/name page) "foo"))
+    (is (= (:block/type page) "whiteboard"))
+    (is (= (:block/original-name page) "Foo"))
+    (is (every? #(= (:block/parent %) {:block/name "foo"}) blocks))))

+ 90 - 4
deps/graph-parser/test/logseq/graph_parser_test.cljs

@@ -8,6 +8,52 @@
             [logseq.graph-parser.property :as gp-property]
             [datascript.core :as d]))
 
+(def foo-edn
+  "Example exported whiteboard page as an edn exportable."
+  '{:blocks
+    ({:block/content "foo content a",
+      :block/format :markdown
+      :block/parent {:block/uuid #uuid "16c90195-6a03-4b3f-839d-095a496d9acd"}},
+     {:block/content "foo content b",
+      :block/format :markdown
+      :block/parent {:block/uuid #uuid "16c90195-6a03-4b3f-839d-095a496d9acd"}}),
+    :pages
+    ({:block/format :markdown,
+      :block/name "foo"
+      :block/original-name "Foo"
+      :block/uuid #uuid "16c90195-6a03-4b3f-839d-095a496d9acd"
+      :block/properties {:title "my whiteboard foo"}})})
+
+(def foo-conflict-edn
+  "Example exported whiteboard page as an edn exportable."
+  '{:blocks
+    ({:block/content "foo content a",
+      :block/format :markdown},
+     {:block/content "foo content b",
+      :block/format :markdown}),
+    :pages
+    ({:block/format :markdown,
+      :block/name "foo conflicted"
+      :block/original-name "Foo conflicted"
+      :block/uuid #uuid "16c90195-6a03-4b3f-839d-095a496d9acd"})})
+
+(def bar-edn
+  "Example exported whiteboard page as an edn exportable."
+  '{:blocks
+    ({:block/content "foo content a",
+      :block/format :markdown
+      :block/parent {:block/uuid #uuid "71515b7d-b5fc-496b-b6bf-c58004a34ee3"
+                     :block/name "foo"}},
+     {:block/content "foo content b",
+      :block/format :markdown
+      :block/parent {:block/uuid #uuid "71515b7d-b5fc-496b-b6bf-c58004a34ee3"
+                     :block/name "foo"}}),
+    :pages
+    ({:block/format :markdown,
+      :block/name "bar"
+      :block/original-name "Bar"
+      :block/uuid #uuid "71515b7d-b5fc-496b-b6bf-c58004a34ee3"})})
+
 (deftest parse-file
   (testing "id properties"
     (let [conn (ldb/start-conn)]
@@ -36,14 +82,54 @@
     (let [conn (ldb/start-conn)
           deleted-page (atom nil)]
       (with-redefs [gp-block/with-pre-block-if-exists (fn stub-failure [& _args]
-                                              (throw (js/Error "Testing unexpected failure")))]
+                                                        (throw (js/Error "Testing unexpected failure")))]
         (try
           (graph-parser/parse-file conn "foo.md" "- id:: 628953c1-8d75-49fe-a648-f4c612109098"
-                                  {:delete-blocks-fn (fn [page _file]
-                                                       (reset! deleted-page page))})
+                                   {:delete-blocks-fn (fn [page _file]
+                                                        (reset! deleted-page page))})
           (catch :default _)))
       (is (= nil @deleted-page)
-          "Page should not be deleted when there is unexpected failure"))))
+          "Page should not be deleted when there is unexpected failure")))
+
+  (testing "parsing whiteboard page"
+    (let [conn (ldb/start-conn)]
+      (graph-parser/parse-file conn "/whiteboards/foo.edn" (pr-str foo-edn) {})
+      (let [blocks (d/q '[:find (pull ?b [* {:block/page
+                                             [:block/name
+                                              :block/original-name
+                                              :block/type
+                                              {:block/file
+                                               [:file/path]}]}])
+                          :in $
+                          :where [?b :block/content] [(missing? $ ?b :block/name)]]
+                        @conn)
+            parent (:block/page (ffirst blocks))]
+        (is (= {:block/name "foo"
+                :block/original-name "Foo"
+                :block/type "whiteboard"
+                :block/file {:file/path "/whiteboards/foo.edn"}}
+               parent)
+            "parsed block in the whiteboard page has correct parent page"))))
+
+  (testing "Loading whiteboard pages that same block/uuid should throw an error."
+    (let [conn (ldb/start-conn)]
+      (graph-parser/parse-file conn "/whiteboards/foo.edn" (pr-str foo-edn) {})
+      (is (thrown-with-msg?
+           js/Error
+           #"Conflicting upserts"
+           (graph-parser/parse-file conn "/whiteboards/foo-conflict.edn" (pr-str foo-conflict-edn) {})))))
+
+  (testing "Loading whiteboard pages should ignore the :block/name property inside :block/parent."
+    (let [conn (ldb/start-conn)]
+      (graph-parser/parse-file conn "/whiteboards/foo.edn" (pr-str foo-edn) {})
+      (graph-parser/parse-file conn "/whiteboards/bar.edn" (pr-str bar-edn) {})
+      (let [pages (d/q '[:find ?name
+                         :in $
+                         :where
+                         [?b :block/name ?name]
+                         [?b :block/type "whiteboard"]]
+                    @conn)]
+        (is (= pages #{["foo"] ["bar"]}))))))
 
 (defn- test-property-order [num-properties]
   (let [conn (ldb/start-conn)

+ 3 - 3
e2e-tests/page-rename.spec.ts

@@ -1,4 +1,4 @@
-import { expect } from '@playwright/test'
+import { expect, Page } from '@playwright/test'
 import { test } from './fixtures'
 import { IsMac, createPage, randomLowerString, newBlock, newInnerBlock, randomString, lastBlock } from './utils'
 
@@ -6,7 +6,7 @@ import { IsMac, createPage, randomLowerString, newBlock, newInnerBlock, randomSt
  * Test rename feature
  ***/
 
-async function page_rename_test(page, original_page_name: string, new_page_name: string) {
+async function page_rename_test(page: Page, original_page_name: string, new_page_name: string) {
   let selectAll = 'Control+a'
   if (IsMac) {
     selectAll = 'Meta+a'
@@ -17,7 +17,7 @@ async function page_rename_test(page, original_page_name: string, new_page_name:
   let new_name = new_page_name + rand
 
   await createPage(page, original_name)
-  await page.click('.page-title .title')
+  await page.click('.ls-page-title .page-title')
   await page.waitForSelector('input[type="text"]')
   await page.keyboard.press(selectAll)
   await page.keyboard.press('Backspace')

+ 65 - 0
e2e-tests/whiteboards.spec.ts

@@ -0,0 +1,65 @@
+import { expect } from '@playwright/test'
+import { test } from './fixtures'
+
+test('enable whiteboards', async ({ page }) => {
+    await page.click('#head .toolbar-dots-btn')
+    await page.click('#head .dropdown-wrapper >> text=Settings')
+    await page.click('.settings-modal a[data-id=features]')
+    await page.click('text=Whiteboards >> .. >> .ui__toggle')
+    await page.keyboard.press('Escape')
+    await expect(page.locator('.nav-header .whiteboard')).toBeVisible()
+})
+
+test('create new whiteboard', async ({ page }) => {
+    await page.click('.nav-header .whiteboard')
+    await page.click('#tl-create-whiteboard')
+    await expect(page.locator('.logseq-tldraw')).toBeVisible()
+})
+
+test('set whiteboard title', async ({ page }) => {
+    const title = "my-whiteboard"
+    await page.click('.whiteboard-page-title')
+    await page.type('.whiteboard-page-title .title', title)
+    await page.keyboard.press('Enter')
+    await expect(page.locator('.whiteboard-page-title .title')).toContainText(title);
+})
+
+test('select rectangle tool', async ({ page }) => {
+    await page.keyboard.press('8')
+    await expect(page.locator('.tl-geometry-tools-pane-anchor [title="Rectangle (8)"]')).toHaveAttribute('data-selected', 'true')
+})
+
+test('draw a rectangle', async ({ page }) => {
+    const canvas = await page.waitForSelector('.logseq-tldraw');
+    const bounds = (await canvas.boundingBox())!;
+
+    await page.keyboard.press('8')
+
+    await page.mouse.move(bounds.x, bounds.y);
+    await page.mouse.down();
+
+    await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
+    await page.mouse.up();
+
+    await expect(page.locator('.logseq-tldraw .tl-positioned-svg rect')).not.toHaveCount(0);
+})
+
+test('zoom in', async ({ page }) => {
+    await page.click('#tl-zoom-in')
+    await expect(page.locator('#tl-zoom')).toContainText('125%');
+})
+
+test('zoom out', async ({ page }) => {
+    await page.click('#tl-zoom-out')
+    await expect(page.locator('#tl-zoom')).toContainText('100%');
+})
+
+test('open context menu', async ({ page }) => {
+    await page.locator('.logseq-tldraw').click({button: "right"})
+    await expect(page.locator('.tl-context-menu')).toBeVisible()
+})
+
+test('close context menu on esc', async ({ page }) => {
+    await page.keyboard.press('Escape')
+    await expect(page.locator('.tl-context-menu')).toBeHidden()
+})

+ 2 - 0
externs.js

@@ -130,6 +130,8 @@ dummy.restart = function() {};
 dummy.observe = function() {};
 dummy.contentRect = function() {};
 dummy.height = function() {};
+dummy.createShapes = function() {};
+dummy.updateShapes = function() {};
 
 /**
  * @typedef {{

+ 1 - 4
libs/package.json

@@ -12,8 +12,7 @@
     "dev:core": "npm run build:core -- --mode development --watch",
     "build": "tsc && rm dist/*.js && npm run build:user",
     "lint": "prettier --check \"src/**/*.{ts, js}\"",
-    "fix": "prettier --write \"src/**/*.{ts, js}\"",
-    "build:docs": "typedoc --plugin typedoc-plugin-lsp-docs src/LSPlugin.user.ts"
+    "fix": "prettier --write \"src/**/*.{ts, js}\""
   },
   "dependencies": {
     "csstype": "3.1.0",
@@ -33,8 +32,6 @@
     "prettier-config-standard": "^5.0.0",
     "ts-loader": "9.3.0",
     "typescript": "4.7.3",
-    "typedoc": "^0.22.15",
-    "typedoc-plugin-lsp-docs": "*",
     "webpack": "5.73.0",
     "webpack-bundle-analyzer": "4.5.0",
     "webpack-cli": "4.9.2"

+ 3 - 1
package.json

@@ -66,7 +66,9 @@
         "cljs:build-electron": "clojure -A:cljs compile app electron",
         "cljs:lint": "clojure -M:clj-kondo --parallel --lint src --cache false",
         "ios:dev": "cross-env PLATFORM=ios gulp cap",
-        "android:dev": "cross-env PLATFORM=android gulp cap"
+        "android:dev": "cross-env PLATFORM=android gulp cap",
+        "tldraw:build": "cd tldraw && yarn",
+        "postinstall": "yarn tldraw:build"
     },
     "dependencies": {
         "@capacitor/android": "^4.0.0",

+ 5 - 1
resources/css/common.css

@@ -72,6 +72,7 @@ html[data-theme='dark'] {
   --ls-block-bullet-color: #608e91;
   --ls-block-highlight-color: #0a3d4b;
   --ls-selection-background-color: #338fff;
+  --ls-selection-text-color: #fff;
   --ls-page-checkbox-color: #6093a0;
   --ls-page-checkbox-border-color: var(--ls-primary-background-color);
   --ls-page-blockquote-color: var(--ls-primary-text-color);
@@ -101,6 +102,7 @@ html[data-theme='dark'] {
   --ls-error-background-color: var(--color-red-900);
   --ls-warning-text-color: var(--color-yellow-100);
   --ls-warning-background-color: var(--color-yellow-900);
+  --ls-focus-ring-color: rgba(18, 98, 119, 0.5);
   --color-level-1: var(--ls-secondary-background-color);
   --color-level-2: var(--ls-tertiary-background-color);
   --color-level-3: var(--ls-quaternary-background-color);
@@ -174,6 +176,7 @@ html[data-theme='light'] {
   --ls-error-background-color: var(--color-red-100);
   --ls-warning-text-color: var(--color-yellow-800);
   --ls-warning-background-color: var(--color-yellow-100);
+  --ls-focus-ring-color: rgba(66, 133, 244, 0.5);
   --color-level-1: var(--ls-secondary-background-color);
   --color-level-2: var(--ls-tertiary-background-color);
   --color-level-3: var(--ls-quaternary-background-color);
@@ -192,7 +195,6 @@ html:not(.is-native-android) {
 html {
   /* FIXME: rewrite revealjs.css ? */
   height: unset !important;
-  overflow: auto !important;
 }
 
 body {
@@ -587,6 +589,8 @@ button.menu:focus {
   @apply my-1;
 
   opacity: .5;
+  border-top-width: 1px;
+  border-color: var(--ls-border-color, #ccc);
 }
 
 a.login {

+ 57 - 15
resources/css/tabler-extension.css

@@ -1,6 +1,14 @@
+/**
+ * This file is managed manually by Webfont app.
+ * Steps:
+ *  - download the zip from Webfont app
+ *  - get the tabler-icons-extension.woff2 and replace the one in fonts/tabler-icons-extension.woff2
+ *  - update the tie-xxx css rules
+ */
+
 @font-face {
-  font-family: "tabler-icons-extension";
-  src: url("../fonts/tabler-icons-extension.woff2?6z5ubs") format("woff2");
+  font-family: 'tabler-icons-extension';
+  src: url('../fonts/tabler-icons-extension.woff2?mxgthk') format('woff2');
   font-style: normal;
   font-weight: 400;
 }
@@ -20,40 +28,74 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
-
 .tie-app-feature::before {
-  content: "\ea01";
+  content: '\ea01';
 }
+
 .tie-block::before {
-  content: "\ea02";
+  content: '\ea02';
 }
+
 .tie-block-search::before {
-  content: "\ea03";
+  content: '\ea03';
 }
+
 .tie-connector::before {
-  content: "\ea04";
+  content: '\ea04';
+}
+
+.tie-new-block::before {
+  content: '\ea05';
+}
+
+.tie-new-page::before {
+  content: '\ea06';
+}
+
+.tie-new-whiteboard::before {
+  content: '\ea07';
+}
+
+.tie-new-whiteboard-element::before {
+  content: '\ea08';
 }
+
+.tie-object-compact::before {
+  content: '\ea09';
+}
+
+.tie-object-expanded::before {
+  content: '\ea0a';
+}
+
 .tie-page::before {
-  content: "\ea05";
+  content: '\ea0b';
 }
+
 .tie-page-search::before {
-  content: "\ea06";
+  content: '\ea0c';
 }
+
 .tie-references-hide::before {
-  content: "\ea07";
+  content: '\ea0d';
 }
+
 .tie-references-show::before {
-  content: "\ea08";
+  content: '\ea0e';
 }
+
 .tie-select-cursor::before {
-  content: "\ea09";
+  content: '\ea0f';
 }
+
 .tie-text::before {
-  content: "\ea0a";
+  content: '\ea10';
 }
+
 .tie-whiteboard::before {
-  content: "\ea0b";
+  content: '\ea11';
 }
+
 .tie-whiteboard-element::before {
-  content: "\ea0c";
+  content: '\ea12';
 }

+ 1 - 0
resources/electron.html

@@ -56,5 +56,6 @@ const portal = new MagicPortal(worker);
 <script defer src="./js/code-editor.js"></script>
 <script defer src="./js/age-encryption.js"></script>
 <script defer src="./js/excalidraw.js"></script>
+<script defer src="./js/tldraw.js"></script>
 </body>
 </html>

BIN
resources/fonts/tabler-icons-extension.woff2


+ 1 - 0
resources/index.html

@@ -55,5 +55,6 @@ const portal = new MagicPortal(worker);
 <script defer src="./js/code-editor.js"></script>
 <script defer src="./js/age-encryption.js"></script>
 <script defer src="./js/excalidraw.js"></script>
+<script defer src="./js/tldraw.js"></script>
 </body>
 </html>

+ 13 - 3
shadow-cljs.edn

@@ -5,6 +5,8 @@
  ;; "." for /static
  :dev-http {3001 ["static" "."]}
 
+ :js-options {:js-package-dirs ["node_modules" "tldraw/apps"]}
+
  :builds
  {:app {:target        :browser
         :module-loader true
@@ -19,7 +21,11 @@
                          :depends-on #{:main}}
                         :excalidraw
                         {:entries    [frontend.extensions.excalidraw]
-                         :depends-on #{:main}}}
+                         :depends-on #{:main}}
+                        :tldraw
+                        {:entries    [frontend.extensions.tldraw]
+                         :depends-on #{:main}}
+                        }
         :output-dir       "./static/js"
         :asset-path       "/static/js"
         :release          {:asset-path "https://asset.logseq.com/static/js"}
@@ -37,7 +43,7 @@
         ;; NOTE: electron, browser/mobile-app use different asset-paths.
         ;;   For browser/mobile-app devs, assets are located in /static/js(via HTTP root).
         ;;   For electron devs, assets are located in ./js(via relative path).
-        ; :dev      {:asset-path "./js"}
+        ;; :dev      {:asset-path "./js"}
         :devtools {:before-load frontend.core/stop  ;; before live-reloading any code call this function
                    :after-load  frontend.core/start ;; after live-reloading finishes call this function
                    :watch-path  "/static"
@@ -80,7 +86,11 @@
                                 :depends-on #{:main}}
                                :excalidraw
                                {:entries    [frontend.extensions.excalidraw]
-                                :depends-on #{:main}}}
+                                :depends-on #{:main}}
+                               :tldraw
+                               {:entries    [frontend.extensions.tldraw]
+                                :depends-on #{:main}}
+                               }
                :output-dir       "./static/js/publishing"
                :asset-path       "static/js"
                :closure-defines  {frontend.config/PUBLISHING true

+ 2 - 2
src/electron/electron/core.cljs

@@ -118,7 +118,7 @@
                            ["css" "fonts" "icons" "img" "js"])))
                 export-css (. fs readFile export-or-custom-css-path)
                 _ (. fs writeFile (path/join static-dir "css" "export.css") export-css)
-                js-files ["main.js" "code-editor.js" "excalidraw.js"]
+                js-files ["main.js" "code-editor.js" "excalidraw.js" "tldraw.js"]
                 _ (p/all (map (fn [file]
                                 (. fs removeSync (path/join static-dir "js" file)))
                               js-files))
@@ -132,7 +132,7 @@
                 ;; TODO: ugly, replace with ls-files and filter with ".map"
                 _ (p/all (map (fn [file]
                                 (. fs removeSync (path/join static-dir "js" (str file ".map"))))
-                              ["main.js" "code-editor.js" "excalidraw.js" "age-encryption.js"]))]
+                              ["main.js" "code-editor.js" "excalidraw.js" "tldraw.js" "age-encryption.js"]))]
           (. dialog showMessageBox (clj->js {:message (str "Export public pages and publish assets to " root-dir " successfully")})))))))
 
 (defn setup-app-manager!

+ 1 - 1
src/electron/electron/handler.cljs

@@ -142,7 +142,7 @@
   (fs/statSync path))
 
 (defonce allowed-formats
-  #{:org :markdown :md :edn :json :js :css :excalidraw})
+  #{:org :markdown :md :edn :json :js :css :excalidraw :tldr})
 
 (defn get-ext
   [p]

+ 48 - 33
src/electron/electron/utils.js

@@ -5,27 +5,36 @@ import fse from 'fs-extra'
 // We set an intercept on incoming requests to disable x-frame-options
 // headers.
 
+// Should we do this? Does this make evil sites doing danagerous things?
 export const disableXFrameOptions = (win) => {
-  win.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] },
-    (d, c) => {
-      if (d.responseHeaders['X-Frame-Options']) {
-        delete d.responseHeaders['X-Frame-Options']
-      } else if (d.responseHeaders['x-frame-options']) {
-        delete d.responseHeaders['x-frame-options']
-      }
+  win.webContents.session.webRequest.onHeadersReceived((d, c) => {
+    if (d.responseHeaders['X-Frame-Options']) {
+      delete d.responseHeaders['X-Frame-Options']
+    }
 
-      c({ cancel: false, responseHeaders: d.responseHeaders })
+    if (d.responseHeaders['x-frame-options']) {
+      delete d.responseHeaders['x-frame-options']
     }
-  )
+
+    if (d.responseHeaders['Content-Security-Policy']) {
+      delete d.responseHeaders['Content-Security-Policy']
+    }
+
+    if (d.responseHeaders['content-security-policy']) {
+      delete d.responseHeaders['content-security-policy']
+    }
+
+    c({ cancel: false, responseHeaders: d.responseHeaders })
+  })
 }
 
-export async function getAllFiles (dir, exts) {
+export async function getAllFiles(dir, exts) {
   const dirents = await fse.readdir(dir, { withFileTypes: true })
 
   if (exts != null) {
     !Array.isArray(exts) && (exts = [exts])
 
-    exts = exts.map(it => {
+    exts = exts.map((it) => {
       if (typeof it === 'string' && it !== '' && !it.startsWith('.')) {
         it = '.' + it
       }
@@ -34,37 +43,43 @@ export async function getAllFiles (dir, exts) {
     })
   }
 
-  const files = await Promise.all(dirents.map(async (dirent) => {
-    const filePath = path.resolve(dir, dirent.name)
+  const files = await Promise.all(
+    dirents.map(async (dirent) => {
+      const filePath = path.resolve(dir, dirent.name)
 
-    if (dirent.isDirectory()) {
-      return getAllFiles(filePath, exts)
-    }
+      if (dirent.isDirectory()) {
+        return getAllFiles(filePath, exts)
+      }
 
-    if (exts && !exts.includes(path.extname(dirent.name)?.toLowerCase())) {
-      return null
-    }
+      if (exts && !exts.includes(path.extname(dirent.name)?.toLowerCase())) {
+        return null
+      }
 
-    const fileStats = await fse.lstat(filePath)
+      const fileStats = await fse.lstat(filePath)
 
-    const stats = {
-      size: fileStats.size,
-      accessTime: fileStats.atimeMs,
-      modifiedTime: fileStats.mtimeMs,
-      changeTime: fileStats.ctimeMs,
-      birthTime: fileStats.birthtimeMs
-    }
+      const stats = {
+        size: fileStats.size,
+        accessTime: fileStats.atimeMs,
+        modifiedTime: fileStats.mtimeMs,
+        changeTime: fileStats.ctimeMs,
+        birthTime: fileStats.birthtimeMs,
+      }
 
-    return { path: filePath, ...stats }
-  }))
-  return files.flat().filter(it => it != null)
+      return { path: filePath, ...stats }
+    })
+  )
+  return files.flat().filter((it) => it != null)
 }
 
-export async function deepReadDir (dirPath, flat = true) {
+export async function deepReadDir(dirPath, flat = true) {
   const ret = await Promise.all(
-    (await fse.readdir(dirPath)).map(async (entity) => {
+    (
+      await fse.readdir(dirPath)
+    ).map(async (entity) => {
       const root = path.join(dirPath, entity)
-      return (await fse.lstat(root)).isDirectory() ? await deepReadDir(root) : root
+      return (await fse.lstat(root)).isDirectory()
+        ? await deepReadDir(root)
+        : root
     })
   )
 

+ 7 - 7
src/main/frontend/commands.cljs

@@ -155,7 +155,7 @@
   ([type optional]
    (let [format (get (state/get-edit-block) :block/format)
          markdown-src? (and (= format :markdown)
-                       (= (string/lower-case type) "src"))
+                            (= (string/lower-case type) "src"))
          [left right] (cond
                         markdown-src?
                         ["```" "\n```"]
@@ -282,7 +282,7 @@
      ["Embed HTML " (->inline "html")]
 
      ["Embed Video URL" [[:editor/input "{{video }}" {:last-pattern (state/get-editor-command-trigger)
-                                                    :backward-pos 2}]]]
+                                                      :backward-pos 2}]]]
 
      ["Embed Youtube timestamp" [[:youtube/insert-timestamp]]]
 
@@ -580,10 +580,10 @@
 (defmethod handle-step :editor/insert-properties [[_ _] _format]
   (when-let [input-id (state/get-edit-input-id)]
     (when-let [current-input (gdom/getElement input-id)]
-        (let [format (or (db/get-page-format (state/get-current-page)) (state/get-preferred-format))
-              edit-content (gobj/get current-input "value")
-              new-value (property/insert-property format edit-content "" "")]
-          (state/set-edit-content! input-id new-value)))))
+      (let [format (or (db/get-page-format (state/get-current-page)) (state/get-preferred-format))
+            edit-content (gobj/get current-input "value")
+            new-value (property/insert-property format edit-content "" "")]
+        (state/set-edit-content! input-id new-value)))))
 
 (defmethod handle-step :editor/move-cursor-to-properties [[_]]
   (when-let [input-id (state/get-edit-input-id)]
@@ -644,7 +644,7 @@
         macro (youtube/gen-youtube-ts-macro)]
     (when-let [input (gdom/getElement input-id)]
       (when macro
-       (util/insert-at-current-position! input (str macro " "))))))
+        (util/insert-at-current-position! input (str macro " "))))))
 
 (defmethod handle-step :youtube/insert-timestamp [[_]]
   (let [input-id (state/get-edit-input-id)

+ 185 - 107
src/main/frontend/components/block.cljs

@@ -5,6 +5,7 @@
             [cljs-bean.core :as bean]
             [cljs.core.match :refer [match]]
             [cljs.reader :as reader]
+            [clojure.set :as set]
             [clojure.string :as string]
             [clojure.walk :as walk]
             [datascript.core :as d]
@@ -22,8 +23,8 @@
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
             [frontend.db.model :as model]
-            [frontend.db.react :as react]
             [frontend.db.query-dsl :as query-dsl]
+            [frontend.db.react :as react]
             [frontend.db.utils :as db-utils]
             [frontend.extensions.highlight :as highlight]
             [frontend.extensions.latex :as latex]
@@ -39,11 +40,13 @@
             [frontend.handler.common :as common-handler]
             [frontend.handler.dnd :as dnd]
             [frontend.handler.editor :as editor-handler]
+            [frontend.handler.file-sync :as file-sync]
             [frontend.handler.plugin :as plugin-handler]
             [frontend.handler.query :as query-handler]
             [frontend.handler.repeated :as repeated]
             [frontend.handler.route :as route-handler]
             [frontend.handler.ui :as ui-handler]
+            [frontend.handler.whiteboard :as whiteboard-handler]
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.outliner.tree :as tree]
             [frontend.search :as search]
@@ -62,17 +65,15 @@
             [logseq.graph-parser.block :as gp-block]
             [logseq.graph-parser.config :as gp-config]
             [logseq.graph-parser.mldoc :as gp-mldoc]
-            [logseq.graph-parser.text :as text]
             [logseq.graph-parser.property :as gp-property]
+            [logseq.graph-parser.text :as text]
             [logseq.graph-parser.util :as gp-util]
-            [logseq.graph-parser.util.page-ref :as page-ref]
             [logseq.graph-parser.util.block-ref :as block-ref]
+            [logseq.graph-parser.util.page-ref :as page-ref]
             [medley.core :as medley]
             [promesa.core :as p]
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]
-            [frontend.handler.file-sync :as file-sync]
-            [clojure.set :as set]
             [shadow.loader :as loader]))
 
 (defn safe-read-string
@@ -525,7 +526,7 @@
 (declare page-reference)
 
 (defn open-page-ref
-  [e page-name redirect-page-name page-name-in-block contents-page?]
+  [e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?]
   (util/stop e)
   (cond
     (gobj/get e "shiftKey")
@@ -535,6 +536,14 @@
        (:db/id page-entity)
        :page))
 
+    (whiteboard-handler/inside-portal (.-target e))
+    (whiteboard-handler/add-new-block-portal-shape!
+     page-name
+     (whiteboard-handler/closest-shape (.-target e)))
+
+    whiteboard-page?
+    (route-handler/redirect-to-whiteboard! page-name)
+
     (not= redirect-page-name page-name)
     (route-handler/redirect-to-page! redirect-page-name)
 
@@ -551,17 +560,18 @@
    page-name-in-block is the overridable name of the page (legacy)
 
    All page-names are sanitized except page-name-in-block"
-  [config page-name-in-block page-name redirect-page-name page-entity contents-page? children html-export? label]
-  (let [tag? (:tag? config)]
+  [config page-name-in-block page-name redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?]
+  (let [tag? (:tag? config)
+        config (assoc config :whiteboard-page? whiteboard-page?)]
     [:a
      {:tabIndex "0"
       :class (cond-> (if tag? "tag" "page-ref")
                (:property? config)
                (str " page-property-key block-property"))
       :data-ref page-name
-      :on-mouse-down (fn [e] (open-page-ref e page-name redirect-page-name page-name-in-block contents-page?))
+      :on-mouse-down (fn [e] (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?))
       :on-key-up (fn [e] (when (and e (= (.-key e) "Enter"))
-                           (open-page-ref e page-name redirect-page-name page-name-in-block contents-page?)))}
+                           (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?)))}
 
      (if (and (coll? children) (seq children))
        (for [child children]
@@ -592,52 +602,56 @@
   [{:keys [children sidebar? tippy-position tippy-distance fixed-position? open? manual?] :as config} page-name]
   (let [*tippy-ref (rum/create-ref)
         page-name (util/page-name-sanity-lc page-name)
+        whiteboard-page? (model/whiteboard-page? page-name)
         redirect-page-name (or (model/get-redirect-page-name page-name (:block/alias? config))
                                page-name)
         page-original-name (model/get-page-original-name redirect-page-name)
         _  #_:clj-kondo/ignore (rum/defc html-template []
-                        (let [*el-popup (rum/use-ref nil)]
+                                 (let [*el-popup (rum/use-ref nil)]
 
-                          (rum/use-effect!
-                            (fn []
-                              (let [el-popup (rum/deref *el-popup)
-                                    cb (fn [^js e]
-                                         (when-not (:editor/editing? @state/state)
+                                   (rum/use-effect!
+                                    (fn []
+                                      (let [el-popup (rum/deref *el-popup)
+                                            cb (fn [^js e]
+                                                 (when-not (:editor/editing? @state/state)
                                            ;; Esc
-                                           (and (= e.which 27)
-                                                (when-let [tp (rum/deref *tippy-ref)]
-                                                  (.hideTooltip tp)))))]
-
-                                (js/setTimeout #(.focus el-popup))
-                                (.addEventListener el-popup "keyup" cb)
-                                #(.removeEventListener el-popup "keyup" cb)))
-                            [])
-
-                          (when redirect-page-name
-                            [:div.tippy-wrapper.overflow-y-auto.p-4.outline-none
-                             {:ref   *el-popup
-                              :tab-index -1
-                              :style {:width          600
-                                      :text-align     "left"
-                                      :font-weight    500
-                                      :max-height     600
-                                      :padding-bottom 64}}
-                             (if (and (string? page-original-name) (string/includes? page-original-name "/"))
-                               [:div.my-2
-                                (->>
-                                  (for [page (string/split page-original-name #"/")]
-                                    (when (and (string? page) page)
-                                      (page-reference false page {} nil)))
-                                  (interpose [:span.mx-2.opacity-30 "/"]))]
-                               [:h2.font-bold.text-lg (if (= page-name redirect-page-name)
-                                                        page-original-name
-                                                        [:span
-                                                         [:span.text-sm.mr-2 "Alias:"]
-                                                         page-original-name])])
-                             (let [page (db/entity [:block/name (util/page-name-sanity-lc redirect-page-name)])]
-                               (editor-handler/insert-first-page-block-if-not-exists! redirect-page-name {:redirect? false})
-                               (when-let [f (state/get-page-blocks-cp)]
-                                 (f (state/get-current-repo) page {:sidebar? sidebar? :preview? true})))])))]
+                                                   (and (= e.which 27)
+                                                        (when-let [tp (rum/deref *tippy-ref)]
+                                                          (.hideTooltip tp)))))]
+
+                                        (js/setTimeout #(.focus el-popup))
+                                        (.addEventListener el-popup "keyup" cb)
+                                        #(.removeEventListener el-popup "keyup" cb)))
+                                    [])
+
+                                   (when redirect-page-name
+                                     [:div.tippy-wrapper.overflow-y-auto.p-4.outline-none
+                                      {:ref   *el-popup
+                                       :tab-index -1
+                                       :style {:width          600
+                                               :text-align     "left"
+                                               :font-weight    500
+                                               :max-height     600
+                                               :padding-bottom 64}}
+                                      (if (and (string? page-original-name) (string/includes? page-original-name "/"))
+                                        [:div.my-2
+                                         (->>
+                                          (for [page (string/split page-original-name #"/")]
+                                            (when (and (string? page) page)
+                                              (page-reference false page {} nil)))
+                                          (interpose [:span.mx-2.opacity-30 "/"]))]
+                                        [:h2.font-bold.text-lg (if (= page-name redirect-page-name)
+                                                                 page-original-name
+                                                                 [:span
+                                                                  [:span.text-sm.mr-2 "Alias:"]
+                                                                  page-original-name])])
+                                      (let [page (db/entity [:block/name (util/page-name-sanity-lc redirect-page-name)])]
+                                        (editor-handler/insert-first-page-block-if-not-exists! redirect-page-name {:redirect? false})
+                                        (let [page-blocks-cp (state/get-page-blocks-cp)
+                                              tldraw-preview (state/get-component :whiteboard/tldraw-preview)]
+                                          (if whiteboard-page?
+                                            (tldraw-preview page-name)
+                                            (page-blocks-cp (state/get-current-repo) page {:sidebar? sidebar? :preview? true}))))])))]
 
     (if (or (not manual?) open?)
       (ui/tippy {:ref             *tippy-ref
@@ -661,6 +675,7 @@
     (let [page-name-in-block (gp-util/remove-boundary-slashes page-name-in-block)
           page-name (util/page-name-sanity-lc page-name-in-block)
           page-entity (db/entity [:block/name page-name])
+          whiteboard-page? (model/whiteboard-page? page-name)
           redirect-page-name (or (and (= :org (state/get-preferred-format))
                                       (:org-mode/insert-file-link? (state/get-config))
                                       redirect-page-name)
@@ -668,7 +683,10 @@
           inner (page-inner config
                             page-name-in-block
                             page-name
-                            redirect-page-name page-entity contents-page? children html-export? label)]
+                            redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?)
+          inner (if whiteboard-page?
+                  [:<> [:span.text-gray-500 (ui/icon "whiteboard" {:extension? true}) " "] inner]
+                  inner)]
       (cond
         (:breadcrumb? config)
         (or (:block/original-name page)
@@ -730,11 +748,9 @@
       (draw-component {:file file :block-uuid block-uuid}))))
 
 (rum/defc page-reference < rum/reactive
-  [html-export? s config label]
+  [html-export? s {:keys [nested-link? block-uuid id] :as config} label]
   (let [show-brackets? (state/show-brackets?)
-        nested-link? (:nested-link? config)
-        contents-page? (= "contents" (string/lower-case (str (:id config))))
-        block-uuid (:block/uuid config)]
+        contents-page? (= "contents" (string/lower-case (str id)))]
     (if (string/ends-with? s ".excalidraw")
       [:div.draw {:on-click (fn [e]
                               (.stopPropagation e))}
@@ -792,7 +808,8 @@
 (rum/defc page-embed < rum/reactive db-mixins/query
   [config page-name]
   (let [page-name (util/page-name-sanity-lc (string/trim page-name))
-        current-page (state/get-current-page)]
+        current-page (state/get-current-page)
+        whiteboard-page? (model/whiteboard-page? page-name)]
     [:div.color-level.embed.embed-page.bg-base-2
      {:class (when (:sidebar? config) "in-sidebar")
       :on-double-click #(edit-parent-block % config)
@@ -805,14 +822,16 @@
                   page-name)
             (not= (util/page-name-sanity-lc (get config :id ""))
                   page-name))
-       (let [page (model/get-page page-name)
-             blocks (db/get-paginated-blocks (state/get-current-repo) (:db/id page))]
-         (blocks-container blocks (assoc config
-                                         :db/id (:db/id page)
-                                         :id page-name
-                                         :embed? true
-                                         :page-embed? true
-                                         :ref? false))))]))
+       (if whiteboard-page?
+         ((state/get-component :whiteboard/tldraw-preview) page-name)
+         (let [page (model/get-page page-name)
+               blocks (db/get-paginated-blocks (state/get-current-repo) (:db/id page))]
+           (blocks-container blocks (assoc config
+                                           :db/id (:db/id page)
+                                           :id page-name
+                                           :embed? true
+                                           :page-embed? true
+                                           :ref? false)))))]))
 
 (defn- get-label-text
   [label]
@@ -845,10 +864,11 @@
           block (when db-id (db/pull-block db-id))
           block-type (keyword (get-in block [:block/properties :ls-type]))
           hl-type (get-in block [:block/properties :hl-type])
-          repo (state/get-current-repo)]
+          repo (state/get-current-repo)
+          stop-inner-events? (= block-type :whiteboard-shape)]
       (if (and block (:block/content block))
-        (let [title [:span {:class "block-ref"}
-                     (block-content (assoc config :block-ref? true)
+        (let [title [:span.block-ref
+                     (block-content (assoc config :block-ref? true :stop-events? stop-inner-events?)
                                     block nil (:block/uuid block)
                                     (:slide? config))]
               inner (if label
@@ -871,16 +891,26 @@
                        (not (util/right-click? e)))
                   (util/stop e)
 
-                  (if (gobj/get e "shiftKey")
+                  (cond
+                    (gobj/get e "shiftKey")
                     (state/sidebar-add-block!
                      (state/get-current-repo)
                      (:db/id block)
                      :block-ref)
 
+                    (whiteboard-handler/inside-portal (.-target e))
+                    (whiteboard-handler/add-new-block-portal-shape!
+                     (:block/uuid block)
+                     (whiteboard-handler/closest-shape (.-target e)))
+
+                    :else
                     (match [block-type (util/electron?)]
                       ;; pdf annotation
                       [:annotation true] (pdf-assets/open-block-ref! block)
 
+                      [:whiteboard-shape true] (route-handler/redirect-to-whiteboard!
+                                                (get-in block [:block/page :block/name]) {:block-id block-id})
+
                       ;; default open block page
                       :else (route-handler/redirect-to-page! id))))))}
 
@@ -1617,13 +1647,22 @@
 
 (defn- bullet-on-click
   [e block uuid]
-  (if (gobj/get e "shiftKey")
+  (cond
+    (gobj/get e "shiftKey")
     (do
       (state/sidebar-add-block!
        (state/get-current-repo)
        (:db/id block)
        :block)
       (util/stop e))
+
+    (whiteboard-handler/inside-portal (.-target e))
+    (do (whiteboard-handler/add-new-block-portal-shape!
+         uuid
+         (whiteboard-handler/closest-shape (.-target e)))
+        (util/stop e))
+
+    :else
     (route-handler/redirect-to-page! uuid)))
 
 (rum/defc block-children < rum/reactive
@@ -1890,7 +1929,8 @@
        (if title
          (conj
           (map-inline config title)
-          (when (and (util/electron?) (not= block-type :default))
+          (when (= block-type :whiteboard-shape) [:span.mr-1 (ui/icon "whiteboard-element" {:extension? true})])
+          (when (and (util/electron?) (not (#{:default :whiteboard-shape} block-type)))
             [:a.prefix-link
              {:on-click #(case block-type
                            ;; pdf annotation
@@ -2176,6 +2216,7 @@
                                                  (merge block (block/parse-title-and-body uuid format pre-block? content)))
         collapsed? (util/collapsed? block)
         block-ref? (:block-ref? config)
+        stop-events? (:stop-events? config)
         block-ref-with-title? (and block-ref? (seq title))
         block-type (or (:ls-type properties) :default)
         content (if (string? content) (string/trim content) "")
@@ -2186,7 +2227,7 @@
         attrs (cond->
                {:blockid       (str uuid)
                 :data-type (name block-type)
-                :style {:width "100%"}}
+                :style {:width "100%" :pointer-events (when stop-events? "none")}}
                 (not block-ref?)
                 (assoc mouse-down-key (fn [e]
                                         (block-content-on-mouse-down e block block-id content edit-input-id))))]
@@ -2236,7 +2277,8 @@
                  (let [hidden? (property/properties-hidden? properties)]
                    (not hidden?))
                  (not (and block-ref? (or (seq title) (seq body))))
-                 (not (:slide? config)))
+                 (not (:slide? config))
+                 (not= block-type :whiteboard-shape))
         (properties-cp config block))
 
       (let [title-collapse-enabled? (:outliner/block-title-collapse-enabled? (state/get-config))]
@@ -2293,7 +2335,7 @@
 
 (rum/defcs block-content-or-editor < rum/reactive
   (rum/local true ::hide-block-refs?)
-  [state config {:block/keys [uuid format] :as block} edit-input-id block-id edit?]
+  [state config {:block/keys [uuid format] :as block} edit-input-id block-id edit? hide-block-refs-count?]
   (let [*hide-block-refs? (get state ::hide-block-refs?)
         hide-block-refs? @*hide-block-refs?
         editor-box (get config :editor-box)
@@ -2331,29 +2373,66 @@
                                            (editor-handler/unhighlight-blocks!)
                                            (state/set-editing! edit-input-id (:block/content block) block ""))}})
             (block-content config block edit-input-id block-id slide?))]
-          [:div.flex.flex-row.items-center
-           (when (and (:embed? config)
-                      (:embed-parent config))
-             [:a.opacity-70.hover:opacity-100.svg-small.inline
-              {:on-mouse-down (fn [e]
-                                (util/stop e)
-                                (when-let [block (:embed-parent config)]
-                                  (editor-handler/edit-block! block :max (:block/uuid block))))}
-              svg/edit])
-
-           (when block-reference-only?
-             [:a.opacity-70.hover:opacity-100.svg-small.inline
-              {:on-mouse-down (fn [e]
-                                (util/stop e)
-                                (editor-handler/edit-block! block :max (:block/uuid block)))}
-              svg/edit])
-
-           (block-refs-count block *hide-block-refs?)]]
+          (when-not hide-block-refs-count?
+            [:div.flex.flex-row.items-center
+             (when (and (:embed? config)
+                        (:embed-parent config))
+               [:a.opacity-70.hover:opacity-100.svg-small.inline
+                {:on-mouse-down (fn [e]
+                                  (util/stop e)
+                                  (when-let [block (:embed-parent config)]
+                                    (editor-handler/edit-block! block :max (:block/uuid block))))}
+                svg/edit])
+
+             (when block-reference-only?
+               [:a.opacity-70.hover:opacity-100.svg-small.inline
+                {:on-mouse-down (fn [e]
+                                  (util/stop e)
+                                  (editor-handler/edit-block! block :max (:block/uuid block)))}
+                svg/edit])
+
+             (block-refs-count block *hide-block-refs?)])]
 
          (when (and (not hide-block-refs?) (> refs-count 0))
            (let [refs-cp (state/get-component :block/linked-references)]
              (refs-cp uuid)))]))))
 
+;; FIXME: not updating when block content is updated outbound
+(rum/defcs single-block-cp-inner < rum/reactive db-mixins/query
+  ;; todo: mixin for init-blocks-container-id?
+  {:init (fn [state]
+           (assoc state
+                  ::init-blocks-container-id (atom nil)))}
+  [state block-uuid]
+  (let [uuid (if (string? block-uuid) (uuid block-uuid) block-uuid)
+        *init-blocks-container-id (::init-blocks-container-id state)
+        block-entity (db/entity [:block/uuid uuid])
+        block-id (:db/id block-entity)
+        block (first (model/get-paginated-blocks (state/get-current-repo) block-id))
+        blocks-container-id (if @*init-blocks-container-id
+                              @*init-blocks-container-id
+                              (let [id' (swap! *blocks-container-id inc)]
+                                (reset! *init-blocks-container-id id')
+                                id'))
+        block-el-id (str "ls-block-" blocks-container-id "-" uuid)
+        config {:id (str uuid)
+                :db/id (:db/id block-entity)
+                :block/uuid uuid
+                :block? true
+                :editor-box (state/get-component :editor/box)}
+        edit-input-id (str "edit-block-" blocks-container-id "-" uuid)
+        edit? (state/sub [:editor/editing? edit-input-id])
+        block (block/parse-title-and-body block)]
+    (when (:block/content block)
+      [:div.single-block.ls-block
+       {:class (str block-uuid)
+        :id (str "ls-block-" blocks-container-id "-" block-uuid)}
+       (block-content-or-editor config block edit-input-id block-el-id edit? true)])))
+
+(rum/defc single-block-cp
+  [block-uuid]
+  (single-block-cp-inner block-uuid))
+
 (defn non-dragging?
   [e]
   (and (= (gobj/get e "buttons") 1)
@@ -2470,6 +2549,16 @@
   [*move-to]
   (reset! *move-to nil))
 
+(defn block-drag-end
+  ([_event]
+   (block-drag-end _event *move-to))
+  ([_event *move-to]
+   (reset! *dragging? false)
+   (reset! *dragging-block nil)
+   (reset! *drag-to-block nil)
+   (reset! *move-to nil)
+   (editor-handler/unhighlight-blocks!)))
+
 (defn- block-drop
   [event uuid target-block *move-to]
   (util/stop event)
@@ -2479,19 +2568,7 @@
           selected (db/pull-many (state/get-current-repo) '[*] lookup-refs)
           blocks (if (seq selected) selected [@*dragging-block])]
       (dnd/move-blocks event blocks target-block @*move-to)))
-  (reset! *dragging? false)
-  (reset! *dragging-block nil)
-  (reset! *drag-to-block nil)
-  (reset! *move-to nil)
-  (editor-handler/unhighlight-blocks!))
-
-(defn- block-drag-end
-  [_event *move-to]
-  (reset! *dragging? false)
-  (reset! *dragging-block nil)
-  (reset! *drag-to-block nil)
-  (reset! *move-to nil)
-  (editor-handler/unhighlight-blocks!))
+  (block-drag-end event *move-to))
 
 (defn- block-mouse-over
   [e *control-show? block-id doc-mode?]
@@ -2668,7 +2745,7 @@
       (when @*show-left-menu?
         (block-left-menu config block))
 
-      (block-content-or-editor config block edit-input-id block-id edit?)
+      (block-content-or-editor config block edit-input-id block-id edit? false)
 
       (when @*show-right-menu?
         (block-right-menu config block edit?))]
@@ -3492,7 +3569,8 @@
           (let [blocks (remove nil? blocks)]
             (when (seq blocks)
               (let [alias? (:block/alias? page)
-                    page (db/entity (:db/id page))]
+                    page (db/entity (:db/id page))
+                    whiteboard? (= "whiteboard" (:block/type page))]
                 [:div.my-2 (cond-> {:key (str "page-" (:db/id page))}
                              (:ref? config)
                              (assoc :class "color-level px-2 sm:px-7 py-2 rounded"))
@@ -3500,7 +3578,7 @@
                   [:div
                    (page-cp config page)
                    (when alias? [:span.text-sm.font-medium.opacity-50 " Alias"])]
-                  (blocks-container blocks config)
+                  (when-not whiteboard? (blocks-container blocks config))
                   {})])))))]
 
      :else

+ 14 - 0
src/main/frontend/components/command_palette.css

@@ -17,10 +17,12 @@
       transition: none;
       border: none;
       border-radius: unset !important;
+      background: none;
     }
 
     .chosen {
       background-color: var(--ls-quaternary-background-color);
+      color: var(--ls-secondary-text-color);
     }
 
     .command-results-wrap,
@@ -43,6 +45,18 @@
         }
       }
     }
+
+    .cp__palette-input {
+      color: var(--ls-secondary-text-color);
+    }
+
+    .search-result {
+      @apply text-sm font-medium flex items-baseline;
+    }
+
+    .ui__icon {
+      font-size: 16px;
+    }
   }
 
   &-input {

+ 8 - 5
src/main/frontend/components/editor.cljs

@@ -1,6 +1,5 @@
 (ns frontend.components.editor
   (:require [clojure.string :as string]
-            [goog.string :as gstring]
             [frontend.commands :as commands
              :refer [*first-command-group *matched-block-commands *matched-commands]]
             [frontend.components.block :as block]
@@ -12,9 +11,9 @@
             [frontend.db.model :as db-model]
             [frontend.extensions.zotero :as zotero]
             [frontend.handler.editor :as editor-handler :refer [get-state]]
-            [frontend.handler.paste :as paste-handler]
             [frontend.handler.editor.lifecycle :as lifecycle]
             [frontend.handler.page :as page-handler]
+            [frontend.handler.paste :as paste-handler]
             [frontend.mixins :as mixins]
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.state :as state]
@@ -22,9 +21,10 @@
             [frontend.util :as util]
             [frontend.util.cursor :as cursor]
             [frontend.util.keycode :as keycode]
-            [logseq.graph-parser.util :as gp-util]
-            [logseq.graph-parser.property :as gp-property]
             [goog.dom :as gdom]
+            [goog.string :as gstring]
+            [logseq.graph-parser.property :as gp-property]
+            [logseq.graph-parser.util :as gp-util]
             [promesa.core :as p]
             [react-draggable]
             [rum.core :as rum]))
@@ -149,7 +149,10 @@
               :item-render (fn [page-name chosen?]
                              [:div.preview-trigger-wrapper
                               (block/page-preview-trigger
-                               {:children        [:div (search/highlight-exact-query page-name q)]
+                               {:children
+                                [:div.flex
+                                 (when (db-model/whiteboard-page? page-name) [:span.mr-1 (ui/icon "whiteboard" {:extension? true})])
+                                 (search/highlight-exact-query page-name q)]
                                 :open?           chosen?
                                 :manual?         true
                                 :fixed-position? true

+ 3 - 3
src/main/frontend/components/header.css

@@ -46,7 +46,7 @@
     justify-content: center;
     opacity: .8;
 
-    .ti {
+    .ti, .tie {
       font-size: 20px;
     }
   }
@@ -105,7 +105,7 @@
   }
 
   .dropdown-wrapper {
-    .ti {
+    .ti, .tie {
       opacity: .9;
     }
   }
@@ -317,7 +317,7 @@ html.is-native-iphone {
   --ls-headbar-inner-top-padding: 36px;
 
   .left-sidebar-inner {
-    .new-page {
+    .create {
       padding-bottom: 32px;
     }
   }

+ 2 - 2
src/main/frontend/components/onboarding/index.css

@@ -151,7 +151,7 @@ body[data-page=import] {
                     background-position-x: center;
                   }
 
-                  > .ti {
+                  > .ti, .tie {
                     opacity: .25;
                     color: black;
                   }
@@ -450,7 +450,7 @@ body[data-page=import] {
     box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
     font-weight: 700;
 
-    .ti {
+    .ti, .tie {
       padding-right: 6px;
       font-size: 18px;
       position: relative;

+ 2 - 2
src/main/frontend/components/onboarding/setups.cljs

@@ -111,9 +111,9 @@
 
        [:ul
         (for [[title label icon]
-              [["Graphics & Documents" "/assets" "artboard"]
+              [["Graphics & Documents" "/assets" "whiteboard"]
                ["Daily notes" "/journals" "calendar-plus"]
-               ["PAGES" "/pages" "file-text"]
+               ["PAGES" "/pages" "page"]
                []
                ["APP Internal" "/logseq" "tool"]
                ["Config File" "/logseq/config.edn"]]]

+ 186 - 118
src/main/frontend/components/page.cljs

@@ -16,6 +16,7 @@
             [frontend.db.model :as model]
             [frontend.extensions.graph :as graph]
             [frontend.extensions.pdf.assets :as pdf-assets]
+            [frontend.format.block :as block]
             [frontend.handler.common :as common-handler]
             [frontend.handler.config :as config-handler]
             [frontend.handler.editor :as editor-handler]
@@ -25,18 +26,17 @@
             [frontend.handler.plugin :as plugin-handler]
             [frontend.handler.route :as route-handler]
             [frontend.mixins :as mixins]
-            [frontend.state :as state]
+            [frontend.mobile.util :as mobile-util]
             [frontend.search :as search]
+            [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
             [frontend.util.text :as text-util]
             [goog.object :as gobj]
-            [reitit.frontend.easy :as rfe]
-            [medley.core :as medley]
-            [rum.core :as rum]
             [logseq.graph-parser.util :as gp-util]
-            [frontend.format.block :as block]
-            [frontend.mobile.util :as mobile-util]))
+            [medley.core :as medley]
+            [reitit.frontend.easy :as rfe]
+            [rum.core :as rum]))
 
 (defn- get-page-name
   [state]
@@ -59,7 +59,7 @@
   (let [[_ blocks _ sidebar? preview?] (:rum/args state)]
     (when (and
            (or preview?
-               (not (contains? #{:home :all-journals} (state/get-current-route))))
+               (not (contains? #{:home :all-journals :whiteboard} (state/get-current-route))))
            (not sidebar?))
       (let [block (first blocks)]
         (when (and (= (count blocks) 1)
@@ -70,7 +70,13 @@
 
 (rum/defc page-blocks-inner <
   {:did-mount  open-first-block!
-   :did-update open-first-block!}
+   :did-update open-first-block!
+   :should-update (fn [prev-state state]
+                    (let [[old-page-name _ old-hiccup _ old-block-uuid] (:rum/args prev-state)
+                          [page-name _ hiccup _ block-uuid] (:rum/args state)]
+                      (or (not= page-name old-page-name)
+                          (not= hiccup old-hiccup)
+                          (not= block-uuid old-block-uuid))))}
   [page-name _blocks hiccup sidebar? _block-uuid]
   [:div.page-blocks-inner {:style {:margin-left (if sidebar? 0 -20)}}
    (rum/with-key
@@ -184,97 +190,141 @@
               original-name]])]
          {:default-collapsed? false})]])))
 
-(rum/defcs page-title <
+(rum/defc page-title-editor < rum/reactive
+  [{:keys [*input-value *title-value *edit? untitled? page-name old-name title whiteboard-page?]}]
+  (let [input-ref (rum/create-ref)
+        collide? #(and (not= (util/page-name-sanity-lc page-name)
+                             (util/page-name-sanity-lc @*title-value))
+                       (db/page-exists? page-name)
+                       (db/page-exists? @*title-value))
+        confirm-fn (fn []
+                     (let [new-page-name (string/trim @*title-value)]
+                       (ui/make-confirm-modal
+                        {:title         (if (collide?)
+                                          (str "Page “" @*title-value "” already exists, merge to it?")
+                                          (str "Do you really want to change the page name to “" new-page-name "”?"))
+                         :on-confirm    (fn [_e {:keys [close-fn]}]
+                                          (close-fn)
+                                          (page-handler/rename! (or title page-name) @*title-value)
+                                          (reset! *edit? false))
+                         :on-cancel     (fn []
+                                          (reset! *title-value old-name)
+                                          (gobj/set (rum/deref input-ref) "value" old-name)
+                                          (reset! *edit? true)
+                                          (.focus (rum/deref input-ref)))})))
+        rollback-fn #(do
+                       (reset! *title-value old-name)
+                       (gobj/set (rum/deref input-ref) "value" old-name)
+                       (reset! *edit? false)
+                       (when-not untitled? (notification/show! "Illegal page name, can not rename!" :warning)))
+        blur-fn (fn [e]
+                  (when (gp-util/wrapped-by-quotes? @*title-value)
+                    (swap! *title-value gp-util/unquote-string)
+                    (gobj/set (rum/deref input-ref) "value" @*title-value))
+                  (cond
+                    (= old-name @*title-value)
+                    (reset! *edit? false)
+
+                    (string/blank? @*title-value)
+                    (rollback-fn)
+
+                    (and (collide?) whiteboard-page?)
+                    (notification/show! (str "Page “" @*title-value "” already exists!") :error)
+
+                    (and (date/valid-journal-title? @*title-value) whiteboard-page?)
+                    (notification/show! (str "Whiteboard page cannot be renamed with journal titles!") :error)
+
+                    untitled?
+                    (page-handler/rename! (or title page-name) @*title-value)
+
+                    :else
+                    (state/set-modal! (confirm-fn)))
+                  (util/stop e))]
+    [:span.absolute.inset-0
+     {:class (util/classnames [{:editing @*edit?}])}
+     [:input.edit-input
+      {:type          "text"
+       :ref           input-ref
+       :auto-focus    true
+       :style         {:outline "none"
+                       :width "100%"
+                       :font-weight "inherit"}
+       :auto-complete (if (util/chrome?) "chrome-off" "off") ; off not working here
+       :value         (rum/react *input-value)
+       :on-change     (fn [^js e]
+                        (let [value (util/evalue e)]
+                          (reset! *title-value (string/trim value))
+                          (reset! *input-value value)))
+       :on-blur       blur-fn
+       :on-key-down   (fn [^js e]
+                        (when (= (gobj/get e "key") "Enter")
+                          (blur-fn e)))
+       :placeholder   (when untitled? (t :untitled))
+       :on-key-up     (fn [^js e]
+                            ;; Esc
+                        (when (= 27 (.-keyCode e))
+                          (reset! *title-value old-name)
+                          (reset! *edit? false)))
+       :on-focus (fn []
+                   (when untitled? (reset! *title-value "")))}]]))
+
+(rum/defcs page-title < rum/reactive
   (rum/local false ::edit?)
+  (rum/local "" ::input-value)
   {:init (fn [state]
            (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)
           *edit? (get state ::edit?)
-          input-ref (rum/create-ref)
+          *input-value (get state ::input-value)
           repo (state/get-current-repo)
           hls-page? (pdf-assets/hls-page? title)
+          whiteboard-page? (model/whiteboard-page? page-name)
+          untitled? (and whiteboard-page? (parse-uuid page-name)) ;; normal page cannot be untitled right?
           title (if hls-page?
                   (pdf-assets/human-hls-pagename-display title)
                   (if fmt-journal? (date/journal-title->custom-format title) title))
-          old-name (or title page-name)
-          confirm-fn (fn []
-                       (let [new-page-name (string/trim @*title-value)
-                             merge? (and (not= (util/page-name-sanity-lc page-name)
-                                               (util/page-name-sanity-lc @*title-value))
-                                         (db/page-exists? page-name)
-                                         (db/page-exists? @*title-value))]
-                         (ui/make-confirm-modal
-                          {:title         (if merge?
-                                            (str "Page “" @*title-value "” already exists, merge to it?")
-                                            (str "Do you really want to change the page name to “" new-page-name "”?"))
-                           :on-confirm    (fn [_e {:keys [close-fn]}]
-                                            (close-fn)
-                                            (page-handler/rename! (or title page-name) @*title-value)
-                                            (reset! *edit? false))
-                           :on-cancel     (fn []
-                                            (reset! *title-value old-name)
-                                            (gobj/set (rum/deref input-ref) "value" old-name)
-                                            (reset! *edit? true))})))
-          rollback-fn #(do
-                         (reset! *title-value old-name)
-                         (gobj/set (rum/deref input-ref) "value" old-name)
-                         (reset! *edit? false)
-                         (notification/show! "Illegal page name, can not rename!" :warning))
-          blur-fn (fn [e]
-                    (when (gp-util/wrapped-by-quotes? @*title-value)
-                      (swap! *title-value gp-util/unquote-string)
-                      (gobj/set (rum/deref input-ref) "value" @*title-value))
-                    (cond
-                      (= old-name @*title-value)
-                      (reset! *edit? false)
-
-                      (string/blank? @*title-value)
-                      (rollback-fn)
-
-                      :else
-                      (state/set-modal! (confirm-fn)))
-                    (util/stop e))]
-      (if @*edit?
-        [:h1.title.ls-page-title
-         {:class (util/classnames [{:editing @*edit?}])}
-         [:input.edit-input
-          {:type          "text"
-           :ref           input-ref
-           :auto-focus    true
-           :style         {:outline "none"
-                           :font-weight 600}
-           :auto-complete (if (util/chrome?) "chrome-off" "off") ; off not working here
-           :default-value old-name
-           :on-change     (fn [^js e]
-                            (let [value (util/evalue e)]
-                              (reset! *title-value (string/trim value))))
-           :on-blur       blur-fn
-           :on-key-down   (fn [^js e]
-                            (when (= (gobj/get e "key") "Enter")
-                              (blur-fn e)))
-           :on-key-up     (fn [^js e]
-                            ;; Esc
-                            (when (= 27 (.-keyCode e))
-                              (reset! *title-value old-name)
-                              (reset! *edit? false)))}]]
-        [:a.page-title {:on-mouse-down (fn [e]
-                                         (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?))
-                                        (reset! *edit? true))))}
-         [:h1.title.ls-page-title {:data-ref page-name}
-          (when (not= icon "") [:span.page-icon icon])
-          title]]))))
+          old-name (or title page-name)]
+      [:h1.page-title.flex.cursor-pointer.gap-1
+       {:on-mouse-down (fn [e]
+                           (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?))
+                          (reset! *input-value (if untitled? "" old-name))
+                          (reset! *edit? true))))}
+       (when (not= icon "") [:span.page-icon icon])
+       [:div.page-title-sizer-wrapper.relative
+        (when (rum/react *edit?)
+          (page-title-editor {:*title-value *title-value
+                              :*edit? *edit?
+                              :*input-value *input-value
+                              :title title
+                              :page-name page-name
+                              :old-name old-name
+                              :untitled? untitled?
+                              :whiteboard-page? whiteboard-page?}))
+        [:span.title.block
+         {:data-value (rum/react *input-value)
+          :data-ref page-name
+          :style {:opacity (when @*edit? 0)
+                  :pointer-events "none"
+                  :font-weight "inherit"
+                  :white-space "nowrap"
+                  :overflow "hidden"
+                  :text-overflow "ellipsis"
+                  :min-width "80px"}}
+         (cond @*edit? [:span {:style {:white-space "pre"}} (rum/react *input-value)]
+               untitled? [:span.opacity-50 (t :untitled)]
+               :else title)]]])))
 
 (defn- page-mouse-over
   [e *control-show? *all-collapsed?]
@@ -326,6 +376,8 @@
           journal? (db/journal-page? page-name)
           fmt-journal? (boolean (date/journal-title->int page-name))
           sidebar? (:sidebar? option)
+          whiteboard? (:whiteboard? option)
+          whiteboard-page? (model/whiteboard-page? page-name)
           route-page-name path-page-name
           page (if block?
                  (->> (:db/id (:block/page (db/entity repo [:block/uuid block-id])))
@@ -355,30 +407,33 @@
               {:key path-page-name
                :class (util/classnames [{:is-journals (or journal? fmt-journal?)}])})
 
-       [:div.relative
-        (when (and (not sidebar?) (not block?))
-          [:div.flex.flex-row.space-between
-           (when (or (mobile-util/native-platform?) (util/mobile?))
-             [:div.flex.flex-row.pr-2
-              {:style {:margin-left -15}
-               :on-mouse-over (fn [e]
-                                (page-mouse-over e *control-show? *all-collapsed?))
-               :on-mouse-leave (fn [e]
-                                 (page-mouse-leave e *control-show?))}
-              (page-blocks-collapse-control title *control-show? *all-collapsed?)])
-           [:div.flex-1.flex-row
-            (page-title page-name icon title format fmt-journal?)]
-           (when (not config/publishing?)
-             [:div.flex.flex-row
-              (when plugin-handler/lsp-enabled?
-                (plugins/hook-ui-slot :page-head-actions-slotted nil)
-                (plugins/hook-ui-items :pagebar))])])
-        [:div
-         (when (and block? (not sidebar?))
-           (let [config {:id "block-parent"
-                         :block? true}]
-             [:div.mb-4
-              (component-block/breadcrumb config repo block-id {:level-limit 3})]))
+       (if whiteboard-page?
+         [:div ((state/get-component :whiteboard/tldraw-preview) page-name)] ;; FIXME: this is not reactive
+         [:div.relative
+          (when (and (not sidebar?) (not block?))
+            [:div.flex.flex-row.space-between
+             (when (or (mobile-util/native-platform?) (util/mobile?))
+               [:div.flex.flex-row.pr-2
+                {:style {:margin-left -15}
+                 :on-mouse-over (fn [e]
+                                  (page-mouse-over e *control-show? *all-collapsed?))
+                 :on-mouse-leave (fn [e]
+                                   (page-mouse-leave e *control-show?))}
+                (page-blocks-collapse-control title *control-show? *all-collapsed?)])
+             (when-not whiteboard?
+               [:div.flex-1.flex-row
+                [:h1.title.ls-page-title (page-title page-name icon title format fmt-journal?)]])
+             (when (not config/publishing?)
+               [:div.flex.flex-row
+                (when plugin-handler/lsp-enabled?
+                  (plugins/hook-ui-slot :page-head-actions-slotted nil)
+                  (plugins/hook-ui-items :pagebar))])])
+          [:div
+           (when (and block? (not sidebar?) (not whiteboard?))
+             (let [config {:id "block-parent"
+                           :block? true}]
+               [:div.mb-4
+                (component-block/breadcrumb config repo block-id {:level-limit 3})]))
 
          ;; blocks
          (let [page (if block?
@@ -387,7 +442,7 @@
                _ (and block? page (reset! *current-block-page (:block/name (:block/page page))))
                _ (when (and block? (not page))
                    (route-handler/redirect-to-page! @*current-block-page))]
-           (page-blocks-cp repo page {:sidebar? sidebar?}))]]
+           (page-blocks-cp repo page {:sidebar? sidebar?}))]])
 
        (when today?
          (today-queries repo today? sidebar?))
@@ -399,13 +454,13 @@
          (tagged-pages repo page-name))
 
        ;; referenced blocks
-       (when-not block?
+       (when-not (or block? whiteboard?)
          [:div {:key "page-references"}
           (rum/with-key
             (reference/references route-page-name)
             (str route-page-name "-refs"))])
 
-       (when-not block?
+       (when-not (or block? whiteboard?)
          [:div
           (when (not journal?)
             (hierarchy/structures route-page-name))
@@ -777,6 +832,7 @@
   (rum/local :block/updated-at ::sort-by-item)
   (rum/local true ::desc?)
   (rum/local false ::journals)
+  (rum/local false ::whiteboards)
   (rum/local nil ::filter-fn)
   (rum/local 1 ::current-page)
   [state]
@@ -785,6 +841,7 @@
         *sort-by-item (get state ::sort-by-item)
         *desc? (::desc? state)
         *journal? (::journals state)
+        *whiteboard? (::whiteboards state)
         *results (::results state)
         *results-all (::results-all state)
         *checks (::checks state)
@@ -837,16 +894,19 @@
                                                              :block/backlinks (count (:block/_refs (db/entity (:db/id page))))
                                                              :block/idx idx))))]
            (reset! *filter-fn
-                   (memoize (fn [sort-by-item desc? journal?]
+                   (memoize (fn [sort-by-item desc? journal? whiteboard?]
                               (->> pages
-                                   (filter #(or (boolean journal?)
-                                                (= false (boolean (:block/journal? %)))))
+                                   (filter #(and
+                                             (or (boolean journal?)
+                                                 (= false (boolean (:block/journal? %))))
+                                             (or (boolean whiteboard?)
+                                                 (not= "whiteboard" (:block/type %)))))
                                    (sort-pages-by sort-by-item desc?)))))
            (reset! *pages pages)))
 
        ;; filter results
        (when @*filter-fn
-         (let [pages (@*filter-fn @*sort-by-item @*desc? @*journal?)
+         (let [pages (@*filter-fn @*sort-by-item @*desc? @*journal? @*whiteboard?)
 
                ;; search key
                pages (if-not (string/blank? @*search-key)
@@ -913,6 +973,14 @@
                   (ui/icon "x")])])]]
 
           [:div.r.flex.items-center.justify-between
+           [:div
+            (ui/tippy
+             {:html  [:small (str (t :page/show-whiteboards) " ?")]
+              :arrow true}
+             [:a.button.whiteboard
+              {:class    (util/classnames [{:active (boolean @*whiteboard?)}])
+               :on-click #(reset! *whiteboard? (not @*whiteboard?))}
+              (ui/icon "whiteboard" {:extension? true :style {:fontSize ui/icon-size}})])]
            [:div
             (ui/tippy
              {:html  [:small (str (t :page/show-journals) " ?")]

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

@@ -115,7 +115,7 @@
       margin: 0;
     }
 
-    i.ti {
+    i:is(.ti, .tie) {
       font-size: 16px;
       display: inline-block;
       position: relative;
@@ -277,6 +277,7 @@ a.page-title {
   margin-left: -8px;
   transition: none;
   display: block;
+  color: inherit;
 }
 
 html.is-native-android,

+ 5 - 5
src/main/frontend/components/plugins.css

@@ -55,7 +55,7 @@
         margin-right: 5px;
         background: transparent;
 
-        .ti {
+        .ti, .tie {
           margin-right: 3px;
         }
 
@@ -67,7 +67,7 @@
     }
 
     .control-tabs {
-      .ti {
+      .ti, .tie {
         margin-right: 4px;
       }
       .ui__dropdown-trigger {
@@ -278,7 +278,7 @@
           position: relative;
           z-index: var(--ls-z-index-level-1);
 
-          .ti {
+          .ti, .tie {
             font-size: 16px;
           }
 
@@ -327,7 +327,7 @@
                 }
               }
 
-              .ti {
+              .ti, .tie {
                 font-size: 12px;
               }
             }
@@ -809,7 +809,7 @@
         font-size: 13px;
         position: relative;
 
-        div[data-injected-ui] .ti {
+        div[data-injected-ui] :is(.ti, .tie) {
           position: relative;
           bottom: -1px;
         }

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

@@ -228,7 +228,7 @@
                                              (check-multiple-windows? state)
                                              (toggle-fn))
                                  :title    repo-path}       ;; show full path on hover
-                                [:span.flex.pr-2.relative
+                                [:span.flex.relative
                                  {:style {:top 1}}
                                  (ui/icon "database" {:size 16 :id "database-icon"})]
                                 [:div.graphs

+ 3 - 1
src/main/frontend/components/right_sidebar.cljs

@@ -90,7 +90,9 @@
     (when-let [page-name (if (integer? db-id)
                            (:block/name (db/entity db-id))
                            db-id)]
-      [[:a.page-title {:href     (rfe/href :page {:name page-name})
+      [[:a.page-title {:href     (if (db-model/whiteboard-page? page-name)
+                                   (rfe/href :whiteboard {:name page-name})
+                                   (rfe/href :page {:name page-name}))
                        :on-click (fn [e]
                                    (when (gobj/get e "shiftKey")
                                      (.preventDefault e)))}

+ 77 - 25
src/main/frontend/components/search.cljs

@@ -12,6 +12,7 @@
             [frontend.db :as db]
             [frontend.db.model :as model]
             [frontend.handler.search :as search-handler]
+            [frontend.handler.whiteboard :as whiteboard-handler]
             [frontend.extensions.pdf.assets :as pdf-assets]
             [frontend.ui :as ui]
             [frontend.state :as state]
@@ -62,11 +63,10 @@
             [:p {:class "m-0"} elements]))))))
 
 (rum/defc search-result-item
-  [type content]
-  [:.text-sm.font-medium.flex.items-baseline
-   [:.text-xs.rounded.border.mr-2.px-1 {:title type}
-    (get type 0)]
-   content])
+  [icon content]
+  [:.search-result
+   (ui/type-icon icon)
+   [:.self-center content]])
 
 (rum/defc block-search-result-item
   [repo uuid format content q search-mode]
@@ -120,11 +120,18 @@
     (state/add-graph-search-filter! search-q)
 
     :new-page
-    (page-handler/create! search-q)
+    (page-handler/create! search-q {:redirect? true})
+
+    :new-whiteboard
+    (whiteboard-handler/create-new-whiteboard-and-redirect! search-q)
 
     :page
     (let [data (or alias data)]
-      (route/redirect-to-page! data))
+      (cond
+        (model/whiteboard-page? data)
+        (route/redirect-to-whiteboard! data)
+        :else
+        (route/redirect-to-page! data)))
 
     :file
     (route/redirect! {:to :file
@@ -134,10 +141,17 @@
     (let [block-uuid (uuid (:block/uuid data))
           collapsed? (db/parents-collapsed? repo block-uuid)
           page (:block/page (db/entity [:block/uuid block-uuid]))
+          page-name (:block/name page)
           long-page? (block-handler/long-page? repo (:db/id page))]
       (if page
-        (if (or collapsed? long-page?)
+        (cond
+          (model/whiteboard-page? page-name)
+          (route/redirect-to-whiteboard! page-name {:block-id block-uuid})
+
+          (or collapsed? long-page?)
           (route/redirect-to-page! block-uuid)
+
+          :else
           (route/redirect-to-page! (:block/name page) {:anchor (str "ls-block-" (:block/uuid data))}))
         ;; search indice outdated
         (println "[Error] Block page missing: "
@@ -177,6 +191,15 @@
     nil)
   (state/close-modal!))
 
+(defn- create-item-render
+  [icon label name]
+  (search-result-item
+   {:name icon
+    :class "highlight"
+    :extension? true}
+   [:div.text.font-bold (str label ": ")
+    [:span.ml-1 name]]))
+
 (defn- search-item-render
   [search-q {:keys [type data alias]}]
   (let [search-mode (state/get-search-mode)
@@ -187,18 +210,25 @@
        [:b search-q]
 
        :new-page
-       [:div.text.font-bold (str (t :new-page) ": ")
-        [:span.ml-1 (str "\"" (string/trim search-q) "\"")]]
+       (create-item-render "new-page" (t :new-page) (str "\"" (string/trim search-q) "\""))
+
+       :new-whiteboard
+       (create-item-render "new-whiteboard" (t :new-whiteboard) (str "\"" (string/trim search-q) "\""))
 
        :page
        [:span {:data-page-ref data}
         (when alias
           (let [target-original-name (model/get-page-original-name alias)]
             [:span.mr-2.text-sm.font-medium.mb-2 (str "Alias -> " target-original-name)]))
-        (search-result-item "Page" (highlight-exact-query data search-q))]
+        (search-result-item {:name (if (model/whiteboard-page? data) "whiteboard" "page")
+                             :extension? true
+                             :title (t (if (model/whiteboard-page? data) :search-item/whiteboard :search-item/page))}
+                            (highlight-exact-query data search-q))]
 
        :file
-       (search-result-item "File" (highlight-exact-query data search-q))
+       (search-result-item {:name "file"
+                            :title (t :search-item/file)}
+                           (highlight-exact-query data search-q))
 
        :block
        (let [{:block/keys [page uuid]} data  ;; content here is normalized
@@ -208,10 +238,13 @@
              block (model/query-block-by-uuid uuid)
              content (:block/content block)]
          [:span {:data-block-ref uuid}
-          (search-result-item "Block"  (if block
-                                         (block-search-result-item repo uuid format content search-q search-mode)
-                                         (do (log/error "search result with non-existing uuid: " data)
-                                             (str "Cache is outdated. Please click the 'Re-index' button in the graph's dropdown menu."))))])
+          (search-result-item {:name "block"
+                               :title (t :search-item/block)
+                               :extension? true}
+                              (if block
+                                (block-search-result-item repo uuid format content search-q search-mode)
+                                (do (log/error "search result with non-existing uuid: " data)
+                                    (str "Cache is outdated. Please click the 'Re-index' button in the graph's dropdown menu."))))])
 
        nil)]))
 
@@ -220,13 +253,13 @@
   (let [pages (when-not all? (map (fn [page]
                                     (let [alias (model/get-redirect-page-name page)]
                                       (cond->
-                                        {:type :page
-                                         :data page}
+                                       {:type :page
+                                        :data page}
                                         (and alias
                                              (not= (util/page-name-sanity-lc page)
                                                    (util/page-name-sanity-lc alias)))
                                         (assoc :alias alias))))
-                               (remove nil? pages)))
+                                  (remove nil? pages)))
         files (when-not all? (map (fn [file] {:type :file :data file}) files))
         blocks (map (fn [block] {:type :block :data block}) blocks)
         search-mode (state/sub :search/mode)
@@ -237,9 +270,17 @@
                       (nil? result)
                       all?)
                    []
-                   [{:type :new-page}])
-        result (if config/publishing?
+                   (if (state/enable-whiteboards?)
+                     [{:type :new-page} {:type :new-whiteboard}]
+                     [{:type :new-page}]))
+        result (cond
+                 config/publishing?
                  (concat pages files blocks)
+
+                 (= :whiteboard/link search-mode)
+                 (concat pages blocks)
+
+                 :else
                  (concat new-page pages files blocks))
         result (if (= search-mode :graph)
                  [{:type :graph-add-filter}]
@@ -334,11 +375,22 @@
                                  svg/search
                                  [:span.ml-2 data]]
                         :page (when-let [original-name (model/get-page-original-name data)] ;; might be block reference
-                                (search-result-item "Page" original-name))
+                                (search-result-item {:name "page"
+                                                     :extension? true}
+                                                    original-name))
                         nil))}))])
 
-(def default-placeholder
-  (if config/publishing? (t :search/publishing) (t :search)))
+(defn default-placeholder
+  [search-mode]
+  (cond
+    config/publishing?
+    (t :search/publishing)
+
+    (= search-mode :whiteboard/link)
+    (t :whiteboard/link-whiteboard-or-block)
+
+    :else
+    (t :search)))
 
 (rum/defcs search-modal < rum/reactive
   (shortcut/disable-all-shortcuts)
@@ -365,7 +417,7 @@
                          (t :graph-search)
                          :page
                          (t :page-search)
-                         default-placeholder)
+                         (default-placeholder search-mode))
         :auto-complete (if (util/chrome?) "chrome-off" "off") ; off not working here
         :value         search-q
         :on-change     (fn [e]

+ 38 - 5
src/main/frontend/components/settings.cljs

@@ -641,16 +641,31 @@
 
 (defn sync-switcher-row [enabled?]
   (row-with-button-action
-   {:left-label (str (t :settings-page/sync) " 🔐")
+   {:left-label (t :settings-page/sync)
     :action (sync-enabled-switcher enabled?)}))
 
+(rum/defc whiteboards-enabled-switcher
+  [enabled?]
+  (ui/toggle enabled?
+             (fn []
+               (let [value (not enabled?)]
+                 (config-handler/set-config! :feature/enable-whiteboards? value)))
+             true))
+
+(defn whiteboards-switcher-row [enabled?]
+  (row-with-button-action
+   {:left-label (t :settings-page/enable-whiteboards)
+    :action (whiteboards-enabled-switcher enabled?)}))
+
 (rum/defc settings-features < rum/reactive
   []
   (let [current-repo (state/get-current-repo)
         enable-journals? (state/enable-journals? current-repo)
         enable-encryption? (state/enable-encryption? current-repo)
         enable-flashcards? (state/enable-flashcards? current-repo)
-        enable-sync? (state/enable-sync?)]
+        enable-sync? (state/enable-sync?)
+        enable-whiteboards? (state/enable-whiteboards? current-repo)
+        logged-in? (user-handler/logged-in?)]
     [:div.panel-wrap.is-features.mb-8
      (journal-row enable-journals?)
      (when (not enable-journals?)
@@ -672,10 +687,28 @@
      (encryption-row enable-encryption?)
 
      (when-not web-platform?
-       [:div
+       [:<>
         [:hr]
-        [:h2.mb-4 "Alpha test (sponsors only)"]
-        (sync-switcher-row enable-sync?)])]))
+        [:div.it.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start
+         [:label.flex.font-medium.leading-5.self-start.mt-1 (ui/icon  (if logged-in? "lock-open" "lock") {:class "mr-1"}) (t :settings-page/alpha-features)]
+         [:div.mt-1.sm:mt-0.sm:col-span-2
+          (if logged-in?
+            [:div 
+              (user-handler/email)
+              [:p (ui/button (t :logout) {:class "p-1"
+                                          :icon "logout"
+                                          :on-click user-handler/logout})]]
+            [:div
+             (ui/button (t :login) {:class "p-1"
+                                    :icon "login"
+                                    :on-click (fn []
+                                                (state/close-settings!)
+                                                (js/window.open config/LOGIN-URL))})
+             [:p.text-sm.opacity-50 (t :settings-page/login-prompt)]])]]
+        [:div.flex.flex-col.gap-4
+         {:class (when-not user-handler/alpha-user? "opacity-50 pointer-events-none cursor-not-allowed")}
+         (sync-switcher-row enable-sync?)
+         (whiteboards-switcher-row enable-whiteboards?)]])]))
 
 (rum/defcs settings
   < (rum/local [:general :general] ::active)

+ 1 - 1
src/main/frontend/components/settings.css

@@ -116,7 +116,7 @@
 
         &.app-updater {
           padding-top: 15px;
-          align-items: start;
+          align-items: flex-start;
 
 
           > .wrap {

+ 99 - 46
src/main/frontend/components/sidebar.cljs

@@ -26,6 +26,7 @@
             [frontend.handler.page :as page-handler]
             [frontend.handler.route :as route-handler]
             [frontend.handler.user :as user-handler]
+            [frontend.handler.whiteboard :as whiteboard-handler]
             [frontend.mixins :as mixins]
             [frontend.mobile.action-bar :as action-bar]
             [frontend.mobile.footer :as footer]
@@ -69,25 +70,28 @@
 
 (rum/defc page-name
   [name icon recent?]
-  (let [original-name (db-model/get-page-original-name name)]
+  (let [original-name (db-model/get-page-original-name name)
+        whiteboard-page? (db-model/whiteboard-page? name)]
     [:a.flex.items-center
      {:on-click
       (fn [e]
         (let [name        (util/safe-page-name-sanity-lc name)
               source-page (db-model/get-alias-source-page (state/get-current-repo) name)
               name        (if (empty? source-page) name (:block/name source-page))]
-          (if (gobj/get e "shiftKey")
+          (if (and (gobj/get e "shiftKey") (not whiteboard-page?))
             (when-let [page-entity (if (empty? source-page) (db/entity [:block/name name]) source-page)]
               (state/sidebar-add-block!
                (state/get-current-repo)
                (:db/id page-entity)
                :page))
-            (route-handler/redirect-to-page! name {:click-from-recent? recent?}))))}
-     [:span.page-icon icon]
+            (if whiteboard-page?
+              (route-handler/redirect-to-whiteboard! name)
+              (route-handler/redirect-to-page! name {:click-from-recent? recent?})))))}
+     [:span.page-icon (if whiteboard-page? (ui/icon "whiteboard" {:extension? true}) icon)]
      [:span.page-title (pdf-assets/fix-local-asset-pagename original-name)]]))
 
 (defn get-page-icon [page-entity]
-  (let [default-icon (ui/icon "file-text")
+  (let [default-icon (ui/icon "page" {:extension? true})
         from-properties (get-in (into {} page-entity) [:block/properties :icon])]
     (or
      (when (not= from-properties "") from-properties)
@@ -206,6 +210,7 @@
     class :class
     title :title
     icon :icon
+    icon-extension? :icon-extension?
     active :active
     href :href}]
   [:div
@@ -214,13 +219,47 @@
     {:on-click on-click-handler
      :class (when active "active")
      :href href}
-    (ui/icon (str icon))
+    (ui/icon (str icon) {:extension? icon-extension?})
     [:span.flex-1 title]]])
 
-(rum/defc sidebar-nav
+(defn close-sidebar-on-mobile!
+  []
+  (and (util/sm-breakpoint?)
+    (state/toggle-left-sidebar!)))
+
+(defn create-dropdown
+  []
+  (ui/dropdown-with-links
+   (fn [{:keys [toggle-fn]}]
+     [:button#create-button
+      {:on-click toggle-fn}
+      [:<>
+       (ui/icon "plus" {:font? "true"})
+       [:span.mx-1 (t :left-side-bar/create)]]])
+   (->>
+    [{:title (t :left-side-bar/new-page)
+      :class "new-page-link"
+      :shortcut (ui/keyboard-shortcut-from-config :go/search)
+      :options {:on-click #(do (close-sidebar-on-mobile!)
+                               (state/pub-event! [:go/search]))}
+      :icon (ui/type-icon {:name "new-page"
+                           :class "highlight"
+                           :extension? true})}
+     {:title (t :left-side-bar/new-whiteboard)
+      :class "new-whiteboard-link"
+      :shortcut (ui/keyboard-shortcut-from-config :editor/new-whiteboard)
+      :options {:on-click #(do (close-sidebar-on-mobile!)
+                               (whiteboard-handler/create-new-whiteboard-and-redirect!))}
+      :icon (ui/type-icon {:name "new-whiteboard"
+                           :class "highlight"
+                           :extension? true})}])
+   {}))
+
+(rum/defc sidebar-nav < rum/reactive
   [route-match close-modal-fn left-sidebar-open? srs-open?]
   (let [default-home (get-default-home-if-valid)
         route-name (get-in route-match [:data :name])
+        enable-whiteboards? (state/enable-whiteboards?)
         on-contents-scroll #(when-let [^js el (.-target %)]
                               (let [top (.-scrollTop el)
                                     cls (.-classList el)
@@ -249,24 +288,24 @@
        [:div.nav-header.flex.gap-1.flex-col
         (let [page (:page default-home)]
           (if (and page (not (state/enable-journals? (state/get-current-repo))))
-          (sidebar-item
-           {:class            "home-nav"
-            :title            page
-            :on-click-handler route-handler/redirect-to-home!
-            :active           (and (not srs-open?)
-                                   (= route-name :page)
-                                   (= page (get-in route-match [:path-params :name])))
-            :icon             "home"})
-          (sidebar-item
-           {:class            "journals-nav"
-            :active           (and (not srs-open?)
-                                   (or (= route-name :all-journals) (= route-name :home)))
-            :title            (t :left-side-bar/journals)
-            :on-click-handler (fn [e]
-                                (if (gobj/get e "shiftKey")
-                                  (route-handler/sidebar-journals!)
-                                  (route-handler/go-to-journals!)))
-            :icon             "calendar"})))
+            (sidebar-item
+             {:class            "home-nav"
+              :title            page
+              :on-click-handler route-handler/redirect-to-home!
+              :active           (and (not srs-open?)
+                                     (= route-name :page)
+                                     (= page (get-in route-match [:path-params :name])))
+              :icon             "home"})
+            (sidebar-item
+             {:class            "journals-nav"
+              :active           (and (not srs-open?)
+                                     (or (= route-name :all-journals) (= route-name :home)))
+              :title            (t :left-side-bar/journals)
+              :on-click-handler (fn [e]
+                                  (if (gobj/get e "shiftKey")
+                                    (route-handler/sidebar-journals!)
+                                    (route-handler/go-to-journals!)))
+              :icon             "calendar"})))
 
         (when (state/enable-flashcards? (state/get-current-repo))
           [:div.flashcards-nav
@@ -284,7 +323,16 @@
           :title  (t :right-side-bar/all-pages)
           :href   (rfe/href :all-pages)
           :active (and (not srs-open?) (= route-name :all-pages))
-          :icon   "files"})]]
+          :icon   "files"})
+
+        (when enable-whiteboards?
+          (sidebar-item
+           {:class "whiteboard"
+            :title (t :right-side-bar/whiteboards)
+            :href  (rfe/href :whiteboards)
+            :active (and (not srs-open?) (#{:whiteboard :whiteboards} route-name))
+            :icon  "whiteboard"
+            :icon-extension? true}))]]
 
       [:div.nav-contents-container.flex.flex-col.gap-1.pt-1
        {:on-scroll on-contents-scroll}
@@ -294,15 +342,17 @@
        (when (and left-sidebar-open? (not config/publishing?))
          (recent-pages t))]
 
-      [:footer.px-2 {:class "new-page"}
+      [:footer.px-2 {:class "create"}
        (when-not config/publishing?
-         [:a.item.group.flex.items-center.px-2.py-2.text-sm.font-medium.rounded-md.new-page-link
-          {:on-click (fn []
-                       (and (util/sm-breakpoint?)
-                            (state/toggle-left-sidebar!))
-                       (state/pub-event! [:go/search]))}
-          (ui/icon "circle-plus" {:style {:font-size 20}})
-          [:span.flex-1 (t :right-side-bar/new-page)]])]]]))
+         (if enable-whiteboards?
+           (create-dropdown)
+           [:a.item.group.flex.items-center.px-2.py-2.text-sm.font-medium.rounded-md.new-page-link
+            {:on-click (fn []
+                         (and (util/sm-breakpoint?)
+                              (state/toggle-left-sidebar!))
+                         (state/pub-event! [:go/search]))}
+            (ui/icon "circle-plus" {:style {:font-size 20}})
+            [:span.flex-1 (t :right-side-bar/new-page)]]))]]]))
 
 (rum/defc left-sidebar < rum/reactive
   [{:keys [left-sidebar-open? route-match]}]
@@ -343,7 +393,7 @@
                                 (editor-handler/upload-asset id files format editor-handler/*asset-uploading? true))))})
                   (common-handler/listen-to-scroll! element))
                 state)}
-  [{:keys [route-match global-graph-pages? route-name indexeddb-support? db-restoring? main-content show-action-bar? show-recording-bar?]}]
+  [{:keys [route-match margin-less-pages? route-name indexeddb-support? db-restoring? main-content show-action-bar? show-recording-bar?]}]
   (let [left-sidebar-open? (state/sub :ui/left-sidebar-open?)
         onboarding-and-home? (and (or (nil? (state/get-current-repo)) (config/demo-graph?))
                                   (not config/publishing?)
@@ -357,7 +407,8 @@
 
      [:div#main-content-container.scrollbar-spacing.w-full.flex.justify-center.flex-row.outline-none
 
-      {:tabIndex "-1"}
+      {:tabIndex "-1"
+       :data-is-margin-less-pages margin-less-pages?}
 
       (when (util/electron?)
         (find-in-page/search))
@@ -366,9 +417,9 @@
         (action-bar/action-bar))
 
       [:div.cp__sidebar-main-content
-       {:data-is-global-graph-pages global-graph-pages?
-        :data-is-full-width         (or global-graph-pages?
-                                        (contains? #{:all-files :all-pages :my-publishing} route-name))}
+       {:data-is-margin-less-pages margin-less-pages?
+        :data-is-full-width        (or margin-less-pages?
+                                       (contains? #{:all-files :all-pages :my-publishing} route-name))}
 
        (when show-recording-bar?
          (recording-bar))
@@ -390,11 +441,13 @@
            (ui/loading (t :loading))]]
          
          :else
-         [:div.mx-auto.pb-24 {:style {:margin-bottom (cond
-                                                       global-graph-pages? 0
-                                                       onboarding-and-home? -48
-                                                       :else 120)
-                                      :padding-bottom (when (mobile-util/native-iphone?) "7rem")}}
+         [:div
+          {:class (if margin-less-pages? "" (util/hiccup->class "mx-auto.pb-24"))
+           :style {:margin-bottom (cond
+                                    margin-less-pages? 0
+                                    onboarding-and-home? -48
+                                    :else 120)
+                   :padding-bottom (when (mobile-util/native-iphone?) "7rem")}}
           main-content])
 
        (when onboarding-and-home?
@@ -573,7 +626,7 @@
         onboarding-state (state/sub :file-sync/onboarding-state)
         right-sidebar-blocks (state/sub-right-sidebar-blocks)
         route-name (get-in route-match [:data :name])
-        global-graph-pages? (= :graph route-name)
+        margin-less-pages? (boolean (#{:graph :whiteboard} route-name))
         db-restoring? (state/sub :db/restoring?)
         indexeddb-support? (state/sub :indexeddb/support?)
         page? (= :page route-name)
@@ -624,7 +677,7 @@
                         :new-block-mode new-block-mode})
 
         (main {:route-match         route-match
-               :global-graph-pages? global-graph-pages?
+               :margin-less-pages?  margin-less-pages?
                :logged?             logged?
                :home?               home?
                :route-name          route-name

+ 57 - 10
src/main/frontend/components/sidebar.css

@@ -64,11 +64,16 @@
 #main-content {
   position: relative;
   height: calc(100vh - var(--ls-headbar-height));
+}
 
-  &-container {
-    @apply p-4 sm:px-8;
-    font-size: 1em;
-  }
+#main-content-container {
+  @apply p-4 sm:px-8;
+  font-size: 1em;
+}
+
+#main-content-container[data-is-margin-less-pages=true] {
+  padding: 0;
+  position: relative;
 }
 
 .left-sidebar-inner {
@@ -114,7 +119,8 @@
   .page-icon {
     @apply flex items-center mr-1 align-baseline;
 
-    width: 16px;
+    width: 20px;
+    flex-shrink: 0;
     height: 18px;
     text-align: center;
     display: inline-block;
@@ -128,7 +134,9 @@
     user-select: none;
     transition: background-color .3s;
 
-    > .ti {
+    .ui__icon {
+      width: 20px;
+      text-align: center;
       font-size: 16px;
       margin-right: 8px;
       opacity: .9;
@@ -268,17 +276,56 @@
     }
   }
 
-  .new-page {
+  .create {
     position: absolute;
     bottom: 0;
     left: 0;
     width: 100%;
     padding: 14px;
+    background-image: linear-gradient(transparent, var(--ls-primary-background-color));
+
+    @screen sm {
+      background-image: linear-gradient(transparent, var(--ls-secondary-background-color));
+    }
 
     &-link {
       background-color: var(--ls-primary-background-color);
       box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
     }
+
+    .dropdown-wrapper {
+      top: initial;
+      right: initial;
+      bottom: calc(100% + 6px);
+      left: 0;
+      width: max-content;
+
+      @screen sm {
+        bottom: 0;
+        left: calc(100% + 6px);
+      }
+    }
+
+    #create-button {
+      @apply flex items-center justify-center p-2 text-sm font-medium rounded-md w-full border;
+      background-color: var(--ls-secondary-background-color) !important;
+      border-color: transparent;
+
+      &:hover,
+      &:focus {
+        border-color: var(--ls-border-color);
+        background-color: var(--ls-primary-background-color) !important;
+      }
+
+      @screen sm {
+        background-color: var(--ls-primary-background-color) !important;
+
+        &:hover,
+        &:focus {
+          background-color: var(--ls-secondary-background-color) !important;
+        }
+      }
+    }
   }
 
   @screen sm {
@@ -290,7 +337,7 @@
       margin-top: 52px;
     }
 
-    .new-page {
+    .create {
       &-link {
         background-color: var(--ls-primary-background-color);
       }
@@ -548,7 +595,7 @@ html[data-theme='dark'] {
   max-width: 100vw;
 }
 
-.cp__sidebar-main-content[data-is-global-graph-pages='true'] {
+.cp__sidebar-main-content[data-is-margin-less-pages='true'] {
   padding: 0;
 }
 
@@ -577,4 +624,4 @@ html[data-theme='dark'] {
 
 .full-height-without-header {
   height: calc(100vh - var(--ls-headbar-height) - 4rem);
-}
+}

+ 259 - 0
src/main/frontend/components/whiteboard.cljs

@@ -0,0 +1,259 @@
+(ns frontend.components.whiteboard
+  "Whiteboard related components"
+  (:require [cljs.math :as math]
+            [frontend.components.page :as page]
+            [frontend.components.reference :as reference]
+            [frontend.context.i18n :refer [t]]
+            [frontend.db.model :as model]
+            [frontend.handler.route :as route-handler]
+            [frontend.handler.user :as user-handler]
+            [frontend.handler.whiteboard :as whiteboard-handler]
+            [frontend.rum :refer [use-bounding-client-rect use-click-outside]]
+            [frontend.state :as state]
+            [frontend.ui :as ui]
+            [frontend.util :as util]
+            [promesa.core :as p]
+            [rum.core :as rum]
+            [shadow.loader :as loader]))
+
+(defonce tldraw-loaded? (atom false))
+(rum/defc tldraw-app < rum/reactive
+  {:init (fn [state]
+           (p/let [_ (loader/load :tldraw)]
+             (reset! tldraw-loaded? true))
+           state)}
+  [name shape-id]
+  (let [loaded? (rum/react tldraw-loaded?)
+        draw-component (when loaded?
+                         (resolve 'frontend.extensions.tldraw/tldraw-app))]
+    (when draw-component
+      (draw-component name shape-id))))
+
+;; TODO: make it reactive to db changes
+(rum/defc tldraw-preview < rum/reactive
+  {:init (fn [state]
+           (p/let [_ (loader/load :tldraw)]
+             (reset! tldraw-loaded? true))
+           state)}
+  [page-name]
+  (let [loaded? (rum/react tldraw-loaded?)
+        tldr (whiteboard-handler/page-name->tldr! page-name)
+        generate-preview (when loaded?
+                           (resolve 'frontend.extensions.tldraw/generate-preview))]
+    (when generate-preview
+      (generate-preview tldr))))
+
+(rum/defc dropdown
+  [label children show? outside-click-hander]
+  (let [[anchor-ref anchor-rect] (use-bounding-client-rect show?)
+        [content-ref content-rect] (use-bounding-client-rect show?)
+        offset-x (when (and anchor-rect content-rect)
+                   (let [offset-x (+ (* 0.5 (- (.-width anchor-rect) (.-width content-rect)))
+                                     (.-x anchor-rect))
+                         vp-w (.-innerWidth js/window)
+                         right (+ offset-x (.-width content-rect) 16)
+                         offset-x (if (> right vp-w) (- offset-x (- right vp-w)) offset-x)]
+                     offset-x))
+        offset-y (when (and anchor-rect content-rect)
+                   (+ (.-y anchor-rect) (.-height anchor-rect) 8))
+        click-outside-ref (use-click-outside outside-click-hander)
+        [d-open set-d-open] (rum/use-state false)
+        _ (rum/use-effect! (fn [] (js/setTimeout #(set-d-open show?) 100))
+                           [show?])]
+    [:div.dropdown-anchor {:ref anchor-ref}
+     label
+     (ui/portal
+      [:div.fixed.shadow-lg.color-level.px-2.rounded-lg.transition.md:w-64.lg:w-128.overflow-auto
+       {:ref (juxt content-ref click-outside-ref)
+        :style {:opacity (if d-open 1 0)
+                :pointer-events (if d-open "auto" "none")
+                :transform (str "translateY(" (if d-open 0 10) "px)")
+                :min-height "40px"
+                :max-height "420px"
+                :left offset-x
+                :top offset-y}} children])]))
+
+(rum/defc page-refs-count < rum/static
+  ([page-name classname]
+   (page-refs-count page-name classname nil))
+  ([page-name classname render-fn]
+   (let [page-entity (model/get-page page-name)
+         block-uuid (:block/uuid page-entity)
+         ref (rum/use-ref nil)
+         refs-count (count (:block/_refs page-entity))
+         [open-flag set-open-flag] (rum/use-state 0)
+         open? (not= open-flag 0)
+         d-open-flag (rum/use-memo #(util/debounce 200 set-open-flag) [])]
+     ;; TODO: move click outside to the utility? 
+     (rum/use-effect!
+      (let [listener (fn [e]
+                       (when (and (.-current ref)
+                                  (not (.contains (.-current ref) (.-target e))))
+                         (d-open-flag 0)))]
+        (.addEventListener js/document.body "mousedown" listener true)
+        #(.removeEventListener js/document.body "mousedown" listener))
+      [ref])
+     (when (> refs-count 0)
+       (dropdown
+        [:div.flex.items-center.gap-2.whiteboard-page-refs-count
+         {:class (str classname (when open? " open"))
+          :on-mouse-enter (fn [] (d-open-flag #(if (= % 0) 1 %)))
+          :on-mouse-leave (fn [] (d-open-flag #(if (= % 2) % 0)))
+          :on-click (fn [e]
+                      (util/stop e)
+                      (d-open-flag (fn [o] (if (not= o 2) 2 0))))}
+         [:div.open-page-ref-link refs-count]
+         (when render-fn (render-fn open?))]
+        (reference/block-linked-references block-uuid)
+        open?
+        #(set-open-flag 0))))))
+
+(defn- get-page-display-name
+  [page-name]
+  (let [page-entity (model/get-page page-name)]
+    (or
+     (get-in page-entity [:block/properties :title] nil)
+     (:block/original-name page-entity)
+     page-name)))
+
+;; This is not accurate yet
+(defn- get-page-human-update-time
+  [page-name]
+  (let [page-entity (model/get-page page-name)
+        {:block/keys [updated-at created-at]} page-entity]
+    (str (if (= created-at updated-at) "Created " "Edited ")
+         (util/time-ago (js/Date. updated-at)))))
+
+(rum/defc dashboard-preview-card
+  [page-name {:keys [checked on-checked-change show-checked?]}]
+  [:div.dashboard-card.dashboard-preview-card.cursor-pointer.hover:shadow-lg
+   {:data-checked checked
+    :style {:filter (if (and show-checked? (not checked)) "opacity(0.5)" "none")}
+    :on-click
+    (fn [e]
+      (util/stop e)
+      (if show-checked?
+        (on-checked-change (not checked))
+        (route-handler/redirect-to-whiteboard! page-name)))}
+   [:div.dashboard-card-title
+    [:div.flex.w-full.items-center
+     [:div.dashboard-card-title-name.font-bold
+      (if (parse-uuid page-name)
+        [:span.opacity-50 (t :untitled)]
+        (get-page-display-name page-name))]
+     [:div.flex-1]
+     [:div.dashboard-card-checkbox
+      {:tab-index -1
+       :style {:visibility (when show-checked? "visible")}
+       :on-click util/stop-propagation}
+      (ui/checkbox {:checked checked
+                    :on-change (fn [] (on-checked-change (not checked)))})]]
+    [:div.flex.w-full.opacity-50
+     [:div (get-page-human-update-time page-name)]
+     [:div.flex-1]
+     (page-refs-count page-name nil)]]
+   [:div.p-4.h-64.flex.justify-center
+    (tldraw-preview page-name)]])
+
+(rum/defc dashboard-create-card
+  []
+  [:div.dashboard-card.dashboard-create-card.cursor-pointer#tl-create-whiteboard
+   {:on-click
+    (fn [e]
+      (util/stop e)
+      (whiteboard-handler/create-new-whiteboard-and-redirect!))}
+   (ui/icon "plus")
+   [:span.dashboard-create-card-caption.select-none
+    "New whiteboard"]])
+
+(rum/defc whiteboard-dashboard
+  []
+  (if (state/enable-whiteboards?)
+    (let [whiteboards (->> (model/get-all-whiteboards (state/get-current-repo))
+                           (sort-by :block/updated-at)
+                           reverse)
+          whiteboard-names (map :block/name whiteboards)
+          [ref rect] (use-bounding-client-rect)
+          [container-width] (when rect [(.-width rect) (.-height rect)])
+          cols (cond (< container-width 600) 1
+                     (< container-width 900) 2
+                     (< container-width 1200) 3
+                     :else 4)
+          total-whiteboards (count whiteboards)
+          empty-cards (- (max (* (math/ceil (/ (inc total-whiteboards) cols)) cols) (* 2 cols))
+                         (inc total-whiteboards))
+          [checked-page-names set-checked-page-names] (rum/use-state #{})
+          has-checked? (not-empty checked-page-names)]
+      [:<>
+       [:h1.select-none.flex.items-center.whiteboard-dashboard-title.title
+        [:div "All whiteboards"
+         [:span.opacity-50
+          (str " · " total-whiteboards)]]
+        [:div.flex-1]
+        (when has-checked?
+          [:button.ui__button.m-0.py-1.inline-flex.items-center.bg-red-800
+           {:on-click
+            (fn []
+              (state/set-modal! (page/batch-delete-dialog
+                                 (map (fn [name]
+                                        (some (fn [w] (when (= (:block/name w) name) w)) whiteboards))
+                                      checked-page-names)
+                                 false route-handler/redirect-to-whiteboard-dashboard!)))}
+           [:span.flex.gap-2.items-center
+            [:span.opacity-50 (ui/icon "trash" {:style {:font-size 15}})]
+            (t :delete)
+            [:span.opacity-50
+             (str " · " (count checked-page-names))]]])]
+       [:div
+        {:ref ref}
+        [:div.gap-8.grid.grid-rows-auto
+         {:style {:visibility (when (nil? container-width) "hidden")
+                  :grid-template-columns (str "repeat(" cols ", minmax(0, 1fr))")}}
+         (dashboard-create-card)
+         (for [whiteboard-name whiteboard-names]
+           [:<> {:key whiteboard-name}
+            (dashboard-preview-card whiteboard-name
+                                    {:show-checked? has-checked?
+                                     :checked (boolean (checked-page-names whiteboard-name))
+                                     :on-checked-change (fn [checked]
+                                                          (set-checked-page-names (if checked
+                                                                               (conj checked-page-names whiteboard-name)
+                                                                               (disj checked-page-names whiteboard-name))))})])
+         (for [n (range empty-cards)]
+           [:div.dashboard-card.dashboard-bg-card {:key n}])]]])
+    [:div "This feature is not publicly available yet."]))
+
+(rum/defc whiteboard-page
+  [name block-id]
+  [:div.absolute.w-full.h-full.whiteboard-page
+
+   ;; makes sure the whiteboard will not cover the borders
+   {:key name
+    :style {:padding "0.5px" :z-index 0
+            :transform "translateZ(0)"
+            :text-rendering "geometricPrecision"
+            :-webkit-font-smoothing "subpixel-antialiased"}}
+
+   [:div.whiteboard-page-title-root
+    [:span.whiteboard-page-title
+     {:style {:color "var(--ls-primary-text-color)"
+              :user-select "none"}}
+     (page/page-title name
+                      [:span.tie.tie-whiteboard
+                       {:style {:font-size "0.9em"}}]
+                      (get-page-display-name name)
+                      nil
+                      false)]
+
+    (page-refs-count name
+                     "text-md px-3 py-2 cursor-default whiteboard-page-refs-count"
+                     (fn [open?] [:<> "References" (ui/icon (if open? "references-hide" "references-show")
+                                                            {:extension? true})]))]
+   (tldraw-app name block-id)])
+
+(rum/defc whiteboard-route
+  [route-match]
+  (when (user-handler/alpha-user?)
+    (let [name (get-in route-match [:parameters :path :name])
+          {:keys [block-id]} (get-in route-match [:parameters :query])]
+      (whiteboard-page name block-id))))

+ 157 - 0
src/main/frontend/components/whiteboard.css

@@ -0,0 +1,157 @@
+.whiteboard-dashboard-bg-grid {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: -1;
+}
+
+h1.title.whiteboard-dashboard-title {
+  padding-right: 0;
+}
+
+.dashboard-bg-card {
+  background-color: var(--ls-secondary-background-color);
+  border: 1px solid var(--ls-border-color);
+  opacity: 0.3;
+}
+
+.dashboard-card {
+  @apply rounded-lg flex flex-col gap-1 overflow-hidden font-medium;
+  height: 300px;
+
+  .dashboard-card-checkbox {
+    @apply flex items-center justify-center rounded flex-shrink-0;
+    border: 2px solid transparent;
+    visibility: hidden;
+    width: 24px;
+    height: 24px;
+    transform: translateX(4px);
+
+    &:focus-within {
+      border-color: var(--ls-border-color);
+    }
+
+    .form-checkbox {
+      top: 0;
+    }
+  }
+
+  &:is(:hover, [data-checked='true']) .dashboard-card-checkbox {
+    visibility: visible;
+  }
+}
+
+.dashboard-preview-card {
+  @apply transition;
+  border: 1px solid var(--ls-border-color);
+}
+
+.dashboard-create-card {
+  @apply items-center justify-center relative;
+  background-color: var(--ls-secondary-background-color);
+  box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
+  border: 1px solid transparent;
+}
+
+.dashboard-create-card i {
+  font-size: 24px;
+}
+
+.dashboard-create-card-caption {
+  position: absolute;
+  width: 100%;
+  bottom: 12px;
+  display: flex;
+  justify-content: center;
+  font-size: 14px;
+}
+
+.dashboard-create-card:hover {
+  background-color: var(--ls-selection-background-color);
+  box-shadow: 0 4px 8px -2px rgba(16, 24, 40, 0.1),
+    0 2px 4px -2px rgba(16, 24, 40, 0.06);
+  border: 1px solid var(--ls-page-blockquote-border-color);
+}
+
+.dashboard-card-title {
+  @apply px-4 py-3 flex flex-col;
+  gap: 4px;
+  border-bottom: 1px solid var(--ls-border-color);
+  background-color: var(--ls-secondary-background-color);
+  font-size: 16px;
+}
+
+.dashboard-card-title-name {
+  @apply truncate;
+  color: var(--ls-primary-text-color);
+}
+
+.single-block > :is(.block-content-wrapper, .editor-wrapper) {
+  width: 100% !important;
+  background-color: var(--ls-secondary-background-color);
+  padding: 8px 12px;
+  border-radius: 8px;
+}
+
+.tl-logseq-cp-container > .page {
+  padding: 12px;
+}
+
+.tl-logseq-cp-container > .ls-block {
+  padding: 0;
+}
+
+/**
+ * ???
+ */
+.open-page-ref-link {
+  @apply text-sm px-1 flex items-center;
+  border-radius: 2px;
+  flex-shrink: 0;
+  background-color: var(--ls-quaternary-background-color);
+  font-size: 14px;
+  color: var(--ls-primary-text-color);
+  vertical-align: baseline;
+}
+
+.whiteboard-page-refs-count {
+  border-radius: 8px;
+  background: var(--ls-primary-background-color);
+}
+
+.whiteboard-page-refs-count:hover,
+.whiteboard-page-refs-count.open {
+  filter: brightness(0.9);
+}
+
+.whiteboard-page-title-root {
+  @apply shadow-md flex items-center;
+  position: absolute;
+  left: 53px;
+  top: 0;
+  background: var(--ls-primary-background-color);
+  padding: 4px;
+  border-radius: 0 0 12px 12px;
+  z-index: 2000;
+  gap: 4px;
+  line-height: 1.4;
+}
+
+.whiteboard-page-title {
+  @apply inline-flex px-2 py-1;
+  font-size: 20px;
+  border-radius: 8px;
+  border: 1px solid transparent;
+  background: var(--ls-secondary-background-color);
+}
+
+.whiteboard-page-title:hover {
+  background-color: var(--ls-tertiary-background-color);
+}
+
+.whiteboard-page-title:focus-within {
+  border: 1px solid var(--ls-border-color);
+  box-shadow: 0 0 0 4px var(--ls-focus-ring-color);
+}

+ 5 - 0
src/main/frontend/config.cljs

@@ -232,6 +232,7 @@
 
 (defonce default-journals-directory "journals")
 (defonce default-pages-directory "pages")
+(defonce default-whiteboards-directory "whiteboards")
 
 (defn get-pages-directory
   []
@@ -241,6 +242,10 @@
   []
   (or (state/get-journals-directory) default-journals-directory))
 
+(defn get-whiteboards-directory
+  []
+  (or (state/get-whiteboards-directory) default-whiteboards-directory))
+
 (defonce local-repo "local")
 
 (defn demo-graph?

+ 49 - 28
src/main/frontend/db/model.cljs

@@ -8,17 +8,18 @@
             [datascript.core :as d]
             [frontend.config :as config]
             [frontend.date :as date]
-            [logseq.db.schema :as db-schema]
             [frontend.db.conn :as conn]
             [frontend.db.react :as react]
             [frontend.db.utils :as db-utils]
             [frontend.state :as state]
             [frontend.util :as util :refer [react]]
-            [logseq.graph-parser.util :as gp-util]
-            [logseq.graph-parser.text :as text]
-            [logseq.db.rules :refer [rules]]
+            [frontend.util.drawer :as drawer]
             [logseq.db.default :as default-db]
-            [frontend.util.drawer :as drawer]))
+            [logseq.db.rules :refer [rules]]
+            [logseq.db.schema :as db-schema]
+            [logseq.graph-parser.config :as gp-config]
+            [logseq.graph-parser.text :as text]
+            [logseq.graph-parser.util :as gp-util]))
 
 ;; lazy loading
 
@@ -64,9 +65,9 @@
   (when-let [repo (state/get-current-repo)]
     (->
      (react/q repo [:frontend.db.react/block id]
-       {:query-fn (fn [_]
-                    (db-utils/pull (butlast block-attrs) id))}
-       nil)
+              {:query-fn (fn [_]
+                           (db-utils/pull (butlast block-attrs) id))}
+              nil)
      react)))
 
 (def get-original-name util/get-page-original-name)
@@ -1358,10 +1359,10 @@
 (defn get-all-properties
   []
   (let [properties (d/q
-                     '[:find [?p ...]
-                       :where
-                       [_ :block/properties ?p]]
-                     (conn/get-db))
+                    '[:find [?p ...]
+                      :where
+                      [_ :block/properties ?p]]
+                    (conn/get-db))
         properties (remove (fn [m] (empty? m)) properties)]
     (->> (map keys properties)
          (apply concat)
@@ -1374,13 +1375,13 @@
                (get properties property))]
     (->>
      (d/q
-       '[:find [?property-val ...]
-         :in $ ?pred
-         :where
-         [_ :block/properties ?p]
-         [(?pred $ ?p) ?property-val]]
-       (conn/get-db)
-       pred)
+      '[:find [?property-val ...]
+        :in $ ?pred
+        :where
+        [_ :block/properties ?p]
+        [(?pred $ ?p) ?property-val]]
+      (conn/get-db)
+      pred)
      (map (fn [x] (if (coll? x) x [x])))
      (apply concat)
      (map str)
@@ -1641,12 +1642,32 @@
 (defn get-macro-blocks
   [repo macro-name]
   (d/q
-    '[:find [(pull ?b [*]) ...]
-      :in $ ?macro-name
-      :where
-      [?b :block/type "macro"]
-      [?b :block/properties ?properties]
-      [(get ?properties :logseq.macro-name) ?name]
-      [(= ?name ?macro-name)]]
-    (conn/get-db repo)
-    macro-name))
+   '[:find [(pull ?b [*]) ...]
+     :in $ ?macro-name
+     :where
+     [?b :block/type "macro"]
+     [?b :block/properties ?properties]
+     [(get ?properties :logseq.macro-name) ?name]
+     [(= ?name ?macro-name)]]
+   (conn/get-db repo)
+   macro-name))
+
+(defn whiteboard-page?
+  [page-name]
+  (let [page (db-utils/entity [:block/name (util/safe-page-name-sanity-lc page-name)])]
+    (or
+     (= "whiteboard" (:block/type page))
+     (when-let [file (:block/file page)]
+       (when-let [path (:file/path (db-utils/entity (:db/id file)))]
+         (gp-config/whiteboard? path))))))
+
+(defn get-all-whiteboards
+  [repo]
+  (->> (d/q
+        '[:find [(pull ?page [:block/name
+                              :block/created-at
+                              :block/updated-at]) ...]
+          :where
+          [?page :block/name]
+          [?page :block/type "whiteboard"]]
+        (conn/get-db repo))))

+ 20 - 2
src/main/frontend/dicts.cljc

@@ -45,6 +45,10 @@
         :search/result-for "Search result for "
         :search/items "items"
         :search/page-names "Search page names"
+        :search-item/whiteboard "Whiteboard"
+        :search-item/page "Page"
+        :search-item/file "File"
+        :search-item/block "Block"
         :help/context-menu "Block context menu"
         :help/fold-unfold "Fold/unfold blocks (when not in edit mode)"
         :help/markdown-syntax "Markdown syntax"
@@ -55,6 +59,7 @@
         :highlight "Highlight"
         :strikethrough "Strikethrough"
         :code "Code"
+        :untitled "Untitled"
         :right-side-bar/help "Help"
         :right-side-bar/switch-theme "Theme modes"
         :right-side-bar/theme "{1} theme"
@@ -65,12 +70,15 @@
         :right-side-bar/block-ref "Block references"
         :right-side-bar/graph-view "Graph view"
         :right-side-bar/all-pages "All pages"
+        :right-side-bar/whiteboards "Whiteboards"
         :right-side-bar/flashcards "Flashcards"
         :right-side-bar/new-page "New page"
         :right-side-bar/show-journals "Show Journals"
         :right-side-bar/separator "Right sidebar resize handler"
         :left-side-bar/journals "Journals"
+        :left-side-bar/create "Create"
         :left-side-bar/new-page "New page"
+        :left-side-bar/new-whiteboard "New whiteboard"
         :left-side-bar/nav-favorites "Favorites"
         :left-side-bar/nav-shortcuts "Shortcuts"
         :left-side-bar/nav-recent-pages "Recent"
@@ -98,6 +106,7 @@
         :page/add-to-favorites "Add to Favorites"
         :page/unfavorite "Unfavorite page"
         :page/show-journals "Show journals"
+        :page/show-whiteboards "Show whiteboards"
         :page/show-name "Show page name"
         :page/hide-name "Hide page name"
         :block/name "Page name"
@@ -231,7 +240,10 @@
         :settings-page/enable-flashcards "Flashcards"
         :settings-page/network-proxy "Network proxy"
         :settings-page/filename-format "Filename format"
+        :settings-page/alpha-features "Alpha features"
+        :settings-page/login-prompt "To access new features before anyone else you must be a financial supporter or alpha tester of Logseq and therefore log in first."
         :settings-page/sync "Sync"
+        :settings-page/enable-whiteboards "Whiteboards"
         :logseq "Logseq"
         :on "ON"
         :more-options "More options"
@@ -258,9 +270,12 @@
         :unlink "unlink"
         :search/publishing "Search"
         :search "Search or create page"
+        :whiteboard/link-whiteboard-or-block "Link whiteboard/page/block"
         :page-search "Search in the current page"
         :graph-search "Search graph"
         :new-page "New page"
+        :new-whiteboard "New whiteboard"
+        :go-to-whiteboard "Go to whiteboard"
         :new-file "New file"
         :new-graph "Add new graph"
         :graph "Graph"
@@ -288,6 +303,7 @@
         :convert-markdown "Convert Markdown headings to unordered lists (# -> -)"
         :all-graphs "All graphs"
         :all-pages "All pages"
+        :all-whiteboards "All whiteboards"
         :all-files "All files"
         :remove-orphaned-pages "Remove orphaned pages"
         :all-journals "All journals"
@@ -661,8 +677,7 @@
         :user/delete-your-account "Ihr Konto löschen"
 
         :file-sync/other-user-graph "Aktuelle lokale Grafik ist an das Remote-Graph des anderen Benutzers gebunden. Kann also nicht mit der Synchronisierung beginnen."
-        :file-sync/graph-deleted "Das aktuelle Ferndiagramm wurde gelöscht"
-        }
+        :file-sync/graph-deleted "Das aktuelle Ferndiagramm wurde gelöscht"}
    :nl {
         :all-files "Alle bestanden"
         :all-graphs "Alle grafieken"
@@ -1199,6 +1214,7 @@
            :highlight "高亮"
            :strikethrough "删除线"
            :code "代码"
+           :untitled "未命名"
            :discourse-title "我们的论坛"
            :export-datascript-edn "导出 datascript EDN"
            :export-edn "导出为 EDN"
@@ -1261,6 +1277,7 @@
            :page/add-to-favorites "添加收藏"
            :page/unfavorite "取消收藏"
            :page/show-journals "显示日志"
+           :page/show-whiteboards "显示白板"
            :page/show-name "显示页面名"
            :page/hide-name "隐藏页面名"
            :block/name "页面名称"
@@ -1321,6 +1338,7 @@
            :settings-page/enable-journals "开启日记"
            :settings-page/enable-all-pages-public "发布所有页面"
            :settings-page/enable-encryption "激活加密功能"
+           :settings-page/enable-whiteboards "激活白板功能"
            :settings-page/customize-shortcuts "自定义快捷键"
            :settings-page/shortcut-settings "快捷键设置"
            :settings-page/home-default-page "设置首页默认页面"

+ 105 - 0
src/main/frontend/extensions/tldraw.cljs

@@ -0,0 +1,105 @@
+(ns frontend.extensions.tldraw
+  "Adapters related to tldraw"
+  (:require ["/frontend/tldraw-logseq" :as TldrawLogseq]
+            [frontend.components.block :as block]
+            [frontend.components.page :as page]
+            [frontend.db.model :as model]
+            [frontend.handler.editor :as editor-handler]
+            [frontend.handler.route :as route-handler]
+            [frontend.handler.whiteboard :as whiteboard-handler]
+            [frontend.rum :as r]
+            [frontend.search :as search]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [goog.object :as gobj]
+            [promesa.core :as p]
+            [rum.core :as rum]))
+
+(def tldraw (r/adapt-class (gobj/get TldrawLogseq "App")))
+
+(def generate-preview (gobj/get TldrawLogseq "generateJSXFromApp"))
+
+(rum/defc page-cp
+  [props]
+  (page/page {:page-name (gobj/get props "pageName") :whiteboard? true}))
+
+(rum/defc block-cp
+  [props]
+  ((state/get-component :block/single-block) (uuid (gobj/get props "blockId"))))
+
+(rum/defc breadcrumb
+  [props]
+  (block/breadcrumb {:preview? true} (state/get-current-repo) (uuid (gobj/get props "blockId")) nil))
+
+(rum/defc page-name-link
+  [props]
+  (block/page-cp {:preview? true} {:block/name (gobj/get props "pageName")}))
+
+(defn search-handler
+  [q filters]
+  (let [{:keys [pages? blocks? files?]} (js->clj filters {:keywordize-keys true})
+        repo (state/get-current-repo)
+        limit 100]
+    (p/let [blocks (when blocks? (search/block-search repo q {:limit limit}))
+            pages (when pages? (search/page-search q))
+            files (when files? (search/file-search q limit))]
+      (clj->js {:pages pages :blocks blocks :files files}))))
+
+(defn save-asset-handler
+  [file]
+  (-> (editor-handler/save-assets! nil (state/get-current-repo) [(js->clj file)])
+      (p/then
+       (fn [res]
+         (when-let [[asset-file-name _ full-file-path] (and (seq res) (first res))]
+           (editor-handler/resolve-relative-path (or full-file-path asset-file-name)))))))
+
+(def tldraw-renderers {:Page page-cp
+                       :Block block-cp
+                       :Breadcrumb breadcrumb
+                       :PageNameLink page-name-link})
+
+(defn get-tldraw-handlers [name]
+  {:search search-handler
+   :queryBlockByUUID #(clj->js (model/query-block-by-uuid (parse-uuid %)))
+   :isWhiteboardPage model/whiteboard-page?
+   :saveAsset save-asset-handler
+   :makeAssetUrl editor-handler/make-asset-url
+   :addNewBlock (fn [content]
+                  (str (whiteboard-handler/add-new-block! name content)))
+   :sidebarAddBlock (fn [uuid type]
+                      (state/sidebar-add-block! (state/get-current-repo)
+                                                (:db/id (model/get-page uuid))
+                                                (keyword type)))
+   :redirectToPage (fn [page-name]
+                     (if (model/whiteboard-page? page-name)
+                       (route-handler/redirect-to-whiteboard! page-name)
+                       (route-handler/redirect-to-page! page-name)))})
+
+(rum/defc tldraw-app
+  [name block-id]
+  (let [data (whiteboard-handler/page-name->tldr! name block-id)
+        [tln set-tln] (rum/use-state nil)]
+    (rum/use-layout-effect!
+     (fn []
+       (when (and tln name)
+         (when-let [^js api (gobj/get tln "api")]
+           (when (and block-id (parse-uuid block-id))
+             (. api selectShapes block-id)
+             (. api zoomToSelection))))
+       nil) [name block-id tln])
+    (when (and (not-empty name) (not-empty (gobj/get data "currentPageId")))
+      [:div.draw.tldraw.whiteboard.relative.w-full.h-full
+       {:style {:overscroll-behavior "none"}
+        :on-blur (fn [e]
+                   (when (#{"INPUT" "TEXTAREA"} (.-tagName (gobj/get e "target")))
+                     (state/clear-edit!)))
+        ;; wheel -> overscroll may cause browser navigation
+        :on-wheel util/stop-propagation}
+
+       (tldraw {:renderers tldraw-renderers
+                :handlers (get-tldraw-handlers name)
+                :onMount (fn [app] (set-tln ^js app))
+                :onPersist (fn [app]
+                             (let [document (gobj/get app "serialized")]
+                               (whiteboard-handler/transact-tldr! name document)))
+                :model data})])))

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

@@ -101,7 +101,7 @@ and handles unexpected failure."
                body (vec (if title (rest ast) ast))
                body (drop-while gp-property/properties-ast? body)
                result (cond->
-                        (if (seq body) {:block/body body} {})
+                       (if (seq body) {:block/body body} {})
                         title
                         (assoc :block/title title))]
            (state/add-block-ast-cache! block-uuid content result)

+ 6 - 0
src/main/frontend/handler.cljs

@@ -5,8 +5,11 @@
             [clojure.string :as string]
             [electron.ipc :as ipc]
             [electron.listener :as el]
+            [frontend.components.block :as block]
+            [frontend.components.editor :as editor]
             [frontend.components.page :as page]
             [frontend.components.reference :as reference]
+            [frontend.components.whiteboard :as whiteboard]
             [frontend.config :as config]
             [frontend.context.i18n :as i18n :refer [t]]
             [frontend.db :as db]
@@ -186,6 +189,9 @@
   []
   (state/set-page-blocks-cp! page/page-blocks-cp)
   (state/set-component! :block/linked-references reference/block-linked-references)
+  (state/set-component! :whiteboard/tldraw-preview whiteboard/tldraw-preview)
+  (state/set-component! :block/single-block block/single-block-cp)
+  (state/set-component! :editor/box editor/box)
   (command-palette/register-global-shortcut-commands))
 
 (reset! db/*db-listener outliner-db/after-transact-pipelines)

+ 32 - 37
src/main/frontend/handler/common/file.cljs

@@ -8,8 +8,7 @@
             [frontend.mobile.util :as mobile-util]
             [logseq.graph-parser :as graph-parser]
             [logseq.graph-parser.util :as gp-util]
-            [logseq.graph-parser.config :as gp-config]
-            [lambdaisland.glogi :as log]))
+            [logseq.graph-parser.config :as gp-config]))
 
 (defn- page-exists-in-another-file
   "Conflict of files towards same page"
@@ -39,42 +38,38 @@
   ([repo-url file content]
    (reset-file! repo-url file content {}))
   ([repo-url file content {:keys [verbose] :as options}]
-   (try
-     (let [electron-local-repo? (and (util/electron?)
-                                     (config/local-db? repo-url))
-           file (cond
-                  (and electron-local-repo?
-                       util/win32?
-                       (utils/win32 file))
-                  file
+   (let [electron-local-repo? (and (util/electron?)
+                                   (config/local-db? repo-url))
+         file (cond
+                (and electron-local-repo?
+                     util/win32?
+                     (utils/win32 file))
+                file
 
-                  (and electron-local-repo? (or
-                                             util/win32?
-                                             (not= "/" (first file))))
-                  (str (config/get-repo-dir repo-url) "/" file)
+                (and electron-local-repo? (or
+                                           util/win32?
+                                           (not= "/" (first file))))
+                (str (config/get-repo-dir repo-url) "/" file)
 
-                  (and (mobile-util/native-android?) (not= "/" (first file)))
-                  file
+                (and (mobile-util/native-android?) (not= "/" (first file)))
+                file
 
-                  (and (mobile-util/native-ios?) (not= "/" (first file)))
-                  file
+                (and (mobile-util/native-ios?) (not= "/" (first file)))
+                file
 
-                  :else
-                  file)
-           file (gp-util/path-normalize file)
-           new? (nil? (db/entity [:file/path file]))
-           options (merge (dissoc options :verbose)
-                          {:new? new?
-                           :delete-blocks-fn (partial get-delete-blocks repo-url)
-                           :extract-options (merge
-                                             {:user-config (state/get-config)
-                                              :date-formatter (state/get-date-formatter)
-                                              :block-pattern (config/get-block-pattern (gp-util/get-format file))
-                                              :supported-formats (gp-config/supported-formats)
-                                              :uri-encoded? (boolean (util/mobile?))
-                                              :filename-format (state/get-filename-format repo-url)}
-                                             (when (some? verbose) {:verbose verbose}))})]
-       (:tx (graph-parser/parse-file (db/get-db repo-url false) file content options)))
-     (catch :default e
-       (prn "Reset file failed " {:file file})
-       (log/error :exception e)))))
+                :else
+                file)
+         file (gp-util/path-normalize file)
+         new? (nil? (db/entity [:file/path file]))
+         options (merge (dissoc options :verbose)
+                        {:new? new?
+                         :delete-blocks-fn (partial get-delete-blocks repo-url)
+                         :extract-options (merge
+                                           {:user-config (state/get-config)
+                                            :date-formatter (state/get-date-formatter)
+                                            :block-pattern (config/get-block-pattern (gp-util/get-format file))
+                                            :supported-formats (gp-config/supported-formats)
+                                            :uri-encoded? (boolean (util/mobile?))
+                                            :filename-format (state/get-filename-format repo-url)}
+                                           (when (some? verbose) {:verbose verbose}))})]
+     (:tx (graph-parser/parse-file (db/get-db repo-url false) file content options)))))

+ 81 - 69
src/main/frontend/handler/editor.cljs

@@ -8,7 +8,6 @@
             [frontend.config :as config]
             [frontend.date :as date]
             [frontend.db :as db]
-            [logseq.db.schema :as db-schema]
             [frontend.db.model :as db-model]
             [frontend.db.utils :as db-utils]
             [frontend.diff :as diff]
@@ -31,9 +30,6 @@
             [frontend.search :as search]
             [frontend.state :as state]
             [frontend.template :as template]
-            [logseq.graph-parser.text :as text]
-            [logseq.graph-parser.utf8 :as utf8]
-            [logseq.graph-parser.property :as gp-property]
             [frontend.util :as util :refer [profile]]
             [frontend.util.clock :as clock]
             [frontend.util.cursor :as cursor]
@@ -43,18 +39,23 @@
             [frontend.util.marker :as marker]
             [frontend.util.priority :as priority]
             [frontend.util.property :as property]
-            [frontend.util.thingatpt :as thingatpt]
             [frontend.util.text :as text-util]
+            [frontend.util.thingatpt :as thingatpt]
             [goog.dom :as gdom]
             [goog.dom.classes :as gdom-classes]
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
-            [promesa.core :as p]
+            [logseq.db.schema :as db-schema]
+            [logseq.graph-parser.block :as gp-block]
+            [logseq.graph-parser.mldoc :as gp-mldoc]
+            [logseq.graph-parser.property :as gp-property]
+            [logseq.graph-parser.text :as text]
+            [logseq.graph-parser.utf8 :as utf8]
             [logseq.graph-parser.util :as gp-util]
             [logseq.graph-parser.util.block-ref :as block-ref]
             [logseq.graph-parser.util.page-ref :as page-ref]
-            [logseq.graph-parser.mldoc :as gp-mldoc]
-            [logseq.graph-parser.block :as gp-block]))
+            [promesa.core :as p]
+            [rum.core :as rum]))
 
 ;; FIXME: should support multiple images concurrently uploading
 
@@ -437,11 +438,11 @@
                    :else
                    (not has-children?))]
     (outliner-tx/transact!
-      {:outliner-op :insert-blocks}
-      (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?}))))
+     {:outliner-op :insert-blocks}
+     (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?}))))
 
 (defn- block-self-alone-when-insert?
   [config uuid]
@@ -771,8 +772,8 @@
         block (db/pull repo '[*] [:block/uuid uuid])]
     (when block
       (outliner-tx/transact!
-        {:outliner-op :delete-blocks}
-        (outliner-core/delete-blocks! [block] {:children? children?})))))
+       {:outliner-op :delete-blocks}
+       (outliner-core/delete-blocks! [block] {:children? children?})))))
 
 (defn- move-to-prev-block
   [repo sibling-block format id value]
@@ -828,8 +829,8 @@
           block-parent (get uuid->dom-block (:block/uuid block))
           sibling-block (when block-parent (util/get-prev-block-non-collapsed-non-embed block-parent))]
       (outliner-tx/transact!
-        {:outliner-op :delete-blocks}
-        (outliner-core/delete-blocks! blocks {}))
+       {:outliner-op :delete-blocks}
+       (outliner-core/delete-blocks! blocks {}))
       (when sibling-block
         (move-to-prev-block repo sibling-block
                             (:block/format block)
@@ -842,25 +843,25 @@
   #_:clj-kondo/ignore
   (when-let [repo (state/get-current-repo)]
     (outliner-tx/transact!
-      {:outliner-op :save-block}
-      (doseq [[block-id key value] col]
-        (let [block-id (if (string? block-id) (uuid block-id) block-id)]
-          (when-let [block (db/entity [:block/uuid block-id])]
-            (let [format (:block/format block)
-                  content (:block/content block)
-                  properties (:block/properties block)
-                  properties (if (nil? value)
-                               (dissoc properties key)
-                               (assoc properties key value))
-                  content (if (nil? value)
-                            (property/remove-property format key content)
-                            (property/insert-property format content key value))
-                  content (property/remove-empty-properties content)
-                  block {:block/uuid block-id
-                         :block/properties properties
-                         :block/properties-order (keys properties)
-                         :block/content content}]
-              (outliner-core/save-block! block))))))
+     {:outliner-op :save-block}
+     (doseq [[block-id key value] col]
+       (let [block-id (if (string? block-id) (uuid block-id) block-id)]
+         (when-let [block (db/entity [:block/uuid block-id])]
+           (let [format (:block/format block)
+                 content (:block/content block)
+                 properties (:block/properties block)
+                 properties (if (nil? value)
+                              (dissoc properties key)
+                              (assoc properties key value))
+                 content (if (nil? value)
+                           (property/remove-property format key content)
+                           (property/insert-property format content key value))
+                 content (property/remove-empty-properties content)
+                 block {:block/uuid block-id
+                        :block/properties properties
+                        :block/properties-order (keys properties)
+                        :block/content content}]
+             (outliner-core/save-block! block))))))
 
     (let [block-id (ffirst col)
           block-id (if (string? block-id) (uuid block-id) block-id)
@@ -1390,8 +1391,12 @@
 (defn make-asset-url
   [path] ;; path start with "/assets" or compatible for "../assets"
   (let [repo-dir (config/get-repo-dir (state/get-current-repo))
-        path (string/replace path "../" "/")]
+        path (string/replace path "../" "/")
+        data-url? (string/starts-with? path "data:")]
     (cond
+      data-url?
+      path ;; just return the original
+      
       (util/electron?)
       (str "assets://" repo-dir path)
 
@@ -1650,9 +1655,9 @@
     (let [edit-block-id (:block/uuid (state/get-edit-block))
           move-nodes (fn [blocks]
                        (outliner-tx/transact!
-                         {:outliner-op :move-blocks}
-                         (save-current-block!)
-                         (outliner-core/move-blocks-up-down! blocks up?))
+                        {:outliner-op :move-blocks}
+                        (save-current-block!)
+                        (outliner-core/move-blocks-up-down! blocks up?))
                        (when-let [block-node (util/get-first-block-by-id (:block/uuid (first blocks)))]
                          (.scrollIntoView block-node #js {:behavior "smooth" :block "nearest"})))]
       (if edit-block-id
@@ -1683,9 +1688,9 @@
   (let [blocks (get-selected-ordered-blocks)]
     (when (seq blocks)
       (outliner-tx/transact!
-        {:outliner-op :move-blocks
-         :real-outliner-op :indent-outdent}
-        (outliner-core/indent-outdent-blocks! blocks (= direction :right))))))
+       {:outliner-op :move-blocks
+        :real-outliner-op :indent-outdent}
+       (outliner-core/indent-outdent-blocks! blocks (= direction :right))))))
 
 (defn- get-link [format link label]
   (let [link (or link "")
@@ -2010,7 +2015,7 @@
                       (when target (:block/page (db/entity (:db/id target)))))
              blocks' (map (fn [block]
                             (paste-block-cleanup block page exclude-properties format content-update-fn))
-                       blocks)
+                          blocks)
              sibling? (:sibling? opts)
              sibling?' (cond
                          (some? sibling?)
@@ -2022,12 +2027,12 @@
                          :else
                          true)]
          (outliner-tx/transact!
-           {:outliner-op :insert-blocks}
-           (save-current-block!)
-           (let [result (outliner-core/insert-blocks! blocks'
-                                                      target
-                                                      (assoc opts :sibling? sibling?'))]
-             (edit-last-block-after-inserted! result))))))))
+          {:outliner-op :insert-blocks}
+          (save-current-block!)
+          (let [result (outliner-core/insert-blocks! blocks'
+                                                     target
+                                                     (assoc opts :sibling? sibling?'))]
+            (edit-last-block-after-inserted! result))))))))
 
 (defn template-on-chosen-handler
   [element-id]
@@ -2083,10 +2088,10 @@
   (when-not (parent-is-page? node)
     (let [parent-node (tree/-get-parent node)]
       (outliner-tx/transact!
-        {:outliner-op :move-blocks
-         :real-outliner-op :indent-outdent}
-        (save-current-block!)
-        (outliner-core/move-blocks! [(:data node)] (:data parent-node) true)))))
+       {:outliner-op :move-blocks
+        :real-outliner-op :indent-outdent}
+       (save-current-block!)
+       (outliner-core/move-blocks! [(:data node)] (:data parent-node) true)))))
 
 (defn- last-top-level-child?
   [{:keys [id]} current-node]
@@ -2379,17 +2384,23 @@
             :else
             (profile
              "Insert block"
-             (insert-new-block! state))))))))
+             (do (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"
+  [el]
+  (some? (dom/closest el ".single-block")))
 
 (defn keydown-new-block-handler [state e]
-  (if (state/doc-mode-enter-for-new-line?)
+  (if (or (state/doc-mode-enter-for-new-line?) (inside-of-single-block (rum/dom-node state)))
     (keydown-new-line)
     (do
       (.preventDefault e)
       (keydown-new-block state))))
 
 (defn keydown-new-line-handler [state e]
-  (if (state/doc-mode-enter-for-new-line?)
+  (if (and (state/doc-mode-enter-for-new-line?) (not (inside-of-single-block (rum/dom-node state))))
     (keydown-new-block state)
     (do
       (.preventDefault e)
@@ -2569,6 +2580,7 @@
         block (state/get-edit-block)
         repo (state/get-current-repo)
         top-block? (= (:block/left block) (:block/page block))
+        single-block? (inside-of-single-block (.-target e))
         root-block? (= (:block/container block) (str (:block/uuid block)))]
     (mark-last-input-time! repo)
     (cond
@@ -2583,7 +2595,8 @@
       (do
         (util/stop e)
         (when (and (if top-block? (string/blank? value) true)
-                   (not root-block?))
+                   (not root-block?)
+                   (not single-block?))
           (delete-block! repo false)))
 
       (and (> current-pos 1)
@@ -2644,10 +2657,10 @@
     (when block
       (state/set-editor-last-pos! pos)
       (outliner-tx/transact!
-        {:outliner-op :move-blocks
-         :real-outliner-op :indent-outdent}
-        (save-current-block!)
-        (outliner-core/indent-outdent-blocks! [block] indent?)))
+       {:outliner-op :move-blocks
+        :real-outliner-op :indent-outdent}
+       (save-current-block!)
+       (outliner-core/indent-outdent-blocks! [block] indent?)))
     (state/set-editor-op! :nil)))
 
 (defn keydown-tab-handler
@@ -2703,9 +2716,9 @@
                 top-block? (= (:block/left block) (:block/page block))
                 root-block? (= (:block/container block) (str (:block/uuid block)))
                 repo (state/get-current-repo)]
-           (when (and (if top-block? (string/blank? value) true)
-                      (not root-block?))
-             (delete-block! repo false))))
+            (when (and (if top-block? (string/blank? value) true)
+                       (not root-block?))
+              (delete-block! repo false))))
 
         (and (= key "#")
              (and (> pos 0)
@@ -2715,7 +2728,7 @@
         (and (contains? (set/difference (set (keys reversed-autopair-map))
                                         #{"`"})
                         key)
-         (= (get-current-input-char input) key))
+             (= (get-current-input-char input) key))
         (do (util/stop e)
             (cursor/move-cursor-forward input))
 
@@ -2949,7 +2962,6 @@
   (util/stop e)
   (cut-blocks-and-clear-selections! false))
 
-;; credits to @pengx17
 (defn- copy-current-block-ref
   [format]
   (when-let [current-block (state/get-edit-block)]
@@ -3068,9 +3080,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!

+ 67 - 1
src/main/frontend/handler/events.cljs

@@ -57,7 +57,8 @@
             [goog.dom :as gdom]
             [logseq.db.schema :as db-schema]
             [promesa.core :as p]
-            [rum.core :as rum]))
+            [rum.core :as rum]
+            [logseq.graph-parser.config :as gp-config]))
 
 ;; TODO: should we move all events here?
 
@@ -92,6 +93,7 @@
                                     (util/uuid-string? (first (:sync-meta %)))
                                     (util/uuid-string? (second (:sync-meta %)))) repos)
                     (sync/sync-start)))))
+            (ui-handler/re-render-root!)
             (file-sync/maybe-onboarding-show status)))))))
 
 (defmethod handle :user/logout [[_]]
@@ -674,6 +676,14 @@
   (when-not (mobile-util/native-ios?)
     (state/pub-event! [:graph/ready (state/get-current-repo)])))
 
+(defmethod handle :whiteboard-link [[_ shapes]]
+  (route-handler/go-to-search! :whiteboard/link)
+  (state/set-state! :whiteboard/linked-shapes shapes))
+
+(defmethod handle :whiteboard-go-to-link [[_ link]]
+  (route-handler/redirect! {:to :whiteboard
+                            :path-params {:name link}}))
+
 (defmethod handle :graph/dir-gone [[_ dir]]
   (state/pub-event! [:notification/show
                      {:content (str "The directory " dir " has been renamed or deleted, the editor will be disabled for this graph, you can unlink the graph.")
@@ -696,6 +706,62 @@
   (p/let [_ (file-handler/alter-file repo path content {:from-disk? true})]
     (ui-handler/re-render-root!)))
 
+(rum/defcs file-id-conflict-item <
+  (rum/local false ::resolved?)
+  [state repo file data]
+  (let [resolved? (::resolved? state)
+        id (last (:assertion data))]
+    [:li {:key file}
+     [:div
+      [:a {:on-click #(js/window.apis.openPath file)} file]
+      (if @resolved?
+        [:div.flex.flex-row.items-center
+         (ui/icon "circle-check" {:style {:font-size 20}})
+         [:div.ml-1 "Resolved"]]
+        [:div
+         [:p
+          (str "It seems that another whiteboard file already has the ID \"" id
+               "\". You can fix it by changing the ID in this file with another UUID.")]
+         [:p
+          "Or, let me"
+          (ui/button "Fix"
+            :on-click (fn []
+                        (let [dir (config/get-repo-dir repo)]
+                          (p/let [content (fs/read-file dir file)]
+                            (let [new-content (string/replace content (str id) (str (random-uuid)))]
+                              (p/let [_ (fs/write-file! repo
+                                                        dir
+                                                        file
+                                                        new-content
+                                                        {})]
+                                (reset! resolved? true))))))
+            :class "inline mx-1")
+          "it."]])]]))
+
+(defmethod handle :file/parse-and-load-error [[_ repo parse-errors]]
+  (state/pub-event! [:notification/show
+                     {:content
+                      [:div
+                       [:h2.title "Oops, those files are failed to imported to your graph:"]
+                       [:ol.my-2
+                        (for [[file error] parse-errors]
+                          (let [data (ex-data error)]
+                            (cond
+                             (and (gp-config/whiteboard? file)
+                                  (= :transact/upsert (:error data))
+                                  (uuid? (last (:assertion data))))
+                             (rum/with-key (file-id-conflict-item repo file data) file)
+
+                             :else
+                             (do
+                               (state/pub-event! [:instrument {:type :file/parse-and-load-error
+                                                               :payload error}])
+                               [:li.my-1 {:key file}
+                                [:a {:on-click #(js/window.apis.openPath file)} file]
+                                [:p (.-message error)]]))))]
+                       [:p "Don't forget to re-index your graph when all the conflicts are resolved."]]
+                      :status :error}]))
+
 (defn run!
   []
   (let [chan (state/get-events-chan)]

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

@@ -116,7 +116,7 @@
                           [:db/retract page-id :block/tags]]
                          opts)))
                    (file-common-handler/reset-file! repo path content (merge opts
-                                                         (when (some? verbose) {:verbose verbose}))))
+                                                                             (when (some? verbose) {:verbose verbose}))))
                  (db/set-file-content! repo path content opts))]
     (util/p-handle (write-file!)
                    (fn [_]

+ 14 - 9
src/main/frontend/handler/page.cljs

@@ -44,6 +44,7 @@
             [frontend.format.block :as block]
             [goog.functions :refer [debounce]]))
 
+;; FIXME: add whiteboard
 (defn- get-directory
   [journal?]
   (if journal?
@@ -100,12 +101,14 @@
   (and (not journal?)
        (fs-util/create-title-property? page-name)))
 
-(defn- build-page-tx [format properties page journal?]
+(defn- build-page-tx [format properties page journal? whiteboard?]
   (when (:block/uuid page)
     (let [page-entity   [:block/uuid (:block/uuid page)]
           title         (util/get-page-original-name page)
           create-title? (create-title-property? journal? title)
-          page          (if (seq properties) (assoc page :block/properties properties) page)
+          page          (merge page
+                               (when (seq properties) {:block/properties properties})
+                               (when whiteboard? {:block/type "whiteboard"}))
           page-empty?   (db/page-empty? (state/get-current-repo) (:block/name page))]
       (cond
         (not page-empty?)
@@ -130,7 +133,7 @@
    :uuid                - when set, use this uuid instead of generating a new one."
   ([title]
    (create! title {}))
-  ([title {:keys [redirect? create-first-block? format properties split-namespace? journal? uuid]
+  ([title {:keys [redirect? create-first-block? format properties split-namespace? journal? uuid whiteboard?]
            :or   {redirect?           true
                   create-first-block? true
                   format              nil
@@ -158,11 +161,11 @@
              txs      (->> pages
                            ;; for namespace pages, only last page need properties
                            drop-last
-                           (mapcat #(build-page-tx format nil % journal?))
+                           (mapcat #(build-page-tx format nil % journal? whiteboard?))
                            (remove nil?)
                            (remove (fn [m]
                                      (some? (db/entity [:block/name (:block/name m)])))))
-             last-txs (build-page-tx format properties (last pages) journal?)
+             last-txs (build-page-tx format properties (last pages) journal? whiteboard?)
              txs      (concat txs last-txs)]
          (when (seq txs)
            (db/transact! txs)))
@@ -434,14 +437,16 @@
             file                (:block/file page)
             journal?            (:block/journal? page)
             properties-block    (:data (outliner-tree/-get-down (outliner-core/block page)))
+            properties-content  (:block/content properties-block)
             properties-block-tx (when (and properties-block
-                                           (string/includes? (util/page-name-sanity-lc (:block/content properties-block))
+                                           properties-content
+                                           (string/includes? (util/page-name-sanity-lc properties-content)
                                                              old-page-name))
-                                  (let [front-matter? (and (property/front-matter? (:block/content properties-block))
+                                  (let [front-matter? (and (property/front-matter? properties-content)
                                                            (= :markdown (:block/format properties-block)))]
                                     {:db/id         (:db/id properties-block)
                                      :block/content (property/insert-property (:block/format properties-block)
-                                                                              (:block/content properties-block)
+                                                                              properties-content
                                                                               :title
                                                                               new-name
                                                                               front-matter?)}))
@@ -465,7 +470,7 @@
 
       ;; Redirect to the newly renamed page
       (when redirect?
-        (route-handler/redirect! {:to          :page
+        (route-handler/redirect! {:to          (if (= "whiteboard" (:block/type page)) :whiteboard :page)
                                   :push        false
                                   :path-params {:name new-page-name}}))
 

+ 9 - 0
src/main/frontend/handler/paste.cljs

@@ -59,12 +59,21 @@
       (notification/show! (util/format "No macro is available for %s" url) :warning)
       nil)))
 
+(defn- try-parse-as-json
+  [text]
+  (try (js/JSON.parse text)
+       (catch :default _ #js{})))
+
 (defn- paste-copied-blocks-or-text
   [text e html]
   (util/stop e)
   (let [copied-blocks (state/get-copied-blocks)
         input (state/get-input)
         text (string/replace text "\r\n" "\n") ;; Fix for Windows platform
+        whiteboard-shape? (= "logseq/whiteboard-shapes" (gobj/get (try-parse-as-json text) "type"))
+        text (if whiteboard-shape?
+               (block-ref/->block-ref (gobj/getValueByKeys (try-parse-as-json text) "shapes" 0 "id"))
+               text)
         internal-paste? (and
                          (seq (:copy/blocks copied-blocks))
                          ;; not copied from the external clipboard

+ 25 - 8
src/main/frontend/handler/repo.cljs

@@ -28,6 +28,7 @@
             [frontend.db.persist :as db-persist]
             [logseq.graph-parser.util :as gp-util]
             [logseq.graph-parser :as graph-parser]
+            [logseq.graph-parser.config :as gp-config]
             [electron.ipc :as ipc]
             [cljs-bean.core :as bean]
             [clojure.core.async :as async]
@@ -191,12 +192,17 @@
                                              :from-disk? true
                                              :skip-db-transact? skip-db-transact?}
                                             (when (some? verbose) {:verbose verbose}))))
+    (state/set-parsing-state! (fn [m]
+                                (update m :finished inc)))
+    @*file-tx
     (catch :default e
+      (println "Parse and load file failed: " (str (:file/path file)))
+      (js/console.error e)
       (state/set-parsing-state! (fn [m]
-                                  (update m :failed-parsing-files conj [(:file/path file) e])))))
-  (state/set-parsing-state! (fn [m]
-                              (update m :finished inc)))
-  @*file-tx)
+                                  (update m :failed-parsing-files conj [(:file/path file) e])))
+      (state/set-parsing-state! (fn [m]
+                                  (update m :finished inc)))
+      nil)))
 
 (defn- after-parse
   [repo-url files file-paths db-encrypted? re-render? re-render-opts opts graph-added-chan]
@@ -209,6 +215,9 @@
   (when re-render?
     (ui-handler/re-render-root! re-render-opts))
   (state/pub-event! [:graph/added repo-url opts])
+  (let [parse-errors (get-in @state/state [:graph/parsing-state repo-url :failed-parsing-files])]
+    (when (seq parse-errors)
+      (state/pub-event! [:file/parse-and-load-error repo-url parse-errors])))
   (state/reset-parsing-state!)
   (state/set-loading-files! repo-url false)
   (async/offer! graph-added-chan true))
@@ -240,17 +249,24 @@
       (async/go-loop [tx []]
         (if-let [item (async/<! chan)]
           (let [[idx file] item
+                whiteboard? (gp-config/whiteboard? (:file/path file))
                 yield-for-ui? (or (not large-graph?)
                                   (zero? (rem idx 10))
-                                  (<= (- total idx) 10))]
+                                  (<= (- total idx) 10)
+                                  whiteboard?)]
             (state/set-parsing-state! (fn [m]
                                         (assoc m :current-parsing-file (:file/path file))))
 
             (when yield-for-ui? (async/<! (async/timeout 1)))
 
-            (let [result (parse-and-load-file! repo-url file (select-keys opts [:new-graph? :verbose]))
-                  tx' (concat tx result)
-                  tx' (if (zero? (rem (inc idx) 100))
+            (let [opts' (select-keys opts [:new-graph? :verbose])
+                  ;; whiteboards might have conflicting block IDs so that db transaction could be failed
+                  opts' (if whiteboard?
+                          (assoc opts' :skip-db-transact? false)
+                          opts')
+                  result (parse-and-load-file! repo-url file opts')
+                  tx' (if whiteboard? tx (concat tx result))
+                  tx' (if (or whiteboard? (zero? (rem (inc idx) 100)))
                         (do (db/transact! repo-url tx' {:from-disk? true})
                             [])
                         tx')]
@@ -432,6 +448,7 @@
 (defn re-index!
   [nfs-rebuild-index! ok-handler]
   (when-let [repo (state/get-current-repo)]
+    (state/reset-parsing-state!)
     (let [dir (config/get-repo-dir repo)]
       (when-not (state/unlinked-dir? dir)
        (route-handler/redirect-to-home!)

+ 15 - 2
src/main/frontend/handler/route.cljs

@@ -39,6 +39,10 @@
   []
   (redirect! {:to :repos}))
 
+(defn redirect-to-whiteboard-dashboard!
+  []
+  (redirect! {:to :whiteboards}))
+
 (defn redirect-to-page!
   "Must ensure `page-name` is dereferenced (not an alias), or it will create a wrong new page with that name (#3511)."
   ([page-name]
@@ -48,14 +52,23 @@
    (recent-handler/add-page-to-recent! (state/get-current-repo) page-name
                                        click-from-recent?)
    (let [m (cond->
-             {:to :page
-              :path-params {:name (str page-name)}}
+            {:to :page
+             :path-params {:name (str page-name)}}
              anchor
              (assoc :query-params {:anchor anchor})
              push
              (assoc :push push))]
      (redirect! m))))
 
+(defn redirect-to-whiteboard!
+  ([name]
+   (redirect-to-whiteboard! name nil))
+  ([name {:keys [block-id]}]
+   (recent-handler/add-page-to-recent! (state/get-current-repo) name false)
+   (redirect! {:to :whiteboard
+               :path-params {:name (str name)}
+               :query-params (merge {:block-id block-id})})))
+
 (defn get-title
   [name path-params]
   (case name

+ 4 - 1
src/main/frontend/handler/search.cljs

@@ -31,6 +31,8 @@
        (property/remove-built-in-properties format)))
 
 (defn search
+  ([q]
+   (search (state/get-current-repo) q))
   ([repo q]
    (search repo q {:limit 20}))
   ([repo q {:keys [page-db-id limit more?]
@@ -50,7 +52,8 @@
                          {:pages (search/page-search q)
                           :files (search/file-search q)}))
                search-key (if more? :search/more-result :search/result)]
-           (swap! state/state assoc search-key result)))))))
+           (swap! state/state assoc search-key result)
+           result))))))
 
 (defn open-find-in-page!
   []

+ 1 - 0
src/main/frontend/handler/web/nfs.cljs

@@ -173,6 +173,7 @@
                                                                 (contains? #{config/app-name
                                                                              gp-config/default-draw-directory
                                                                              (config/get-journals-directory)
+                                                                             (config/get-whiteboards-directory)
                                                                              (config/get-pages-directory)}
                                                                            last-part)))))
                                                    (into {})))))

+ 207 - 0
src/main/frontend/handler/whiteboard.cljs

@@ -0,0 +1,207 @@
+(ns frontend.handler.whiteboard
+  "Whiteboard related handlers"
+  (:require [datascript.core :as d]
+            [dommy.core :as dom]
+            [frontend.db.model :as model]
+            [frontend.db.utils :as db-utils]
+            [frontend.handler.route :as route-handler]
+            [frontend.modules.outliner.core :as outliner]
+            [frontend.modules.outliner.file :as outliner-file]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [logseq.graph-parser.whiteboard :as gp-whiteboard]))
+
+(defn shape->block [shape page-name idx]
+  (let [properties {:ls-type :whiteboard-shape
+                    :logseq.tldraw.shape (assoc shape :index idx)}
+        block {:block/page {:block/name (util/page-name-sanity-lc page-name)}
+               :block/parent {:block/name page-name}
+               :block/properties properties}
+        additional-props (gp-whiteboard/with-whiteboard-block-props block page-name)]
+    (merge block additional-props)))
+
+(defn- tldr-page->blocks-tx [page-name tldr-data]
+  (let [page-name (util/page-name-sanity-lc page-name)
+        page-entity (model/get-page page-name)
+        page-block (merge {:block/name page-name
+                           :block/type "whiteboard"
+                           :block/properties {:ls-type :whiteboard-page
+                                              :logseq.tldraw.page (dissoc tldr-data :shapes)}}
+                          (when page-entity (select-keys page-entity [:block/created-at])))
+        page-block (outliner/block-with-timestamps page-block)
+        ;; todo: use get-paginated-blocks instead?
+        existing-blocks (model/get-page-blocks-no-cache (state/get-current-repo)
+                                                        page-name
+                                                        {:pull-keys '[:db/id
+                                                                      :block/uuid
+                                                                      :block/properties [:ls-type]
+                                                                      {:block/parent [:block/uuid]}]})
+        shapes (:shapes tldr-data)
+        ;; we should maintain the order of the shapes in the page
+        ;; bring back/forward is depending on this ordering
+        blocks (map-indexed (fn [idx shape] (shape->block shape page-name idx)) shapes)
+        block-ids (->> shapes
+                       (map (fn [shape] (when (= (:blockType shape) "B")
+                                          (uuid (:pageId shape)))))
+                       (concat (map :block/uuid blocks))
+                       (remove nil?)
+                       (set))
+        ;; delete blocks when all of the following are false
+        ;; - the block is not in the new blocks list
+        ;; - the block's parent is not in the new block list
+        ;; - the block is not a shape block 
+        delete-blocks (filterv (fn [block]
+                                 (not
+                                  (or (block-ids (:block/uuid block))
+                                      (block-ids (:block/uuid (:block/parent block)))
+                                      (not (gp-whiteboard/shape-block? block)))))
+                               existing-blocks)
+        delete-blocks-tx (mapv (fn [s] [:db/retractEntity (:db/id s)]) delete-blocks)]
+    (concat [page-block] blocks delete-blocks-tx)))
+
+(defn- get-whiteboard-clj [page-name]
+  (when (model/page-exists? page-name)
+    (let [page-block (model/get-page page-name)
+          ;; fixme: can we use cache?
+          blocks (model/get-page-blocks-no-cache page-name)]
+      [page-block blocks])))
+
+(defn- whiteboard-clj->tldr [page-block blocks shape-id]
+  (let [id (str (:block/uuid page-block))
+        shapes (->> blocks
+                    (filter gp-whiteboard/shape-block?)
+                    (map gp-whiteboard/block->shape)
+                    (sort-by :index))
+        tldr-page (gp-whiteboard/page-block->tldr-page page-block)
+        assets (:assets tldr-page)
+        tldr-page (dissoc tldr-page :assets)]
+    (clj->js {:currentPageId id
+              :assets (or assets #js[])
+              :selectedIds (if (not-empty shape-id) #js[shape-id] #js[])
+              :pages [(merge tldr-page
+                             {:id id
+                              :name "page"
+                              :shapes shapes})]})))
+
+(defn transact-tldr! [page-name tldr]
+  (let [{:keys [pages assets]} (js->clj tldr :keywordize-keys true)
+        page (first pages)
+        tx (tldr-page->blocks-tx page-name (assoc page :assets assets))]
+    (db-utils/transact! tx)))
+
+(defn get-default-tldr
+  [page-id]
+  {:currentPageId page-id,
+   :selectedIds [],
+   :pages [{:id page-id
+            :name page-id
+            :ls-type :whiteboard-page
+            :shapes []
+            :bindings {}
+            :nonce 1}]
+   :assets []})
+
+(defn get-whiteboard-entity [page-name]
+  (db-utils/entity [:block/name (util/page-name-sanity-lc page-name)]))
+
+(defn create-new-whiteboard-page!
+  ([]
+   (create-new-whiteboard-page! nil))
+  ([name]
+   (let [uuid (or (and name (parse-uuid name)) (d/squuid))
+         name (or name (str uuid))
+         tldr (get-default-tldr (str uuid))]
+     (transact-tldr! name (get-default-tldr (str uuid)))
+     (let [entity (get-whiteboard-entity name)
+           tx (assoc (select-keys entity [:db/id])
+                     :block/uuid uuid)]
+       (db-utils/transact! [tx])
+       (let [page-entity (get-whiteboard-entity name)]
+         (when (and page-entity (nil? (:block/file page-entity)))
+           (outliner-file/sync-to-file page-entity))))
+     tldr)))
+
+(defn create-new-whiteboard-and-redirect!
+  ([]
+   (create-new-whiteboard-and-redirect! (str (d/squuid))))
+  ([name]
+   (create-new-whiteboard-page! name)
+   (route-handler/redirect-to-whiteboard! name)))
+
+(defn ->logseq-portal-shape
+  [block-id point]
+  {:blockType (if (parse-uuid (str block-id)) "B" "P")
+   :id (str (d/squuid))
+   :compact false
+   :pageId (str block-id)
+   :point point
+   :size [400, 0]
+   :type "logseq-portal"})
+
+(defn add-new-block-portal-shape!
+  "Given the block uuid, add a new shape to the referenced block.
+   By default it will be placed next to the given shape id"
+  [block-uuid source-shape & {:keys [link? bottom?]}]
+  (let [app (state/active-tldraw-app)
+        api (.-api app)
+        point (-> (.getShapeById app source-shape)
+                  (.-bounds)
+                  ((fn [bounds] (if bottom? 
+                                  [(.-minX bounds) (+ 64 (.-maxY bounds))]
+                                  [(+ 64 (.-maxX bounds)) (.-minY bounds)]))))
+        shape (->logseq-portal-shape block-uuid point)]
+    (.createShapes api (clj->js shape))
+    (when link?
+      (.createNewLineBinding api source-shape (:id shape)))))
+
+(defn page-name->tldr!
+  ([page-name]
+   (page-name->tldr! page-name nil))
+  ([page-name shape-id]
+   (if page-name
+     (if-let [[page-block blocks] (get-whiteboard-clj page-name)]
+       (whiteboard-clj->tldr page-block blocks shape-id)
+       (create-new-whiteboard-page! page-name))
+     (create-new-whiteboard-page! nil))))
+
+(defn- get-whiteboard-blocks
+  "Given a page, return all the logseq blocks (exlude all shapes)"
+  [page-name]
+  (let [blocks (model/get-page-blocks-no-cache page-name)]
+    (remove gp-whiteboard/shape-block? blocks)))
+
+(defn- get-last-root-block
+  "Get the last root Logseq block in the page. Main purpose is to calculate the new :block/left id"
+  [page-name]
+  (let [page-id (:db/id (model/get-page page-name))
+        blocks (get-whiteboard-blocks page-name)
+        root-blocks (filter (fn [block] (= page-id (:db/id (:block/parent block)))) blocks)
+        root-block-left-ids (->> root-blocks
+                                 (map (fn [block] (get-in block [:block/left :db/id] nil)))
+                                 (remove nil?)
+                                 (set))
+        blocks-with-no-next (remove #(root-block-left-ids (:db/id %)) root-blocks)]
+    (when (seq blocks-with-no-next) (first blocks-with-no-next))))
+
+(defn add-new-block!
+  [page-name content]
+  (let [uuid (d/squuid)
+        page-entity (model/get-page page-name)
+        last-root-block (or (get-last-root-block page-name) page-entity)
+        tx {:block/left (select-keys last-root-block [:db/id])
+            :block/uuid uuid
+            :block/content (or content "")
+            :block/format :markdown ; fixme
+            :block/page {:block/name (util/page-name-sanity-lc page-name)}
+            :block/parent {:block/name page-name}}]
+    (db-utils/transact! [tx])
+    uuid))
+
+(defn inside-portal
+  [target]
+  (dom/closest target ".tl-logseq-cp-container"))
+
+(defn closest-shape
+  [target]
+  (when-let [shape-el (dom/closest target "[data-shape-id]")]
+    (.getAttribute shape-el "data-shape-id")))

+ 1 - 1
src/main/frontend/mobile/index.css

@@ -45,7 +45,7 @@
         width: 120%;
         
         
-        .ti {
+        .ti, .tie {
             color: var(--ls-primary-text-color);
             font-size: 23px;
             opacity: 50%;

+ 14 - 6
src/main/frontend/modules/file/core.cljs

@@ -110,14 +110,17 @@
       (let [format (name (get page :block/format
                               (state/get-preferred-format)))
             title (string/capitalize (:block/name page))
+            whiteboard-page? (= "whiteboard" (:block/type page))
+            format (if whiteboard-page? "edn" format)
             journal-page? (date/valid-journal-title? title)
             filename (if journal-page?
                        (date/date->file-name journal-page?)
                        (-> (or (:block/original-name page) (:block/name page))
                            (fs-util/file-name-sanity)))
-            sub-dir (if journal-page?
-                      (config/get-journals-directory)
-                      (config/get-pages-directory))
+            sub-dir (cond
+                      journal-page?    (config/get-journals-directory)
+                      whiteboard-page? (config/get-whiteboards-directory)
+                      :else            (config/get-pages-directory))
             ext (if (= format "markdown") "md" format)
             file-path (config/get-page-file-path repo sub-dir filename ext)
             file {:file/path file-path}
@@ -127,20 +130,25 @@
         (db/transact! tx)
         (when ok-handler (ok-handler))))))
 
+(defn- remove-transit-ids [block] (dissoc block :db/id :block/file))
+
 (defn save-tree-aux!
   [page-block tree]
   (let [page-block (db/pull (:db/id page-block))
-        new-content (tree->file-content tree {:init-level init-level})
         file-db-id (-> page-block :block/file :db/id)
         file-path (-> (db-utils/entity file-db-id) :file/path)]
     (if (and (string? file-path) (not-empty file-path))
-      (let [files [[file-path new-content]]
+      (let [new-content (if (= "whiteboard" (:block/type page-block))
+                          (pr-str {:blocks tree
+                                   :pages (list (remove-transit-ids page-block))})
+                          (tree->file-content tree {:init-level init-level}))
+            files [[file-path new-content]]
             repo (state/get-current-repo)]
         (file-handler/alter-files-handler! repo files {} {}))
       ;; In e2e tests, "card" page in db has no :file/path
       (js/console.error "File path from page-block is not valid" page-block tree))))
 
-(defn save-tree
+(defn save-tree!
   [page-block tree]
   {:pre [(map? page-block)]}
   (let [ok-handler #(save-tree-aux! page-block tree)

+ 1 - 1
src/main/frontend/modules/outliner/core.cljs

@@ -48,7 +48,7 @@
      db/pull
      block)))
 
-(defn- block-with-timestamps
+(defn block-with-timestamps
   [block]
   (let [updated-at (util/time-ms)
         block (cond->

+ 32 - 4
src/main/frontend/modules/outliner/file.cljs

@@ -16,21 +16,49 @@
 
 (def batch-write-interval 1000)
 
+(def whiteboard-blocks-pull-keys-with-persisted-ids
+  '[:block/properties
+    :block/uuid
+    :block/content
+    :block/format
+    {:block/page      [:block/uuid]}
+    {:block/left      [:block/uuid]}
+    {:block/parent    [:block/uuid]}])
+
+(defn- cleanup-whiteboard-block
+  [block]
+  (if (get-in block [:block/properties :ls-type] false)
+    (dissoc block
+            :db/id
+            :block/uuid ;; shape block uuid is read from properties
+            :block/content
+            :block/format
+            :block/left
+            :block/page
+            :block/parent) ;; these are auto-generated for whiteboard shapes
+    (dissoc block :db/id :block/page)))
+
+
 (defn do-write-file!
   [repo page-db-id]
   (let [page-block (db/pull repo '[*] page-db-id)
         page-db-id (:db/id page-block)
+        whiteboard? (= "whiteboard" (:block/type page-block))
         blocks-count (model/get-page-blocks-count repo page-db-id)]
-    (if (and (> blocks-count 500)
-             (not (state/input-idle? repo :diff 3000))) ; long page
+    (if (or (and (> blocks-count 500)
+                 (not (state/input-idle? repo {:diff 3000}))) ;; long page
+            ;; when this whiteboard page is just being updated
+            (and whiteboard? (not (state/whiteboard-page-idle? repo page-block))))
       (async/put! (state/get-file-write-chan) [repo page-db-id])
-      (let [blocks (model/get-page-blocks-no-cache repo (:block/name page-block))]
+      (let [pull-keys (if whiteboard? whiteboard-blocks-pull-keys-with-persisted-ids '[*])
+            blocks (model/get-page-blocks-no-cache repo (:block/name page-block) {:pull-keys pull-keys})
+            blocks (if whiteboard? (map cleanup-whiteboard-block blocks) blocks)]
         (when-not (and (= 1 (count blocks))
                        (string/blank? (:block/content (first blocks)))
                        (nil? (:block/file page-block)))
           (let [tree (tree/blocks->vec-tree repo blocks (:block/name page-block))]
             (if page-block
-              (file/save-tree page-block tree)
+              (file/save-tree! page-block (if whiteboard? blocks tree))
               (js/console.error (str "can't find page id: " page-db-id)))))))))
 
 (defn write-files!

+ 5 - 3
src/main/frontend/modules/outliner/tree.cljs

@@ -2,7 +2,8 @@
   (:require [frontend.db :as db]
             [frontend.db.model :as model]
             [clojure.string :as string]
-            [frontend.state :as state]))
+            [frontend.state :as state]
+            [logseq.graph-parser.whiteboard :as gp-whiteboard]))
 
 (defprotocol INode
   (-get-id [this])
@@ -28,7 +29,8 @@
   [blocks root]
   (let [id-map (fn [m] {:db/id (:db/id m)})
         root (id-map root)
-        parent-blocks (group-by :block/parent blocks)
+        blocks (remove gp-whiteboard/shape-block? blocks)
+        parent-blocks (group-by :block/parent blocks) ;; exclude whiteboard shapes
         sort-fn (fn [parent]
                   (db/sort-by-left (get parent-blocks parent) parent))
         block-children (fn block-children [parent level]
@@ -39,7 +41,7 @@
                                   (assoc m
                                          :block/level level
                                          :block/children children)))
-                           (sort-fn parent)))]
+                              (sort-fn parent)))]
     (block-children root 1)))
 
 (defn- get-root-and-page

+ 8 - 5
src/main/frontend/modules/shortcut/before.cljs

@@ -1,7 +1,7 @@
 (ns frontend.modules.shortcut.before
-  (:require [frontend.state :as state]
-            [frontend.util :as util]
-            [frontend.mobile.util :as mobile-util]))
+  (:require [frontend.mobile.util :as mobile-util]
+            [frontend.state :as state]
+            [frontend.util :as util]))
 
 ;; before function
 (defn prevent-default-behavior
@@ -32,6 +32,9 @@
 (defn enable-when-not-component-editing!
   [f]
   (fn [e]
-    (when (or (contains? #{:srs :page-histories} (state/get-modal-id))
-              (not (state/block-component-editing?)))
+    (when (and (or (contains? #{:srs :page-histories} (state/get-modal-id))
+                   (not (state/block-component-editing?)))
+               ;; should not enable when in whiteboard mode, but not editing a logseq block
+               (not (and (state/active-tldraw-app)
+                         (not (state/tldraw-editing-logseq-block?)))))
       (f e))))

+ 12 - 0
src/main/frontend/modules/shortcut/config.cljs

@@ -12,6 +12,7 @@
             [frontend.handler.search :as search-handler]
             [frontend.handler.ui :as ui-handler]
             [frontend.handler.plugin :as plugin-handler]
+            [frontend.handler.whiteboard :as whiteboard-handler]
             [frontend.modules.shortcut.dicts :as dicts]
             [frontend.modules.shortcut.before :as m]
             [frontend.state :as state]
@@ -30,6 +31,7 @@
 ;; To add a new entry to this map, first add it here and then
 ;; a description for it in frontend.modules.shortcut.dicts/all-default-keyboard-shortcuts.
 ;; :inactive key is for commands that are not active for a given platform or feature condition
+;; Avoid using single letter shortcuts to allow chords that start with those characters
 (def ^:large-vars/data-var all-default-keyboard-shortcuts
   {:date-picker/complete         {:binding "enter"
                                   :fn      ui-handler/shortcut-complete}
@@ -101,6 +103,9 @@
    :editor/new-line              {:binding "shift+enter"
                                   :fn      editor-handler/keydown-new-line-handler}
 
+   :editor/new-whiteboard        {:binding "n w"
+                                  :fn      #(whiteboard-handler/create-new-whiteboard-and-redirect!)}
+
    :editor/follow-link           {:binding "mod+o"
                                   :fn      editor-handler/follow-link-under-cursor!}
 
@@ -328,6 +333,9 @@
    :go/all-graphs                  {:binding "g shift+g"
                                     :fn      route-handler/redirect-to-all-graphs}
 
+   :go/whiteboards                  {:binding "g w"
+                                     :fn      route-handler/redirect-to-whiteboard-dashboard!}
+
    :go/keyboard-shortcuts          {:binding "g s"
                                     :fn      #(route-handler/redirect! {:to :shortcut-setting})}
 
@@ -537,6 +545,7 @@
                           :go/flashcards
                           :go/graph-view
                           :go/all-graphs
+                          :go/whiteboards
                           :go/keyboard-shortcuts
                           :go/tomorrow
                           :go/next-journal
@@ -551,6 +560,7 @@
                           :editor/open-file-in-default-app
                           :editor/open-file-in-directory
                           :editor/copy-current-file
+                          :editor/new-whiteboard
                           :ui/toggle-wide-mode
                           :ui/select-theme-color
                           :ui/goto-plugins
@@ -603,6 +613,7 @@
     :go/all-pages
     :go/graph-view
     :go/all-graphs
+    :go/whiteboards
     :go/flashcards
     :go/tomorrow
     :go/next-journal
@@ -680,6 +691,7 @@
     :editor/insert-youtube-timestamp
     :editor/open-file-in-default-app
     :editor/open-file-in-directory
+    :editor/new-whiteboard
     :auto-complete/prev
     :auto-complete/next
     :auto-complete/complete

+ 2 - 1
src/main/frontend/modules/shortcut/data_helper.cljs

@@ -93,11 +93,12 @@
 
 (defn decorate-binding [binding]
   (-> (if (string? binding) binding (str/join "+"  binding))
-      (str/replace "mod" (if util/mac? "cmd" "ctrl"))
+      (str/replace "mod" (if util/mac? "" "ctrl"))
       (str/replace "alt" (if util/mac? "opt" "alt"))
       (str/replace "shift+/" "?")
       (str/replace "left" "←")
       (str/replace "right" "→")
+      (str/replace "shift" "⇧")
       (str/replace "open-square-bracket" "[")
       (str/replace "close-square-bracket" "]")
       (str/lower-case)))

+ 2 - 0
src/main/frontend/modules/shortcut/dicts.cljc

@@ -30,6 +30,7 @@
    :editor/delete                "Delete / Delete forwards"
    :editor/new-block             "Create new block"
    :editor/new-line              "New line in current block"
+   :editor/new-whiteboard        "New whiteboard"
    :editor/follow-link           "Follow link under cursor"
    :editor/open-link-in-sidebar  "Open link in sidebar"
    :editor/bold                  "Bold"
@@ -97,6 +98,7 @@
    :command/run                    "Run git command"
    :go/home                        "Go to home"
    :go/all-graphs                  "Go to all graphs"
+   :go/whiteboards                 "Go to whiteboards"
    :go/all-pages                   "Go to all pages"
    :go/graph-view                  "Go to graph view"
    :go/keyboard-shortcuts          "Go to keyboard shortcuts"

+ 14 - 5
src/main/frontend/routes.cljs

@@ -1,15 +1,16 @@
 (ns frontend.routes
   "Defines routes for use with reitit router"
-  (:require [frontend.components.home :as home]
-            [frontend.components.repo :as repo]
-            [frontend.components.file :as file]
+  (:require [frontend.components.file :as file]
+            [frontend.components.home :as home]
+            [frontend.components.journal :as journal]
+            [frontend.components.onboarding.setups :as setups]
             [frontend.components.page :as page]
             [frontend.components.plugins :as plugins]
-            [frontend.components.journal :as journal]
+            [frontend.components.repo :as repo]
             [frontend.components.search :as search]
             [frontend.components.settings :as settings]
             [frontend.components.shortcut :as shortcut]
-            [frontend.components.onboarding.setups :as setups]
+            [frontend.components.whiteboard :as whiteboard]
             [frontend.extensions.zotero :as zotero]))
 
 ;; http://localhost:3000/#?anchor=fn.1
@@ -22,6 +23,14 @@
     {:name :repos
      :view repo/repos}]
 
+   ["/whiteboard/:name"
+    {:name :whiteboard
+     :view whiteboard/whiteboard-route}]
+
+   ["/whiteboards"
+    {:name :whiteboards
+     :view whiteboard/whiteboard-dashboard}]
+
    ["/repo/add"
     {:name :repo-add
      :view setups/picker}]

+ 37 - 0
src/main/frontend/rum.cljs

@@ -97,3 +97,40 @@
          #(rum/set-ref! *mounted false))
        [])
     #(rum/deref *mounted)))
+
+(defn use-bounding-client-rect
+  "Returns the bounding client rect for a given dom node
+   You can manually change the tick value, if you want to force refresh the value, you can manually change the tick value"
+  ([] (use-bounding-client-rect nil))
+  ([tick]
+   (let [[ref set-ref] (rum/use-state nil)
+         [rect set-rect] (rum/use-state nil)]
+     (rum/use-effect!
+      (if ref
+        (fn []
+          (let [update-rect #(set-rect (. ref getBoundingClientRect))
+                updator (fn [entries]
+                          (when (.-contentRect (first (js->clj entries))) (update-rect)))
+                observer (js/ResizeObserver. updator)]
+            (update-rect)
+            (.observe observer ref)
+            #(.disconnect observer)))
+        #())
+      [ref tick])
+     [set-ref rect])))
+
+(defn use-click-outside
+  "Returns a function that can be used to register a callback
+   that will be called when the user clicks outside the given dom node"
+  [handler]
+  (let [[ref set-ref] (rum/use-state nil)]
+    (rum/use-effect!
+     (fn []
+       (let [listener (fn [e]
+                        (when (and ref
+                                   (not (.. ref (contains (.-target e)))))
+                          (handler e)))]
+         (js/document.addEventListener "click" listener)
+         #(.removeEventListener js/document "click" listener)))
+     [ref])
+    set-ref))

+ 56 - 10
src/main/frontend/state.cljs

@@ -3,20 +3,20 @@
   cursors"
   (:require [cljs-bean.core :as bean]
             [cljs.core.async :as async]
-            [clojure.string :as string]
             [cljs.spec.alpha :as s]
+            [clojure.string :as string]
             [dommy.core :as dom]
-            [medley.core :as medley]
             [electron.ipc :as ipc]
+            [frontend.mobile.util :as mobile-util]
             [frontend.storage :as storage]
             [frontend.util :as util]
             [frontend.util.cursor :as cursor]
             [goog.dom :as gdom]
             [goog.object :as gobj]
-            [promesa.core :as p]
-            [rum.core :as rum]
             [logseq.graph-parser.config :as gp-config]
-            [frontend.mobile.util :as mobile-util]))
+            [medley.core :as medley]
+            [promesa.core :as p]
+            [rum.core :as rum]))
 
 ;; Stores main application state
 (defonce ^:large-vars/data-var state
@@ -410,6 +410,13 @@ should be done through this fn in order to get global config and config defaults
       (:journals-directory (get-config repo)))
     "journals"))
 
+(defn get-whiteboards-directory
+  []
+  (or
+   (when-let [repo (get-current-repo)]
+     (:whiteboards-directory (get-config repo)))
+   "whiteboards"))
+
 (defn org-mode-file-link?
   [repo]
   (:org-mode/insert-file-link? (get-config repo)))
@@ -545,6 +552,15 @@ Similar to re-frame subscriptions"
   []
   (sub :feature/enable-sync?))
 
+(defn enable-whiteboards?
+  ([]
+   (enable-whiteboards? (get-current-repo)))
+  ([repo]
+   (and
+    (util/electron?)
+    ((resolve 'frontend.handler.user/alpha-user?)) ;; using resolve to avoid circular dependency
+    (:feature/enable-whiteboards? (sub-config repo)))))
+
 (defn export-heading-to-list?
   []
   (not (false? (:export/heading-to-list? (sub-config)))))
@@ -1391,6 +1407,15 @@ Similar to re-frame subscriptions"
         (set-state! [:plugin/installed-hooks hook-or-all] (disj coll pid))))
     true))
 
+(defn active-tldraw-app
+  []
+  ^js js/window.tln)
+
+(defn tldraw-editing-logseq-block?
+  []
+  (when-let [app (active-tldraw-app)]
+    (and (= 1 (.. app -selectedShapesArray -length))
+         (= (.. app -editingShape) (.. app -selectedShapesArray (at 0))))))
 
 (defn set-graph-syncing?
   [value]
@@ -1440,11 +1465,32 @@ Similar to re-frame subscriptions"
            :or {diff 1000}}]
   (when repo
     (or
-      (when-let [last-time (get-in @state [:editor/last-input-time repo])]
-        (let [now (util/time-ms)]
-          (>= (- now last-time) diff)))
-      ;; not in editing mode
-      (not (get-edit-input-id)))))
+     (when-let [last-time (get-in @state [:editor/last-input-time repo])]
+       (let [now (util/time-ms)]
+         (>= (- now last-time) diff)))
+     ;; not in editing mode
+     ;; Is this a good idea to put whiteboard check here?
+     (not (get-edit-input-id)))))
+
+(defn whiteboard-page-idle?
+  "Check if whiteboard page is idle.
+   - when current tool is select and idle
+     - and whiteboard page is updated longer than 1000 seconds
+   - when current tool is other tool and idle
+     - and whiteboard page is updated longer than 3000 seconds"
+  [repo whiteboard-page & {:keys [select-idle-ms tool-idle-ms]
+                           :or {select-idle-ms 1000
+                                tool-idle-ms 3000}}]
+  (when repo
+    (if-let [tldraw-app (active-tldraw-app)]
+      (let [last-time (:block/updated-at whiteboard-page)
+            now (util/time-ms)
+            ellapsed (- now last-time)
+            select-idle (.. tldraw-app (isIn "select.idle"))
+            tool-idle (.. tldraw-app -selectedTool (isIn "idle"))]
+        (or (and select-idle (>= ellapsed select-idle-ms))
+            (and (not select-idle) tool-idle (>= ellapsed tool-idle-ms))))
+      true)))
 
 (defn set-nfs-refreshing!
   [value]

+ 89 - 67
src/main/frontend/ui.cljs

@@ -1,38 +1,39 @@
 (ns frontend.ui
   "Main ns for reusable components"
-  (:require [clojure.string :as string]
+  (:require ["@logseq/react-tweet-embed" :as react-tweet-embed]
+            ["react-intersection-observer" :as react-intersection-observer]
+            ["react-resize-context" :as Resize]
+            ["react-textarea-autosize" :as TextareaAutosize]
+            ["react-tippy" :as react-tippy]
+            ["react-transition-group" :refer [CSSTransition TransitionGroup]]
+            [camel-snake-kebab.core :as csk]
+            [cljs-bean.core :as bean]
+            [clojure.string :as string]
+            [datascript.core :as d]
+            [electron.ipc :as ipc]
             [frontend.components.svg :as svg]
             [frontend.context.i18n :refer [t]]
+            [frontend.db-mixins :as db-mixins]
             [frontend.handler.notification :as notification]
+            [frontend.handler.plugin :as plugin-handler]
             [frontend.mixins :as mixins]
+            [frontend.mobile.util :as mobile-util]
+            [frontend.modules.shortcut.config :as shortcut-config]
             [frontend.modules.shortcut.core :as shortcut]
+            [frontend.modules.shortcut.data-helper :as shortcut-helper]
             [frontend.rum :as r]
             [frontend.state :as state]
             [frontend.storage :as storage]
             [frontend.ui.date-picker]
             [frontend.util :as util]
             [frontend.util.cursor :as cursor]
-            [frontend.handler.plugin :as plugin-handler]
-            [cljs-bean.core :as bean]
             [goog.dom :as gdom]
-            [frontend.modules.shortcut.config :as shortcut-config]
-            [frontend.modules.shortcut.data-helper :as shortcut-helper]
-            [promesa.core :as p]
+            [goog.functions :refer [debounce]]
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
             [medley.core :as medley]
-            [electron.ipc :as ipc]
-            ["react-resize-context" :as Resize]
-            ["react-textarea-autosize" :as TextareaAutosize]
-            ["react-tippy" :as react-tippy]
-            ["react-transition-group" :refer [CSSTransition TransitionGroup]]
-            ["@logseq/react-tweet-embed" :as react-tweet-embed]
-            ["react-intersection-observer" :as react-intersection-observer]
-            [rum.core :as rum]
-            [camel-snake-kebab.core :as csk]
-            [frontend.db-mixins :as db-mixins]
-            [frontend.mobile.util :as mobile-util]
-            [goog.functions :refer [debounce]]))
+            [promesa.core :as p]
+            [rum.core :as rum]))
 
 (defonce transition-group (r/adapt-class TransitionGroup))
 (defonce css-transition (r/adapt-class CSSTransition))
@@ -207,58 +208,35 @@
          wrapper-children)))
    opts))
 
-(defn button
-  [text & {:keys [background href class intent on-click small? large? title]
-           :or   {small? false large? false}
-           :as   option}]
-  (let [klass (when-not intent ".bg-indigo-600.hover:bg-indigo-700.focus:border-indigo-700.active:bg-indigo-700.text-center")
-        klass (if background (string/replace klass "indigo" background) klass)
-        klass (if small? (str klass ".px-2.py-1") klass)
-        klass (if large? (str klass ".text-base") klass)]
-    [:button.ui__button
-     (merge
-      {:type  "button"
-       :title title
-       :class (str (util/hiccup->class klass) " " class)}
-      (dissoc option :background :class :small? :large?)
-      (when href
-        {:on-click (fn []
-                     (util/open-url href)
-                     (when (fn? on-click) (on-click)))}))
-     text]))
-
 (rum/defc notification-content
   [state content status uid]
   (when (and content status)
-    (let [[color-class svg]
+    (let [svg
           (case status
             :success
-            ["text-gray-900 dark:text-gray-300 "
-             [:svg.h-6.w-6
-              {:stroke "var(--ls-success-color)", :viewBox "0 0 24 24", :fill "none"}
-              [:path
-               {:d               "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
-                :stroke-width    "2"
-                :stroke-linejoin "round"
-                :stroke-linecap  "round"}]]]
+            [:svg.h-6.w-6.text-green-400
+             {:stroke "var(--ls-success-color)", :viewBox "0 0 24 24", :fill "none"}
+             [:path
+              {:d               "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
+               :stroke-width    "2"
+               :stroke-linejoin "round"
+               :stroke-linecap  "round"}]]
             :warning
-            ["text-gray-900 dark:text-gray-300 "
-             [:svg.h-6.w-6
-              {:stroke "var(--ls-warning-color)", :viewBox "0 0 24 24", :fill "none"}
-              [:path
-               {:d               "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
-                :stroke-width    "2"
-                :stroke-linejoin "round"
-                :stroke-linecap  "round"}]]]
-
-            ["text-error"
-             [:svg.h-6.w-6
-              {:view-box "0 0 20 20", :fill "var(--ls-error-color)"}
-              [:path
-               {:clip-rule "evenodd"
-                :d
-                "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
-                :fill-rule "evenodd"}]]])]
+            [:svg.h-6.w-6.text-yellow-500
+             {:stroke "var(--ls-warning-color)", :viewBox "0 0 24 24", :fill "none"}
+             [:path
+              {:d               "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+               :stroke-width    "2"
+               :stroke-linejoin "round"
+               :stroke-linecap  "round"}]]
+
+            [:svg.h-6.w-6.text-red-500
+             {:view-box "0 0 20 20", :fill "var(--ls-error-color)"}
+             [:path
+              {:clip-rule "evenodd"
+               :d
+               "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
+               :fill-rule "evenodd"}]])]
       [:div.ui__notifications-content
        {:style
         (when (or (= state "exiting")
@@ -278,8 +256,7 @@
            [:div.flex-shrink-0
             svg]
            [:div.ml-3.w-0.flex-1
-            [:div.text-sm.leading-5.font-medium.whitespace-pre-line {:style {:margin 0}
-                                                                     :class color-class}
+            [:div.text-sm.leading-5.font-medium.whitespace-pre-line {:style {:margin 0}}
              content]]
            [:div.ml-4.flex-shrink-0.flex
             [:button.inline-flex.text-gray-400.focus:outline-none.focus:text-gray-500.transition.ease-in-out.duration-150.notification-close-button
@@ -954,7 +931,7 @@
    (when-not (string/blank? class)
      (let [^js jsTablerIcons (gobj/get js/window "tablerIcons")]
        (if (or extension? font? (not jsTablerIcons))
-         [:i (merge {:class
+         [:span.ui__icon (merge {:class
                      (util/format
                       (str "%s-" class
                            (when (:class opts)
@@ -969,6 +946,33 @@
               {:class (str "ls-icon-" class)}
               (f (merge {:size 18} (r/map-keys->camel-case opts)))])))))))
 
+(defn button
+  [text & {:keys [background href class intent on-click small? large? title icon]
+           :or   {small? false large? false}
+           :as   option}]
+  (let [klass (when-not intent ".bg-indigo-600.hover:bg-indigo-700.focus:border-indigo-700.active:bg-indigo-700.text-center")
+        klass (if background (string/replace klass "indigo" background) klass)
+        klass (if small? (str klass ".px-2.py-1") klass)
+        klass (if large? (str klass ".text-base") klass)]
+    [:button.ui__button
+     (merge
+      {:type  "button"
+       :title title
+       :class (str (util/hiccup->class klass) " " class)}
+      (dissoc option :background :class :small? :large?)
+      (when href
+        {:on-click (fn []
+                     (util/open-url href)
+                     (when (fn? on-click) (on-click)))}))
+     (when icon (frontend.ui/icon icon {:class "mr-1"}))
+     text]))
+
+(rum/defc type-icon
+  [{:keys [name class title extension?]}]
+  [:.type-icon {:class class
+                :title title}
+   (icon name {:extension? extension?})])
+
 (rum/defc with-shortcut < rum/reactive
   < {:key-fn (fn [key pos] (str "shortcut-" key pos))}
   [shortcut-key position content]
@@ -1045,3 +1049,21 @@
                                                        (set-visible! in-view?))))})
            ref (.-ref inViewState)]
        (lazy-visible-inner visible? content-fn ref)))))
+
+(rum/defc portal
+  ([children]
+   (portal children {:attach-to (fn [] js/document.body)
+                     :prepend? false}))
+  ([children {:keys [attach-to prepend?]}]
+   (let [[portal-anchor set-portal-anchor] (rum/use-state nil)]
+     (rum/use-effect!
+      (fn []
+        (let [div (js/document.createElement "div")
+              attached (or (if (fn? attach-to) (attach-to) attach-to) js/document.body)]
+          (.setAttribute div "data-logseq-portal" (str (d/squuid)))
+          (if prepend? (.prepend attached div) (.append attached div))
+          (set-portal-anchor div)
+          #(.remove div)))
+      [])
+     (when portal-anchor
+       (rum/portal (rum/fragment children) portal-anchor)))))

+ 32 - 0
src/main/frontend/ui.css

@@ -356,3 +356,35 @@ html.is-mobile {
 .ui__icon {
   display: inline-block;
 }
+
+.type-icon {
+  @apply text-xs text-center flex items-center justify-center rounded border mr-2 relative;
+
+  width: 1.5rem;
+  height: 1.5rem;
+  flex-shrink: 0;
+  border-color: var(--ls-primary-background-color);
+  overflow: hidden;
+  color: var(--ls-primary-text-color);
+
+  .ti,
+  .tie {
+    z-index: 1;
+  }
+
+  &:before {
+    @apply block absolute inset-0 ;
+    background: var(--ls-primary-background-color);
+    content: " ";
+  }
+
+  &.highlight {
+    color: var(--ls-selection-text-color);
+    border-color: var(--ls-selection-background-color);
+
+    &:before {
+      opacity: 0.5;
+      background: var(--ls-selection-background-color);
+    }
+  }
+}

+ 4 - 0
src/main/logseq/api.cljs

@@ -936,6 +936,10 @@
   (p/let [_ (el/persist-dbs!)]
          true))
 
+(def ^:export make_asset_url editor-handler/make-asset-url)
+
+(def ^:export set_blocks_id #(editor-handler/set-blocks-id! (map uuid %)))
+
 (defn ^:export __debug_state
   [path]
   (-> (if (string? path)

+ 1 - 0
tailwind.all.css

@@ -9,6 +9,7 @@
 @import "resources/css/shepherd.css";
 @import "resources/css/fonts.css";
 @import "resources/css/excalidraw.min.css";
+@import "tldraw/apps/tldraw-logseq/src/styles.css";
 @import "resources/css/katex.min.css";
 @import "resources/css/codemirror.min.css";
 @import "resources/css/codemirror.solarized.css";

+ 19 - 0
tldraw/.editorconfig

@@ -0,0 +1,19 @@
+
+# https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+max_line_length = 80
+trim_trailing_whitespace = true
+
+[*.md]
+max_line_length = 0
+trim_trailing_whitespace = false
+
+[COMMIT_EDITMSG]
+max_line_length = 0

+ 3 - 0
tldraw/.eslintignore

@@ -0,0 +1,3 @@
+**/node_modules/*
+**/out/*
+**/.next/*

+ 19 - 0
tldraw/.eslintrc

@@ -0,0 +1,19 @@
+{
+  "root": true,
+  "parser": "@typescript-eslint/parser",
+  "plugins": ["@typescript-eslint"],
+  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
+  "ignorePatterns": ["*.js", "*.jsx"],
+  "overrides": [
+    {
+      // enable the rule specifically for TypeScript files
+      "files": ["*.ts", "*.tsx"],
+      "rules": {
+        "@typescript-eslint/explicit-module-boundary-types": "off",
+        "@typescript-eslint/explicit-function-return-type": "off",
+        "@typescript-eslint/no-explicit-any": "off",
+        "@typescript-eslint/camelcase": "off"
+      }
+    }
+  ]
+}

+ 2 - 0
tldraw/.gitattributes

@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto

+ 17 - 0
tldraw/.gitignore

@@ -0,0 +1,17 @@
+node_modules/
+build/
+dist/
+docs/
+.idea/*
+
+.DS_Store
+coverage
+*.log
+
+.vercel
+.next
+apps/www/public/workbox-*
+apps/www/public/worker-*
+apps/www/public/sw.js
+apps/www/public/sw.js.map
+.env

+ 17 - 0
tldraw/.npmignore

@@ -0,0 +1,17 @@
+/.github/
+/.vscode/
+/node_modules/
+/build/
+/tmp/
+.idea/*
+/docs/
+
+coverage
+*.log
+.gitlab-ci.yml
+
+package-lock.json
+/*.tgz
+/tmp*
+/mnt/
+/package/

+ 11 - 0
tldraw/.prettierrc

@@ -0,0 +1,11 @@
+{
+  "trailingComma": "es5",
+  "singleQuote": true,
+  "semi": false,
+  "printWidth": 100,
+  "tabWidth": 2,
+  "useTabs": false,
+  "jsxSingleQuote": false,
+  "jsxBracketSameLine": false,
+  "arrowParens": "avoid"
+}

+ 21 - 0
tldraw/LICENSE.md

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Stephen Ruiz Ltd
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 29 - 0
tldraw/README.md

@@ -0,0 +1,29 @@
+# Developer Notes
+
+## Background
+
+This folder contains the JS codes for a custom build of Tldraw to fit the needs of Logseq, which originates from an abandoned next branch from the author of Tldraw.
+
+## Development
+
+### Prerequisites
+
+Morden JS eco tools like Node.js and yarn.
+
+### Run in dev mode
+
+- install dependencies with `yarn`
+- run dev mode with `yarn dev`, which will start a Vite server at http://127.0.0.1:3031/
+
+Note, the dev mode is a standalone web app running a demo Tldraw app in `tldraw/demo/src/App.jsx`. The Logseq component renderers and handlers are all mocked to make sure Tldraw only functions can be isolatedly developed.
+
+## Other useful commands
+
+- fixing styles: `yarn fix:style`
+- build: `yarn build`
+
+## How it works
+
+### Data flow between Tldraw & Logseq
+
+The data flow between Tldraw & Logseq can be found here: https://whimsical.com/9sdt5j7MabK6DVrxgTZw25

+ 3 - 0
tldraw/apps/tldraw-logseq/README.md

@@ -0,0 +1,3 @@
+# @tldraw/core Simple Example
+
+A (relatively) simple example project for `@tldraw/core`.

+ 23 - 0
tldraw/apps/tldraw-logseq/build.mjs

@@ -0,0 +1,23 @@
+#!/usr/bin/env zx
+/* eslint-disable no-undef */
+import 'zx/globals'
+import fs from 'fs'
+import path from 'path'
+
+// Build with [tsup](https://tsup.egoist.sh)
+await $`tsup`
+
+// Prepare package.json file
+const packageJson = fs.readFileSync('package.json', 'utf8')
+const glob = JSON.parse(packageJson)
+Object.assign(glob, {
+  main: './index.js',
+  module: './index.mjs',
+})
+
+fs.writeFileSync('dist/package.json', JSON.stringify(glob, null, 2))
+
+const dest = path.join(__dirname, '/../../../src/main/frontend/tldraw-logseq.js')
+
+if (fs.existsSync(dest)) fs.unlinkSync(dest)
+fs.linkSync(path.join(__dirname, '/dist/index.js'), dest)

+ 46 - 0
tldraw/apps/tldraw-logseq/package.json

@@ -0,0 +1,46 @@
+{
+  "version": "0.0.0-dev",
+  "name": "@tldraw/logseq",
+  "license": "MIT",
+  "module": "./src/index.ts",
+  "scripts": {
+    "build": "zx build.mjs",
+    "build:packages": "yarn build",
+    "dev": "tsup --watch",
+    "dev:vite": "tsup --watch --sourcemap inline"
+  },
+  "devDependencies": {
+    "@radix-ui/react-context-menu": "^1.0.0",
+    "@radix-ui/react-dropdown-menu": "^1.0.0",
+    "@radix-ui/react-select": "^1.0.0",
+    "@radix-ui/react-separator": "^1.0.0",
+    "@radix-ui/react-switch": "^1.0.0",
+    "@radix-ui/react-toggle": "^1.0.0",
+    "@radix-ui/react-toggle-group": "^1.0.0",
+    "@tldraw/core": "2.0.0-alpha.1",
+    "@tldraw/react": "2.0.0-alpha.1",
+    "@tldraw/vec": "2.0.0-alpha.1",
+    "@types/node": "^17.0.42",
+    "@types/react": "^17.0.0",
+    "@types/react-dom": "^17.0.0",
+    "autoprefixer": "^10.4.7",
+    "concurrently": "^7.2.1",
+    "esbuild": "^0.15.10",
+    "mobx": "^6.6.2",
+    "mobx-react-lite": "^3.4.0",
+    "perfect-freehand": "^1.2.0",
+    "polished": "^4.0.0",
+    "postcss": "^8.4.17",
+    "react": "^17.0.0",
+    "react-dom": "^17.0.0",
+    "rimraf": "3.0.2",
+    "shadow-cljs": "^2.19.5",
+    "tsup": "^6.2.3",
+    "typescript": "^4.8.2",
+    "zx": "^7.1.0"
+  },
+  "peerDependencies": {
+    "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+    "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+  }
+}

+ 3 - 0
tldraw/apps/tldraw-logseq/postcss.config.js

@@ -0,0 +1,3 @@
+module.exports = ctx => ({
+  plugins: [require('autoprefixer')()],
+})

+ 123 - 0
tldraw/apps/tldraw-logseq/src/app.tsx

@@ -0,0 +1,123 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { deepEqual, TLDocumentModel } from '@tldraw/core'
+import {
+  AppCanvas,
+  AppProvider,
+  TLReactCallbacks,
+  TLReactComponents,
+  TLReactToolConstructor,
+} from '@tldraw/react'
+import * as React from 'react'
+import { AppUI } from './components/AppUI'
+import { ContextBar } from './components/ContextBar'
+import { ContextMenu } from './components/ContextMenu'
+import { useDrop } from './hooks/useDrop'
+import { usePaste } from './hooks/usePaste'
+import { useQuickAdd } from './hooks/useQuickAdd'
+import {
+  BoxTool,
+  EllipseTool,
+  HighlighterTool,
+  HTMLTool,
+  LineTool,
+  LogseqPortalTool,
+  NuEraseTool,
+  PencilTool,
+  PolygonTool,
+  shapes,
+  TextTool,
+  YouTubeTool,
+  IFrameTool,
+  type Shape,
+} from './lib'
+import { LogseqContext, type LogseqContextValue } from './lib/logseq-context'
+
+const components: TLReactComponents<Shape> = {
+  ContextBar: ContextBar,
+}
+
+const tools: TLReactToolConstructor<Shape>[] = [
+  BoxTool,
+  // DotTool,
+  EllipseTool,
+  PolygonTool,
+  NuEraseTool,
+  HighlighterTool,
+  LineTool,
+  PencilTool,
+  TextTool,
+  YouTubeTool,
+  IFrameTool,
+  HTMLTool,
+  LogseqPortalTool,
+]
+
+interface LogseqTldrawProps {
+  renderers: LogseqContextValue['renderers']
+  handlers: LogseqContextValue['handlers']
+  model?: TLDocumentModel<Shape>
+  onMount?: TLReactCallbacks<Shape>['onMount']
+  onPersist?: TLReactCallbacks<Shape>['onPersist']
+}
+
+const AppInner = ({
+  onPersist,
+  model,
+  ...rest
+}: Omit<LogseqTldrawProps, 'renderers' | 'handlers'>) => {
+  const onDrop = useDrop()
+  const onPaste = usePaste()
+  const onQuickAdd = useQuickAdd()
+  const ref = React.useRef<HTMLDivElement>(null)
+
+  const onPersistOnDiff: TLReactCallbacks<Shape>['onPersist'] = React.useCallback(
+    (app, info) => {
+      if (!deepEqual(app.serialized, model)) {
+        onPersist?.(app, info)
+      }
+    },
+    [model]
+  )
+
+  return (
+    <AppProvider
+      Shapes={shapes}
+      Tools={tools}
+      onDrop={onDrop}
+      onPaste={onPaste}
+      onCanvasDBClick={onQuickAdd}
+      onPersist={onPersistOnDiff}
+      model={model}
+      {...rest}
+    >
+      <ContextMenu collisionRef={ref}>
+        <div ref={ref} className="logseq-tldraw logseq-tldraw-wrapper">
+          <AppCanvas components={components}>
+            <AppUI />
+          </AppCanvas>
+        </div>
+      </ContextMenu>
+    </AppProvider>
+  )
+}
+
+export const App = function App({ renderers, handlers, ...rest }: LogseqTldrawProps): JSX.Element {
+  const memoRenders: any = React.useMemo(() => {
+    return Object.fromEntries(
+      Object.entries(renderers).map(([key, comp]) => {
+        return [key, React.memo(comp)]
+      })
+    )
+  }, [])
+  const contextValue = {
+    renderers: memoRenders,
+    handlers: handlers,
+  }
+
+  return (
+    <LogseqContext.Provider value={contextValue}>
+      <AppInner {...rest} />
+    </LogseqContext.Provider>
+  )
+}

+ 53 - 0
tldraw/apps/tldraw-logseq/src/components/ActionBar/ActionBar.tsx

@@ -0,0 +1,53 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { useApp } from '@tldraw/react'
+import { observer } from 'mobx-react-lite'
+import * as React from 'react'
+import type { Shape } from '../../lib'
+import { TablerIcon } from '../icons'
+import { Button } from '../Button'
+import { ZoomMenu } from '../ZoomMenu'
+import * as Separator from '@radix-ui/react-separator'
+
+export const ActionBar = observer(function ActionBar(): JSX.Element {
+  const app = useApp<Shape>()
+  const undo = React.useCallback(() => {
+    app.api.undo()
+  }, [app])
+
+  const redo = React.useCallback(() => {
+    app.api.redo()
+  }, [app])
+
+  const zoomIn = React.useCallback(() => {
+    app.api.zoomIn()
+  }, [app])
+
+  const zoomOut = React.useCallback(() => {
+    app.api.zoomOut()
+  }, [app])
+
+  return (
+    <div className="tl-action-bar">
+      <div className="tl-toolbar tl-history-bar">
+        <Button title="Undo" onClick={undo}>
+          <TablerIcon name="arrow-back-up" />
+        </Button>
+        <Button title="Redo" onClick={redo}>
+          <TablerIcon name="arrow-forward-up" />
+        </Button>
+      </div>
+
+      <div className="tl-toolbar tl-zoom-bar">
+        <Button title="Zoom in" onClick={zoomIn} id="tl-zoom-in">
+          <TablerIcon name="plus" />
+        </Button>
+        <Button title="Zoom out" onClick={zoomOut} id="tl-zoom-out">
+          <TablerIcon name="minus" />
+        </Button>
+        <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
+        <ZoomMenu />
+      </div>
+    </div>
+  )
+})

+ 1 - 0
tldraw/apps/tldraw-logseq/src/components/ActionBar/index.ts

@@ -0,0 +1 @@
+export * from './ActionBar'

+ 18 - 0
tldraw/apps/tldraw-logseq/src/components/AppUI.tsx

@@ -0,0 +1,18 @@
+import { observer } from 'mobx-react-lite'
+import { ActionBar } from './ActionBar'
+import { DevTools } from './Devtools'
+import { PrimaryTools } from './PrimaryTools'
+import { StatusBar } from './StatusBar'
+
+const isDev = process.env.NODE_ENV === 'development'
+
+export const AppUI = observer(function AppUI() {
+  return (
+    <>
+      {isDev && <StatusBar />}
+      {isDev && <DevTools />}
+      <PrimaryTools />
+      <ActionBar />
+    </>
+  )
+})

+ 7 - 0
tldraw/apps/tldraw-logseq/src/components/Button/Button.tsx

@@ -0,0 +1,7 @@
+export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
+  children: React.ReactNode
+}
+
+export function Button(props: ButtonProps) {
+  return <button className="tl-button" {...props} />
+}

+ 1 - 0
tldraw/apps/tldraw-logseq/src/components/Button/index.ts

@@ -0,0 +1 @@
+export * from './Button'

+ 65 - 0
tldraw/apps/tldraw-logseq/src/components/ContextBar/ContextBar.tsx

@@ -0,0 +1,65 @@
+import {
+  getContextBarTranslation,
+  HTMLContainer,
+  TLContextBarComponent,
+  useApp,
+} from '@tldraw/react'
+import { observer } from 'mobx-react-lite'
+import * as Separator from '@radix-ui/react-separator'
+
+import * as React from 'react'
+import type { Shape } from '~lib/shapes'
+import { getContextBarActionsForTypes as getContextBarActionsForShapes } from './contextBarActionFactory'
+
+const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets, hidden }) => {
+  const app = useApp()
+  const rSize = React.useRef<[number, number] | null>(null)
+  const rContextBar = React.useRef<HTMLDivElement>(null)
+
+  React.useLayoutEffect(() => {
+    setTimeout(() => {
+      const elm = rContextBar.current
+      if (!elm) return
+      const { offsetWidth, offsetHeight } = elm
+      rSize.current = [offsetWidth, offsetHeight]
+    })
+  })
+
+  React.useLayoutEffect(() => {
+    const elm = rContextBar.current
+    if (!elm) return
+    const size = rSize.current ?? [0, 0]
+    const [x, y] = getContextBarTranslation(size, { ...offsets, bottom: offsets.bottom - 32 })
+    elm.style.setProperty('transform', `translateX(${x}px) translateY(${y}px)`)
+  }, [offsets])
+
+  if (!app) return null
+
+  const Actions = getContextBarActionsForShapes(shapes)
+
+  return (
+    <HTMLContainer centered>
+      {Actions.length > 0 && (
+        <div
+          ref={rContextBar}
+          className="tl-toolbar tl-context-bar"
+          style={{
+            visibility: hidden ? 'hidden' : 'visible',
+            pointerEvents: hidden ? 'none' : 'all',
+          }}
+        >
+          {Actions.map((Action, idx) => (
+            <React.Fragment key={idx}>
+              <Action />
+              {idx < Actions.length - 1 && (
+                <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
+              )}
+            </React.Fragment>
+          ))}
+        </div>
+      )}
+    </HTMLContainer>
+  )
+}
+
+export const ContextBar = observer(_ContextBar)

Неке датотеке нису приказане због велике количине промена