Browse Source

Merge branch 'master' into feat/custom-children-list-style

charlie 2 years ago
parent
commit
2f6377ed4c
57 changed files with 890 additions and 762 deletions
  1. 1 0
      deps/db/src/logseq/db/rules.cljc
  2. 12 0
      e2e-tests/whiteboards.spec.ts
  3. 1 1
      package.json
  4. 4 4
      src/main/frontend/components/block.cljs
  5. 1 1
      src/main/frontend/components/encryption.cljs
  6. 1 2
      src/main/frontend/components/journal.cljs
  7. 1 2
      src/main/frontend/components/query.cljs
  8. 18 10
      src/main/frontend/components/query_table.cljs
  9. 7 1
      src/main/frontend/components/scheduled_deadlines.cljs
  10. 7 0
      src/main/frontend/db/react.cljs
  11. 6 2
      src/main/frontend/db_mixins.cljs
  12. 7 6
      src/main/frontend/fs/capacitor_fs.cljs
  13. 1 0
      src/main/frontend/modules/shortcut/config.cljs
  14. 28 28
      src/main/frontend/ui.cljs
  15. 0 7
      src/main/frontend/util.cljc
  16. 31 13
      src/test/frontend/components/query_table_test.cljs
  17. 17 0
      src/test/frontend/db/query_dsl_test.cljs
  18. 1 12
      src/test/frontend/util_test.cljs
  19. 1 17
      tldraw/apps/tldraw-logseq/src/components/Button/CircleButton.tsx
  20. 53 104
      tldraw/apps/tldraw-logseq/src/components/ContextBar/contextBarActionFactory.tsx
  21. 221 181
      tldraw/apps/tldraw-logseq/src/components/ContextMenu/ContextMenu.tsx
  22. 1 62
      tldraw/apps/tldraw-logseq/src/components/Devtools/Devtools.tsx
  23. 33 23
      tldraw/apps/tldraw-logseq/src/components/GeometryTools/GeometryTools.tsx
  24. 29 8
      tldraw/apps/tldraw-logseq/src/components/PrimaryTools/PrimaryTools.tsx
  25. 9 14
      tldraw/apps/tldraw-logseq/src/components/ToolButton/ToolButton.tsx
  26. 1 1
      tldraw/apps/tldraw-logseq/src/components/ZoomMenu/ZoomMenu.tsx
  27. 1 0
      tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts
  28. 9 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx
  29. 10 2
      tldraw/apps/tldraw-logseq/src/lib/shapes/DotShape.tsx
  30. 10 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/EllipseShape.tsx
  31. 9 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/HTMLShape.tsx
  32. 4 2
      tldraw/apps/tldraw-logseq/src/lib/shapes/HighlighterShape.tsx
  33. 11 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/IFrameShape.tsx
  34. 9 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/ImageShape.tsx
  35. 8 2
      tldraw/apps/tldraw-logseq/src/lib/shapes/LineShape.tsx
  36. 63 64
      tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx
  37. 1 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/PenShape.tsx
  38. 1 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/PencilShape.tsx
  39. 2 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/PolygonShape.tsx
  40. 2 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/TextShape.tsx
  41. 16 11
      tldraw/apps/tldraw-logseq/src/lib/shapes/TweetShape.tsx
  42. 9 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/VideoShape.tsx
  43. 11 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/YouTubeShape.tsx
  44. 10 49
      tldraw/apps/tldraw-logseq/src/styles.css
  45. 30 5
      tldraw/packages/core/src/lib/TLApi/TLApi.ts
  46. 42 3
      tldraw/packages/core/src/lib/TLApp/TLApp.ts
  47. 1 1
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/HoveringSelectionHandleState.ts
  48. 1 1
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/IdleState.ts
  49. 1 0
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingSelectedShapeState.ts
  50. 3 3
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/TranslatingState.ts
  51. 6 0
      tldraw/packages/core/src/types/types.ts
  52. 3 2
      tldraw/packages/react/src/components/Canvas/Canvas.tsx
  53. 8 1
      tldraw/packages/react/src/components/Indicator/Indicator.tsx
  54. 5 1
      tldraw/packages/react/src/components/Shape/Shape.tsx
  55. 107 101
      tldraw/packages/react/src/components/ui/SelectionForeground/SelectionForeground.tsx
  56. 1 0
      tldraw/packages/react/src/lib/TLReactShape.tsx
  57. 4 4
      yarn.lock

+ 1 - 0
deps/db/src/logseq/db/rules.cljc

@@ -134,6 +134,7 @@
      [(str ?val) ?str-val]
      (or [(= ?v ?val)]
          [(contains? ?v ?val)]
+         ;; For integer pages that aren't strings
          [(contains? ?v ?str-val)])]
 
    :page-ref

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

@@ -159,6 +159,18 @@ test('undo the delete action', async ({ page }) => {
   await expect(page.locator('.logseq-tldraw .tl-line-container')).toHaveCount(1)
 })
 
+test('locked elements should not be removed', async ({ page }) => {
+  await page.keyboard.press('Escape')
+  await page.waitForTimeout(1000)
+  await page.click('.logseq-tldraw .tl-box-container:first-of-type')
+  await page.keyboard.press(`${modKey}+l`)
+  await page.keyboard.press('Delete')
+  await page.keyboard.press(`${modKey}+Shift+l`)
+
+  await expect(page.locator('.logseq-tldraw .tl-box-container')).toHaveCount(2)
+
+})
+
 test('move arrow to back', async ({ page }) => {
   await page.keyboard.press('Escape')
   await page.waitForTimeout(1000)

+ 1 - 1
package.json

@@ -90,7 +90,7 @@
         "@capawesome/capacitor-background-task": "^2.0.0",
         "@excalidraw/excalidraw": "0.12.0",
         "@hugotomazi/capacitor-navigation-bar": "^2.0.0",
-        "@logseq/capacitor-file-sync": "0.0.22",
+        "@logseq/capacitor-file-sync": "0.0.24",
         "@logseq/diff-merge": "^0.0.2",
         "@logseq/react-tweet-embed": "1.3.1-1",
         "@sentry/react": "^6.18.2",

+ 4 - 4
src/main/frontend/components/block.cljs

@@ -2932,7 +2932,8 @@
         custom-query? (boolean (:custom-query? config))]
     (if (and (or ref? custom-query?) (not (:ref-query-child? config)))
       (ui/lazy-visible
-        (fn [] (block-container-inner state repo config block)))
+       (fn [] (block-container-inner state repo config block))
+       {:debug-id (str "block-container-ref " (:db/id block))})
       (block-container-inner state repo config block))))
 
 (defn divide-lists
@@ -3360,7 +3361,7 @@
                    "More"
 
                    @*loading?
-                   (ui/lazy-loading-placeholder)
+                   (ui/lazy-loading-placeholder 88)
 
                    :else
                    "")})]))))
@@ -3452,8 +3453,7 @@
                        (rum/with-key
                          (breadcrumb-with-container blocks (assoc config :top-level? top-level?))
                          (:db/id parent)))))
-                 {:debug-id page
-                  :trigger-once? false})])))))]
+                 {:debug-id page})])))))]
 
      (and (:ref? config) (:group-by-page? config))
      [:div.flex.flex-col

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

@@ -209,7 +209,7 @@
           [:span.px-3.flex (ui/icon "key")]
           [:p.dark:text-gray-100
            [:span "Please make sure you "]
-           "remember the password you have set, "
+           "remember the password you have set, as we are unable to reset or retrieve it in case you forget it, "
            [:span "and we recommend you "]
            "keep a secure backup "
            [:span "of the password."]]]

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

@@ -59,8 +59,7 @@
         (blocks-cp repo page)
         (ui/lazy-visible
          (fn [] (blocks-cp repo page))
-         {:trigger-once? false
-          :debug-id (str "journal-blocks " page)}))
+         {:debug-id (str "journal-blocks " page)}))
 
       {})
 

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

@@ -253,5 +253,4 @@
    (ui/lazy-visible
     (fn []
       (custom-query* config q (::query-triggered? state)))
-    {:debug-id q
-     :trigger-once? false})))
+    {:debug-id q})))

+ 18 - 10
src/main/frontend/components/query_table.cljs

@@ -12,6 +12,7 @@
             [frontend.format.block :as block]
             [medley.core :as medley]
             [rum.core :as rum]
+            [logseq.graph-parser.text :as text]
             [frontend.modules.outliner.tree :as tree]))
 
 ;; Util fns
@@ -27,7 +28,7 @@
       (map #(medley/dissoc-in % ks) result)
       result)))
 
-(defn- sort-by-fn [sort-by-column item]
+(defn- sort-by-fn [sort-by-column item {:keys [page?]}]
   (case sort-by-column
     :created-at
     (:block/created-at item)
@@ -36,7 +37,7 @@
     :block
     (:block/content item)
     :page
-    (:block/name item)
+    (if page? (:block/name item) (get-in item [:block/page :block/name]))
     (get-in item [:block/properties sort-by-column])))
 
 (defn- locale-compare
@@ -46,11 +47,12 @@
       (< x y)
       (.localeCompare (str x) (str y) (state/sub :preferred-language) #js {:numeric true})))
 
-(defn- sort-result [result {:keys [sort-by-column sort-desc? sort-nlp-date?]}]
+(defn- sort-result [result {:keys [sort-by-column sort-desc? sort-nlp-date? page?]}]
   (if (some? sort-by-column)
     (let [comp-fn (if sort-desc? #(locale-compare %2 %1) locale-compare)]
       (sort-by (fn [item]
-                 (block/normalize-block (sort-by-fn sort-by-column item) sort-nlp-date?))
+                 (block/normalize-block (sort-by-fn sort-by-column item {:page? page?})
+                                        sort-nlp-date?))
                comp-fn
                result))
     result))
@@ -115,7 +117,7 @@
 (defn- build-column-value
   "Builds a column's tuple value for a query table given a row, column and
   options"
-  [row column {:keys [page? ->elem map-inline config]}]
+  [row column {:keys [page? ->elem map-inline config comma-separated-property?]}]
   (case column
     :page
     [:string (if page?
@@ -143,9 +145,13 @@
     [:string (when-let [updated-at (:block/updated-at row)]
                (date/int->local-time-2 updated-at))]
 
-    [:string (or (get-in row [:block/properties-text-values column])
-                 ;; Fallback to property relationships for page blocks
-                 (get-in row [:block/properties column]))]))
+    [:string (if comma-separated-property?
+               ;; Return original properties since comma properties need to
+               ;; return collections for display purposes
+               (get-in row [:block/properties column])
+               (or (get-in row [:block/properties-text-values column])
+                   ;; Fallback to original properties for page blocks
+                   (get-in row [:block/properties column])))]))
 
 (rum/defcs result-table < rum/reactive
   (rum/local false ::select?)
@@ -165,7 +171,8 @@
           ;; Sort state needs to be in sync between final result and sortable title
           ;; as user needs to know if there result is sorted
           sort-state (get-sort-state current-block)
-          result' (sort-result result sort-state)]
+          result' (sort-result result (assoc sort-state :page? page?))
+          property-separated-by-commas? (partial text/separated-by-commas? (state/get-config))]
       [:div.overflow-x-auto {:on-mouse-down (fn [e] (.stopPropagation e))
                              :style {:width "100%"}
                              :class (when-not page? "query-table")}
@@ -188,7 +195,8 @@
                                                 {:page? page?
                                                  :->elem ->elem
                                                  :map-inline map-inline
-                                                 :config config})]
+                                                 :config config
+                                                 :comma-separated-property? (property-separated-by-commas? column)})]
                   [:td.whitespace-nowrap {:on-mouse-down (fn []
                                                            (reset! *mouse-down? true)
                                                            (reset! select? false))

+ 7 - 1
src/main/frontend/components/scheduled_deadlines.cljs

@@ -16,7 +16,7 @@
        (not (true? (state/scheduled-deadlines-disabled?)))
        (= (string/lower-case page-name) (string/lower-case (date/journal-name)))))
 
-(rum/defc scheduled-and-deadlines < rum/reactive db-mixins/query
+(rum/defc scheduled-and-deadlines-inner < rum/reactive db-mixins/query
   [page-name]
   (let [scheduled-or-deadlines (when (scheduled-or-deadlines? page-name)
                                  (db/get-date-scheduled-or-deadlines (string/capitalize page-name)))]
@@ -33,3 +33,9 @@
                                           {})]
            (content/content page-name {:hiccup ref-hiccup}))]
         {:title-trigger? true})])))
+
+(rum/defc scheduled-and-deadlines
+  [page-name]
+  (ui/lazy-visible
+   (fn [] (scheduled-and-deadlines-inner page-name))
+   {:debug-id "scheduled-and-deadlines"}))

+ 7 - 0
src/main/frontend/db/react.cljs

@@ -51,8 +51,12 @@
 
 (defonce query-state (atom {}))
 
+;; Current dynamic component
 (def ^:dynamic *query-component*)
 
+;; Which reactive queries are triggered by the current component
+(def ^:dynamic *reactive-queries*)
+
 ;; component -> query-key
 (defonce query-components (atom {}))
 
@@ -156,11 +160,14 @@
                 transform-fn identity}} query & inputs]
   {:pre [(s/valid? ::react-query-keys k)]}
   (let [kv? (and (vector? k) (= :kv (first k)))
+        origin-key k
         k (vec (cons repo k))]
     (when-let [db (conn/get-db repo)]
       (let [result-atom (get-query-cached-result k)]
         (when-let [component *query-component*]
           (add-query-component! k component))
+        (when-let [queries *reactive-queries*]
+          (swap! queries conj origin-key))
         (if (and use-cache? result-atom)
           result-atom
           (let [{:keys [result time]} (util/with-time

+ 6 - 2
src/main/frontend/db_mixins.cljs

@@ -3,10 +3,14 @@
   (:require [frontend.db.react :as react]))
 
 (def query
-  {:wrap-render
+  {:init
+   (fn [state]
+     (assoc state :reactive-queries (atom #{})))
+   :wrap-render
    (fn [render-fn]
      (fn [state]
-       (binding [react/*query-component* (:rum/react-component state)]
+       (binding [react/*query-component* (:rum/react-component state)
+                 react/*reactive-queries* (:reactive-queries state)]
          (render-fn state))))
    :will-unmount
    (fn [state]

+ 7 - 6
src/main/frontend/fs/capacitor_fs.cljs

@@ -148,7 +148,7 @@
                                           "")
                           (string/replace (re-pattern (str "(?i)" (gstring/regExpEscape (str "." ext)) "$"))
                                           ""))]
-    (util/safe-path-join repo-dir (str bak-dir "/" relative-path))))
+    (path/path-join repo-dir bak-dir relative-path)))
 
 (defn- truncate-old-versioned-files!
   "reserve the latest 6 version files"
@@ -174,7 +174,8 @@
         dir (case dir
               :backup-dir (get-backup-dir repo-dir path backup-dir ext)
               :version-file-dir (get-backup-dir repo-dir path version-file-dir ext))
-        new-path (util/safe-path-join dir (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) "." ext))]
+        new-path (path/path-join dir (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) "." ext))]
+
     (<write-file-with-utf8 new-path content)
     (truncate-old-versioned-files! dir)))
 
@@ -187,14 +188,14 @@
                                (string/includes? repo-dir divider-schema))
         repo-dir          (if-not dir-schema?
                             (str file-schema divider-schema repo-dir) repo-dir)
-        backup-root       (util/safe-path-join repo-dir backup-dir)
+        backup-root       (path/path-join repo-dir backup-dir)
         backup-dir-parent (util/node-path.dirname file-path)
         backup-dir-parent (string/replace backup-dir-parent repo-dir "")
         backup-dir-name (util/node-path.name file-path)
         file-extname (.extname util/node-path file-path)
-        file-root (util/safe-path-join backup-root backup-dir-parent backup-dir-name)
-        file-path (util/safe-path-join file-root
-                                       (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) file-extname))]
+        file-root (path/path-join backup-root backup-dir-parent backup-dir-name)
+        file-path (path/path-join file-root
+                                  (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) file-extname))]
     (<write-file-with-utf8 file-path content)
     (truncate-old-versioned-files! file-root)))
 

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

@@ -677,6 +677,7 @@
    [:editor/bold
     :editor/insert-link
     :editor/italics
+    :editor/strike-through
     :editor/highlight]
 
    :shortcut.category/navigating

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

@@ -1065,8 +1065,8 @@
    (progress-bar width)])
 
 (rum/defc lazy-loading-placeholder
-  []
-  [:div.shadow.rounded-md.p-4.w-full.mx-auto.mb-5.fade-in {:style {:height 88}}
+  [height]
+  [:div.shadow.rounded-md.p-4.w-full.mx-auto.mb-5.fade-in {:style {:height height}}
    [:div.animate-pulse.flex.space-x-4
     [:div.flex-1.space-y-3.py-1
      [:div.h-2.bg-base-4.rounded]
@@ -1076,37 +1076,37 @@
        [:div.h-2.bg-base-4.rounded.col-span-1]]
       [:div.h-2.bg-base-4.rounded]]]]])
 
-(rum/defcs lazy-visible-inner
-  [state visible? content-fn ref]
-  [:div.lazy-visibility
-   {:ref ref
-    :style {:min-height 24}}
-   (if visible?
-     (when (fn? content-fn)
-       [:div.fade-enter
-        {:ref #(when-let [^js cls (and % (.-classList %))]
-                 (.add cls "fade-enter-active"))}
-        (content-fn)])
-     (lazy-loading-placeholder))])
+(rum/defc lazy-visible-inner
+  [visible? content-fn ref]
+  (let [[set-ref rect] (r/use-bounding-client-rect)
+        placeholder-height (or (when rect (.-height rect)) 88)]
+    [:div.lazy-visibility {:ref ref}
+     [:div {:ref set-ref}
+      (if visible?
+        (when (fn? content-fn)
+          [:div.fade-enter
+           {:ref #(when-let [^js cls (and % (.-classList %))]
+                    (.add cls "fade-enter-active"))}
+           (content-fn)])
+        (lazy-loading-placeholder placeholder-height))]]))
 
 (rum/defc lazy-visible
   ([content-fn]
    (lazy-visible content-fn nil))
   ([content-fn {:keys [trigger-once? _debug-id]
-                :or {trigger-once? true}}]
-   (if (or (util/mobile?) (mobile-util/native-platform?))
-     (content-fn)
-     (let [[visible? set-visible!] (rum/use-state false)
-           inViewState (useInView #js {:rootMargin "100px"
-                                       :triggerOnce trigger-once?
-                                       :onChange (fn [in-view? entry]
-                                                   (let [self-top (.-top (.-boundingClientRect entry))]
-                                                     (when (or (and (not visible?) in-view?)
-                                                               ;; hide only the components below the current top for better ux
-                                                               (and visible? (not in-view?) (> self-top 0)))
-                                                       (set-visible! in-view?))))})
-           ref (.-ref inViewState)]
-       (lazy-visible-inner visible? content-fn ref)))))
+                :or {trigger-once? false}}]
+   (let [[visible? set-visible!] (rum/use-state false)
+         root-margin 100
+         inViewState (useInView #js {:rootMargin (str root-margin "px")
+                                     :triggerOnce trigger-once?
+                                     :onChange (fn [in-view? entry]
+                                                 (let [self-top (.-top (.-boundingClientRect entry))]
+                                                   (when (or (and (not visible?) in-view?)
+                                                             ;; hide only the components below the current top for better ux
+                                                             (and visible? (not in-view?) (> self-top root-margin)))
+                                                     (set-visible! in-view?))))})
+         ref (.-ref inViewState)]
+     (lazy-visible-inner visible? content-fn ref))))
 
 (rum/defc portal
   ([children]

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

@@ -528,13 +528,6 @@
   (if (string? s)
     (string/lower-case s) s))
 
-#?(:cljs
-   (defn safe-path-join [prefix & paths]
-     (let [path (apply node-path.join (cons prefix paths))]
-       (if (and (electron?) (gstring/caseInsensitiveStartsWith path "file://"))
-         (gp-util/safe-decode-uri-component (subs path 7))
-         path))))
-
 (defn trim-safe
   [s]
   (when s

+ 31 - 13
src/test/frontend/components/query_table_test.cljs

@@ -16,11 +16,11 @@
   (when-let [result (query-dsl/query test-helper/test-db s)]
     (map first (deref result))))
 
-(deftest sort-result
+(deftest sort-result-for-standard-columns
   ;; Define since it's not defined
   (state/set-preferred-language! "en")
 
-  (testing "sort by block content"
+  (testing "sort by block column"
     (are [sort-state result sorted-result]
          (= (mapv #(hash-map :block/content %) sorted-result)
             (#'query-table/sort-result (mapv #(hash-map :block/content %) result) sort-state))
@@ -30,6 +30,24 @@
          {:sort-desc? false :sort-by-column :block}
          ["abc" "cde"] ["abc" "cde"]))
 
+  (testing "sort by page column"
+    (are [sort-options result sorted-result]
+         (= sorted-result
+            (#'query-table/sort-result result sort-options))
+         ;; on page queries
+         {:sort-desc? true :sort-by-column :page :page? true}
+         (map #(hash-map :block/name %) ["abc" "cde"])
+         (map #(hash-map :block/name %) ["cde" "abc"])
+
+         ;; on block queries
+         {:sort-desc? true :sort-by-column :page :page? false}
+         (map #(hash-map :block/page {:block/name %}) ["abc" "cde"])
+         (map #(hash-map :block/page {:block/name %}) ["cde" "abc"]))))
+
+(deftest sort-result-for-property-columns
+  ;; Define since it's not defined
+  (state/set-preferred-language! "en")
+
   (testing "sort by integer block property"
     (are [sort-state result sorted-result]
          (= (mapv #(hash-map :block/properties %) sorted-result)
@@ -86,17 +104,17 @@
          [{:title 1.1} {:title 1.2} {:title 1.11}]
          [{:title 1.1} {:title 1.11} {:title 1.2}]))
 
-   (testing "sort by semver-style string block property"
-     (are [sort-state result sorted-result]
-          (= (mapv #(hash-map :block/properties %) sorted-result)
-             (#'query-table/sort-result (mapv #(hash-map :block/properties %) result) sort-state))
-          {:sort-desc? true :sort-by-column :title}
-          [{:title "1.1.0"} {:title "1.2.0"} {:title "1.11.0"} {:title "1.1.1"} {:title "1.11.1"} {:title "1.2.1"}]
-          [{:title "1.11.1"} {:title "1.11.0"} {:title "1.2.1"} {:title "1.2.0"} {:title "1.1.1"} {:title "1.1.0"}]
-
-          {:sort-desc? false :sort-by-column :title}
-          [{:title "1.1.0"} {:title "1.2.0"} {:title "1.11.0"} {:title "1.1.1"} {:title "1.11.1"} {:title "1.2.1"}]
-          [{:title "1.1.0"} {:title "1.1.1"} {:title "1.2.0"} {:title "1.2.1"} {:title "1.11.0"} {:title "1.11.1"}]))
+  (testing "sort by semver-style string block property"
+    (are [sort-state result sorted-result]
+         (= (mapv #(hash-map :block/properties %) sorted-result)
+            (#'query-table/sort-result (mapv #(hash-map :block/properties %) result) sort-state))
+         {:sort-desc? true :sort-by-column :title}
+         [{:title "1.1.0"} {:title "1.2.0"} {:title "1.11.0"} {:title "1.1.1"} {:title "1.11.1"} {:title "1.2.1"}]
+         [{:title "1.11.1"} {:title "1.11.0"} {:title "1.2.1"} {:title "1.2.0"} {:title "1.1.1"} {:title "1.1.0"}]
+
+         {:sort-desc? false :sort-by-column :title}
+         [{:title "1.1.0"} {:title "1.2.0"} {:title "1.11.0"} {:title "1.1.1"} {:title "1.11.1"} {:title "1.2.1"}]
+         [{:title "1.1.0"} {:title "1.1.1"} {:title "1.2.0"} {:title "1.2.1"} {:title "1.11.0"} {:title "1.11.1"}]))
 
   (testing "sort by string block property for specific locale"
     (state/set-preferred-language! "zh-CN")

+ 17 - 0
src/test/frontend/db/query_dsl_test.cljs

@@ -1,7 +1,9 @@
 (ns frontend.db.query-dsl-test
   (:require [cljs.test :refer [are deftest testing use-fixtures is]]
             [clojure.string :as str]
+            [logseq.graph-parser.util.page-ref :as page-ref]
             [frontend.db :as db]
+            [frontend.util :as util]
             [frontend.db.query-dsl :as query-dsl]
             [frontend.test.helper :as test-helper :include-macros true :refer [load-test-files]]))
 
@@ -142,6 +144,21 @@ prop-d:: nada"}])
     (test-helper/with-config {}
       (block-property-queries-test))))
 
+(deftest block-property-query-performance
+  (let [pages (->> (repeat 10 {:tags ["tag1" "tag2"]})
+                   (map-indexed (fn [idx {:keys [tags]}]
+                                  {:file/path (str "pages/page" idx ".md")
+                                   :file/content (if (seq tags)
+                                                   (str "tags:: " (str/join ", " (map page-ref/->page-ref tags)))
+                                                   "")})))
+        _ (load-test-files pages)
+        {:keys [result time]}
+        (util/with-time (dsl-query "(and (property tags tag1) (property tags tag2))"))]
+    ;; Specific number isn't as important as ensuring query doesn't take orders
+    ;; of magnitude longer
+    (is (> 25.0 time) "multi property query perf is reasonable")
+    (is (= 10 (count result)))))
+
 (defn- page-property-queries-test
   []
   (load-test-files [{:file/path "pages/page1.md"

+ 1 - 12
src/test/frontend/util_test.cljs

@@ -22,17 +22,6 @@
     (is (= 2 (util/safe-inc-current-pos-from-start "abcde" 1)))
     (is (= 1 (util/safe-inc-current-pos-from-start "中文" 0)))))
 
-(deftest test-safe-path-join
-  (testing "safe path join with custom schema"
-    (is (= (util/node-path.join "a/b" "c/d.md") "a/b/c/d.md"))
-    (is (= (util/node-path.join "a/b/c" "../../d.md") "a/d.md"))
-    (is (= (util/node-path.join "file:///a/b" "c/d.md") "file:///a/b/c/d.md"))
-    (is (= (util/node-path.join "file:///a/b" "../d.md") "file:///a/d.md"))
-    (is (= (util/node-path.join "file:///a   a2/b" "c/d.md") "file:///a   a2/b/c/d.md"))
-    (is (= (util/node-path.join "C:/a2/b" "c/d.md") "C:/a2/b/c/d.md"))
-    (is (= (util/node-path.join "content://a/b" "../d.md") "content://a/d.md"))
-    (is (= (util/node-path.join "https://logseq.com/a/b" "c/d.md") "https://logseq.com/a/b/c/d.md"))))
-
 (deftest test-memoize-last
   (testing "memoize-last add test"
     (let [actual-ops (atom 0)
@@ -120,4 +109,4 @@
     (is (= (config/ext-of-video? "file.mp3") false))
     (is (= (config/ext-of-image? "file.svg") true))
     (is (= (config/ext-of-image? "a.file.png") true))
-    (is (= (config/ext-of-image? "file.tiff") false))))
+    (is (= (config/ext-of-image? "file.tiff") false))))

+ 1 - 17
tldraw/apps/tldraw-logseq/src/components/Button/CircleButton.tsx

@@ -1,11 +1,8 @@
-import React from 'react'
 import { TablerIcon } from '../icons'
 
 export const CircleButton = ({
-  active,
   style,
   icon,
-  otherIcon,
   onClick,
 }: {
   active?: boolean
@@ -14,27 +11,14 @@ export const CircleButton = ({
   otherIcon?: string
   onClick: () => void
 }) => {
-  const [recentlyChanged, setRecentlyChanged] = React.useState(false)
-
-  React.useEffect(() => {
-    setRecentlyChanged(true)
-    const timer = setTimeout(() => {
-      setRecentlyChanged(false)
-    }, 500)
-    return () => clearTimeout(timer)
-  }, [active])
-
   return (
     <button
-      data-active={active}
-      data-recently-changed={recentlyChanged}
       data-html2canvas-ignore="true"
       style={style}
       className="tl-circle-button"
       onPointerDown={onClick}
     >
-      <div className="tl-circle-button-icons-wrapper" data-icons-count={otherIcon ? 2 : 1}>
-        {otherIcon && <TablerIcon name={otherIcon} />}
+      <div className="tl-circle-button-icons-wrapper">
         <TablerIcon name={icon} />
       </div>
     </button>

+ 53 - 104
tldraw/apps/tldraw-logseq/src/components/ContextBar/contextBarActionFactory.tsx

@@ -28,11 +28,13 @@ import {
   type ToggleGroupInputOption,
 } from '../inputs/ToggleGroupInput'
 import { ToggleInput } from '../inputs/ToggleInput'
+import { GeometryTools } from '../GeometryTools'
 import { LogseqContext } from '../../lib/logseq-context'
 
 export const contextBarActionTypes = [
   // Order matters
-  'Edit',
+  'LogseqPortalViewMode',
+  'Geometry',
   'AutoResizing',
   'Swatch',
   'NoFill',
@@ -42,14 +44,12 @@ export const contextBarActionTypes = [
   'YoutubeLink',
   'TwitterLink',
   'IFrameSource',
-  'LogseqPortalViewMode',
   'ArrowMode',
   'Links',
 ] as const
 
 type ContextBarActionType = typeof contextBarActionTypes[number]
 const singleShapeActions: ContextBarActionType[] = [
-  'Edit',
   'YoutubeLink',
   'TwitterLink',
   'IFrameSource',
@@ -63,7 +63,6 @@ type ShapeType = Shape['props']['type']
 export const shapeMapping: Record<ShapeType, ContextBarActionType[]> = {
   'logseq-portal': [
     'Swatch',
-    'Edit',
     'LogseqPortalViewMode',
     'ScaleLevel',
     'AutoResizing',
@@ -72,13 +71,13 @@ export const shapeMapping: Record<ShapeType, ContextBarActionType[]> = {
   youtube: ['YoutubeLink', 'Links'],
   tweet: ['TwitterLink', 'Links'],
   iframe: ['IFrameSource', 'Links'],
-  box: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
-  ellipse: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
-  polygon: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
-  line: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'ArrowMode', 'Links'],
+  box: ['Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
+  ellipse: ['Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
+  polygon: ['Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
+  line: ['TextStyle', 'Swatch', 'ScaleLevel', 'ArrowMode', 'Links'],
   pencil: ['Swatch', 'Links', 'ScaleLevel'],
   highlighter: ['Swatch', 'Links', 'ScaleLevel'],
-  text: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'AutoResizing', 'Links'],
+  text: ['TextStyle', 'Swatch', 'ScaleLevel', 'AutoResizing', 'Links'],
   html: ['ScaleLevel', 'AutoResizing', 'Links'],
   image: ['Links'],
   video: ['Links'],
@@ -90,64 +89,15 @@ export const withFillShapes = Object.entries(shapeMapping)
   })
   .map(([key]) => key) as ShapeType[]
 
-function filterShapeByAction<S extends Shape>(shapes: Shape[], type: ContextBarActionType): S[] {
-  return shapes.filter(shape => shapeMapping[shape.props.type]?.includes(type)) as S[]
-}
-
-const EditAction = observer(() => {
-  const {
-    handlers: { isWhiteboardPage, redirectToPage, getRedirectPageName, insertFirstPageBlock },
-  } = React.useContext(LogseqContext)
-
+function filterShapeByAction<S extends Shape>(type: ContextBarActionType) {
   const app = useApp<Shape>()
-  const shape = filterShapeByAction(app.selectedShapesArray, 'Edit')[0]
-
-  const iconName =
-    ('label' in shape.props && shape.props.label) || ('text' in shape.props && shape.props.text)
-      ? 'forms'
-      : 'text'
-
-  return (
-    <Button
-      type="button"
-      tooltip="Edit"
-      onClick={() => {
-        app.api.editShape(shape)
-        if (shape.props.type === 'logseq-portal') {
-          let uuid = shape.props.pageId
-          if (shape.props.blockType === 'P') {
-            if (isWhiteboardPage(uuid)) {
-              redirectToPage(uuid)
-            }
-
-            const pageId = getRedirectPageName(shape.props.pageId)
-            let pageBlocksTree = window.logseq?.api?.get_page_blocks_tree?.(pageId)
-
-            if (pageBlocksTree?.length === 0) {
-              insertFirstPageBlock(pageId)
-              pageBlocksTree = window.logseq?.api?.get_page_blocks_tree?.(pageId)
-            }
-
-            const firstNonePropertyBlock =
-              pageBlocksTree?.find(b => !('propertiesOrder' in b)) || pageBlocksTree[0]
-
-            uuid = firstNonePropertyBlock?.uuid
-          }
-          window.logseq?.api?.edit_block?.(uuid)
-        }
-      }}
-    >
-      <TablerIcon name={iconName} />
-    </Button>
-  )
-})
+  const unlockedSelectedShapes = app.selectedShapesArray.filter(s => !s.props.isLocked)
+  return unlockedSelectedShapes.filter(shape => shapeMapping[shape.props.type]?.includes(type))
+}
 
 const AutoResizingAction = observer(() => {
   const app = useApp<Shape>()
-  const shapes = filterShapeByAction<LogseqPortalShape | TextShape | HTMLShape>(
-    app.selectedShapesArray,
-    'AutoResizing'
-  )
+  const shapes = filterShapeByAction<LogseqPortalShape | TextShape | HTMLShape>('AutoResizing')
 
   const pressed = shapes.every(s => s.props.isAutoResizing)
 
@@ -177,36 +127,23 @@ const AutoResizingAction = observer(() => {
 
 const LogseqPortalViewModeAction = observer(() => {
   const app = useApp<Shape>()
-  const shapes = filterShapeByAction<LogseqPortalShape>(
-    app.selectedShapesArray,
-    'LogseqPortalViewMode'
-  )
+  const shapes = filterShapeByAction<LogseqPortalShape>('LogseqPortalViewMode')
 
   const collapsed = shapes.every(s => s.collapsed)
-  const ViewModeOptions: ToggleGroupInputOption[] = [
-    {
-      value: '1',
-      icon: 'object-compact',
-      tooltip: 'Collapse',
-    },
-    {
-      value: '0',
-      icon: 'object-expanded',
-      tooltip: 'Expand',
-    },
-  ]
+  if (!collapsed && !shapes.every(s => !s.collapsed)) {
+    return null
+  }
+
   return (
-    <ToggleGroupInput
-      title="View Mode"
-      options={ViewModeOptions}
-      value={collapsed ? '1' : '0'}
-      onValueChange={v => {
-        shapes.forEach(shape => {
-          shape.toggleCollapsed()
-        })
-        app.persist()
-      }}
-    />
+    <ToggleInput
+      tooltip={collapsed ? 'Expand' : 'Collapse'}
+      toggle={shapes.every(s => s.props.type === 'logseq-portal')}
+      className="tl-button"
+      pressed={collapsed}
+      onPressedChange={() => app.api.setCollapsed(!collapsed) }
+    >
+      <TablerIcon name={collapsed ? 'object-expanded' : 'object-compact'} />
+    </ToggleInput>
   )
 })
 
@@ -215,8 +152,7 @@ const ScaleLevelAction = observer(() => {
     handlers: { isMobile },
   } = React.useContext(LogseqContext)
 
-  const app = useApp<Shape>()
-  const shapes = filterShapeByAction<LogseqPortalShape>(app.selectedShapesArray, 'ScaleLevel')
+  const shapes = filterShapeByAction<LogseqPortalShape>('ScaleLevel')
   const scaleLevel = new Set(shapes.map(s => s.scaleLevel)).size > 1 ? '' : shapes[0].scaleLevel
 
   return <ScaleInput scaleLevel={scaleLevel} compact={isMobile()} />
@@ -224,7 +160,7 @@ const ScaleLevelAction = observer(() => {
 
 const IFrameSourceAction = observer(() => {
   const app = useApp<Shape>()
-  const shape = filterShapeByAction<IFrameShape>(app.selectedShapesArray, 'IFrameSource')[0]
+  const shape = filterShapeByAction<IFrameShape>('IFrameSource')[0]
 
   const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
     shape.onIFrameSourceChange(e.target.value.trim().toLowerCase())
@@ -255,7 +191,7 @@ const IFrameSourceAction = observer(() => {
 
 const YoutubeLinkAction = observer(() => {
   const app = useApp<Shape>()
-  const shape = filterShapeByAction<YouTubeShape>(app.selectedShapesArray, 'YoutubeLink')[0]
+  const shape = filterShapeByAction<YouTubeShape>('YoutubeLink')[0]
   const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
     shape.onYoutubeLinkChange(e.target.value)
     app.persist()
@@ -282,7 +218,7 @@ const YoutubeLinkAction = observer(() => {
 
 const TwitterLinkAction = observer(() => {
   const app = useApp<Shape>()
-  const shape = filterShapeByAction<TweetShape>(app.selectedShapesArray, 'TwitterLink')[0]
+  const shape = filterShapeByAction<TweetShape>('TwitterLink')[0]
   const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
     shape.onTwitterLinkChange(e.target.value)
     app.persist()
@@ -309,10 +245,7 @@ const TwitterLinkAction = observer(() => {
 
 const NoFillAction = observer(() => {
   const app = useApp<Shape>()
-  const shapes = filterShapeByAction<BoxShape | PolygonShape | EllipseShape>(
-    app.selectedShapesArray,
-    'NoFill'
-  )
+  const shapes = filterShapeByAction<BoxShape | PolygonShape | EllipseShape>('NoFill')
   const handleChange = React.useCallback((v: boolean) => {
     shapes.forEach(s => s.update({ noFill: v }))
     app.persist()
@@ -337,7 +270,7 @@ const SwatchAction = observer(() => {
   // Placeholder
   const shapes = filterShapeByAction<
     BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape | TextShape
-  >(app.selectedShapesArray, 'Swatch')
+  >('Swatch')
 
   const handleSetColor = React.useCallback((color: string) => {
     shapes.forEach(s => {
@@ -365,11 +298,27 @@ const SwatchAction = observer(() => {
   )
 })
 
+const GeometryAction = observer(() => {
+  const app = useApp<Shape>()
+
+  const handleSetGeometry = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
+    const type = e.currentTarget.dataset.tool
+    app.api.convertShapes(type)
+  }, [])
+
+  return (
+    <GeometryTools
+      popoverSide="top"
+      chevron={false}
+      setGeometry={handleSetGeometry}/>
+  )
+})
+
 const StrokeTypeAction = observer(() => {
   const app = useApp<Shape>()
   const shapes = filterShapeByAction<
     BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape
-  >(app.selectedShapesArray, 'StrokeType')
+  >('StrokeType')
 
   const StrokeTypeOptions: ToggleGroupInputOption[] = [
     {
@@ -409,7 +358,7 @@ const StrokeTypeAction = observer(() => {
 
 const ArrowModeAction = observer(() => {
   const app = useApp<Shape>()
-  const shapes = filterShapeByAction<LineShape>(app.selectedShapesArray, 'ArrowMode')
+  const shapes = filterShapeByAction<LineShape>('ArrowMode')
 
   const StrokeTypeOptions: ToggleGroupInputOption[] = [
     {
@@ -453,7 +402,7 @@ const ArrowModeAction = observer(() => {
 
 const TextStyleAction = observer(() => {
   const app = useApp<Shape>()
-  const shapes = filterShapeByAction<TextShape>(app.selectedShapesArray, 'TextStyle')
+  const shapes = filterShapeByAction<TextShape>('TextStyle')
 
   const bold = shapes.every(s => s.props.fontWeight > 500)
   const italic = shapes.every(s => s.props.italic)
@@ -517,7 +466,7 @@ const LinksAction = observer(() => {
   )
 })
 
-contextBarActionMapping.set('Edit', EditAction)
+contextBarActionMapping.set('Geometry', GeometryAction)
 contextBarActionMapping.set('AutoResizing', AutoResizingAction)
 contextBarActionMapping.set('LogseqPortalViewMode', LogseqPortalViewModeAction)
 contextBarActionMapping.set('ScaleLevel', ScaleLevelAction)

+ 221 - 181
tldraw/apps/tldraw-logseq/src/components/ContextMenu/ContextMenu.tsx

@@ -56,77 +56,81 @@ export const ContextMenu = observer(function ContextMenu({
         tabIndex={-1}
       >
         <div>
-          {app.selectedShapes?.size > 1 && !app.readOnly && (
-            <>
-              <ReactContextMenu.Item>
-                <div className="tl-menu-button-row pb-0">
-                  <Button
-                    tooltip="Align left"
-                    onClick={() => runAndTransition(() => app.align(AlignType.Left))}
-                  >
-                    <TablerIcon name="layout-align-left" />
-                  </Button>
-                  <Button
-                    tooltip="Align center horizontally"
-                    onClick={() => runAndTransition(() => app.align(AlignType.CenterHorizontal))}
-                  >
-                    <TablerIcon name="layout-align-center" />
-                  </Button>
-                  <Button
-                    tooltip="Align right"
-                    onClick={() => runAndTransition(() => app.align(AlignType.Right))}
-                  >
-                    <TablerIcon name="layout-align-right" />
-                  </Button>
-                  <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
-                  <Button
-                    tooltip="Distribute horizontally"
-                    onClick={() =>
-                      runAndTransition(() => app.distribute(DistributeType.Horizontal))
-                    }
-                  >
-                    <TablerIcon name="layout-distribute-vertical" />
-                  </Button>
-                </div>
-                <div className="tl-menu-button-row pt-0">
-                  <Button
-                    tooltip="Align top"
-                    onClick={() => runAndTransition(() => app.align(AlignType.Top))}
-                  >
-                    <TablerIcon name="layout-align-top" />
-                  </Button>
-                  <Button
-                    tooltip="Align center vertically"
-                    onClick={() => runAndTransition(() => app.align(AlignType.CenterVertical))}
-                  >
-                    <TablerIcon name="layout-align-middle" />
-                  </Button>
-                  <Button
-                    tooltip="Align bottom"
-                    onClick={() => runAndTransition(() => app.align(AlignType.Bottom))}
-                  >
-                    <TablerIcon name="layout-align-bottom" />
-                  </Button>
-                  <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
-                  <Button
-                    tooltip="Distribute vertically"
-                    onClick={() => runAndTransition(() => app.distribute(DistributeType.Vertical))}
-                  >
-                    <TablerIcon name="layout-distribute-horizontal" />
-                  </Button>
-                </div>
-              </ReactContextMenu.Item>
-              <ReactContextMenu.Separator className="menu-separator" />
-              <ReactContextMenu.Item
-                className="tl-menu-item"
-                onClick={() => runAndTransition(app.packIntoRectangle)}
-              >
-                <TablerIcon className="tl-menu-icon" name="layout-grid" />
-                Pack into rectangle
-              </ReactContextMenu.Item>
-              <ReactContextMenu.Separator className="menu-separator" />
-            </>
-          )}
+          {app.selectedShapes?.size > 1 &&
+            !app.readOnly &&
+            app.selectedShapesArray?.some(s => !s.props.isLocked) && (
+              <>
+                <ReactContextMenu.Item>
+                  <div className="tl-menu-button-row pb-0">
+                    <Button
+                      tooltip="Align left"
+                      onClick={() => runAndTransition(() => app.align(AlignType.Left))}
+                    >
+                      <TablerIcon name="layout-align-left" />
+                    </Button>
+                    <Button
+                      tooltip="Align center horizontally"
+                      onClick={() => runAndTransition(() => app.align(AlignType.CenterHorizontal))}
+                    >
+                      <TablerIcon name="layout-align-center" />
+                    </Button>
+                    <Button
+                      tooltip="Align right"
+                      onClick={() => runAndTransition(() => app.align(AlignType.Right))}
+                    >
+                      <TablerIcon name="layout-align-right" />
+                    </Button>
+                    <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
+                    <Button
+                      tooltip="Distribute horizontally"
+                      onClick={() =>
+                        runAndTransition(() => app.distribute(DistributeType.Horizontal))
+                      }
+                    >
+                      <TablerIcon name="layout-distribute-vertical" />
+                    </Button>
+                  </div>
+                  <div className="tl-menu-button-row pt-0">
+                    <Button
+                      tooltip="Align top"
+                      onClick={() => runAndTransition(() => app.align(AlignType.Top))}
+                    >
+                      <TablerIcon name="layout-align-top" />
+                    </Button>
+                    <Button
+                      tooltip="Align center vertically"
+                      onClick={() => runAndTransition(() => app.align(AlignType.CenterVertical))}
+                    >
+                      <TablerIcon name="layout-align-middle" />
+                    </Button>
+                    <Button
+                      tooltip="Align bottom"
+                      onClick={() => runAndTransition(() => app.align(AlignType.Bottom))}
+                    >
+                      <TablerIcon name="layout-align-bottom" />
+                    </Button>
+                    <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
+                    <Button
+                      tooltip="Distribute vertically"
+                      onClick={() =>
+                        runAndTransition(() => app.distribute(DistributeType.Vertical))
+                      }
+                    >
+                      <TablerIcon name="layout-distribute-horizontal" />
+                    </Button>
+                  </div>
+                </ReactContextMenu.Item>
+                <ReactContextMenu.Separator className="menu-separator" />
+                <ReactContextMenu.Item
+                  className="tl-menu-item"
+                  onClick={() => runAndTransition(app.packIntoRectangle)}
+                >
+                  <TablerIcon className="tl-menu-icon" name="layout-grid" />
+                  Pack into rectangle
+                </ReactContextMenu.Item>
+                <ReactContextMenu.Separator className="menu-separator" />
+              </>
+            )}
           {app.selectedShapes?.size > 0 && (
             <>
               <ReactContextMenu.Item
@@ -136,7 +140,7 @@ export const ContextMenu = observer(function ContextMenu({
                 Zoom to fit
                 <div className="tl-menu-right-slot">
                   <span className="keyboard-shortcut">
-                    <code>{MOD_KEY}</code> <code>⇧</code> <code>1</code>
+                    <code>⇧</code> <code>2</code>
                   </span>
                 </div>
               </ReactContextMenu.Item>
@@ -145,6 +149,7 @@ export const ContextMenu = observer(function ContextMenu({
           )}
           {(app.selectedShapesArray.some(s => s.type === 'group' || app.getParentGroup(s)) ||
             app.selectedShapesArray.length > 1) &&
+            app.selectedShapesArray?.some(s => !s.props.isLocked) &&
             !app.readOnly && (
               <>
                 {app.selectedShapesArray.some(s => s.type === 'group' || app.getParentGroup(s)) && (
@@ -161,24 +166,25 @@ export const ContextMenu = observer(function ContextMenu({
                     </div>
                   </ReactContextMenu.Item>
                 )}
-                {app.selectedShapesArray.length > 1 && (
-                  <ReactContextMenu.Item
-                    className="tl-menu-item"
-                    onClick={() => runAndTransition(app.api.doGroup)}
-                  >
-                    <TablerIcon className="tl-menu-icon" name="group" />
-                    Group
-                    <div className="tl-menu-right-slot">
-                      <span className="keyboard-shortcut">
-                        <code>{MOD_KEY}</code> <code>G</code>
-                      </span>
-                    </div>
-                  </ReactContextMenu.Item>
-                )}
+                {app.selectedShapesArray.length > 1 &&
+                  app.selectedShapesArray?.some(s => !s.props.isLocked) && (
+                    <ReactContextMenu.Item
+                      className="tl-menu-item"
+                      onClick={() => runAndTransition(app.api.doGroup)}
+                    >
+                      <TablerIcon className="tl-menu-icon" name="group" />
+                      Group
+                      <div className="tl-menu-right-slot">
+                        <span className="keyboard-shortcut">
+                          <code>{MOD_KEY}</code> <code>G</code>
+                        </span>
+                      </div>
+                    </ReactContextMenu.Item>
+                  )}
                 <ReactContextMenu.Separator className="menu-separator" />
               </>
             )}
-          {app.selectedShapes?.size > 0 && (
+          {app.selectedShapes?.size > 0 && app.selectedShapesArray?.some(s => !s.props.isLocked) && (
             <>
               {!app.readOnly && (
                 <ReactContextMenu.Item
@@ -235,27 +241,31 @@ export const ContextMenu = observer(function ContextMenu({
               </div>
             </ReactContextMenu.Item>
           )}
-          <ReactContextMenu.Separator className="menu-separator" />
-          <ReactContextMenu.Item
-            className="tl-menu-item"
-            onClick={() =>
-              runAndTransition(() =>
-                handlers.exportToImage(app.currentPageId, {
-                  x: app.selectionBounds.minX + app.viewport.camera.point[0] - EXPORT_PADDING,
-                  y: app.selectionBounds.minY + app.viewport.camera.point[1] - EXPORT_PADDING,
-                  width: app.selectionBounds?.width + EXPORT_PADDING * 2,
-                  height: app.selectionBounds?.height + EXPORT_PADDING * 2,
-                  zoom: app.viewport.camera.zoom,
-                })
-              )
-            }
-          >
-            <TablerIcon className="tl-menu-icon" name="file-export" />
-            Export
-            <div className="tl-menu-right-slot">
-              <span className="keyboard-shortcut"></span>
-            </div>
-          </ReactContextMenu.Item>
+          {app.selectedShapes?.size > 0 && (
+            <>
+              <ReactContextMenu.Separator className="menu-separator" />
+              <ReactContextMenu.Item
+                className="tl-menu-item"
+                onClick={() =>
+                  runAndTransition(() =>
+                    handlers.exportToImage(app.currentPageId, {
+                      x: app.selectionBounds.minX + app.viewport.camera.point[0] - EXPORT_PADDING,
+                      y: app.selectionBounds.minY + app.viewport.camera.point[1] - EXPORT_PADDING,
+                      width: app.selectionBounds?.width + EXPORT_PADDING * 2,
+                      height: app.selectionBounds?.height + EXPORT_PADDING * 2,
+                      zoom: app.viewport.camera.zoom,
+                    })
+                  )
+                }
+              >
+                <TablerIcon className="tl-menu-icon" name="file-export" />
+                Export
+                <div className="tl-menu-right-slot">
+                  <span className="keyboard-shortcut"></span>
+                </div>
+              </ReactContextMenu.Item>
+            </>
+          )}
           <ReactContextMenu.Separator className="menu-separator" />
           <ReactContextMenu.Item
             className="tl-menu-item"
@@ -276,83 +286,113 @@ export const ContextMenu = observer(function ContextMenu({
               Deselect all
             </ReactContextMenu.Item>
           )}
-          {app.selectedShapes?.size > 0 && !app.readOnly && (
-            <>
-              <ReactContextMenu.Item
-                className="tl-menu-item"
-                onClick={() => runAndTransition(app.api.deleteShapes)}
-              >
-                <TablerIcon className="tl-menu-icon" name="backspace" />
-                Delete
-                <div className="tl-menu-right-slot">
-                  <span className="keyboard-shortcut">
-                    <code>Del</code>
-                  </span>
-                </div>
-              </ReactContextMenu.Item>
-              {app.selectedShapes?.size > 1 && !app.readOnly && (
-                <>
-                  <ReactContextMenu.Separator className="menu-separator" />
-                  <ReactContextMenu.Item
-                    className="tl-menu-item"
-                    onClick={() => runAndTransition(app.flipHorizontal)}
-                  >
-                    <TablerIcon className="tl-menu-icon" name="flip-horizontal" />
-                    Flip horizontally
-                  </ReactContextMenu.Item>
-                  <ReactContextMenu.Item
-                    className="tl-menu-item"
-                    onClick={() => runAndTransition(app.flipVertical)}
-                  >
-                    <TablerIcon className="tl-menu-icon" name="flip-vertical" />
-                    Flip vertically
-                  </ReactContextMenu.Item>
-                </>
-              )}
-              {!app.readOnly && (
-                <>
-                  <ReactContextMenu.Separator className="menu-separator" />
-                  <ReactContextMenu.Item
-                    className="tl-menu-item"
-                    onClick={() => runAndTransition(app.bringToFront)}
-                  >
-                    Move to front
-                    <div className="tl-menu-right-slot">
-                      <span className="keyboard-shortcut">
-                        <code>⇧</code> <code>]</code>
-                      </span>
-                    </div>
-                  </ReactContextMenu.Item>
-                  <ReactContextMenu.Item
-                    className="tl-menu-item"
-                    onClick={() => runAndTransition(app.sendToBack)}
-                  >
-                    Move to back
-                    <div className="tl-menu-right-slot">
-                      <span className="keyboard-shortcut">
-                        <code>⇧</code> <code>[</code>
-                      </span>
-                    </div>
-                  </ReactContextMenu.Item>
-                </>
-              )}
-
-              {developerMode && (
+          {app.selectedShapes?.size > 0 && app.selectedShapesArray?.some(s => !s.props.isLocked) && (
+            <ReactContextMenu.Item
+              className="tl-menu-item"
+              onClick={() => runAndTransition(() => app.setLocked(true))}
+            >
+              <TablerIcon className="tl-menu-icon" name="lock" />
+              Lock
+              <div className="tl-menu-right-slot">
+                <span className="keyboard-shortcut">
+                <code>{MOD_KEY}</code> <code>L</code>
+                </span>
+              </div>
+            </ReactContextMenu.Item>
+          )}
+          {app.selectedShapes?.size > 0 && app.selectedShapesArray?.some(s => s.props.isLocked) && (
+            <ReactContextMenu.Item
+              className="tl-menu-item"
+              onClick={() => runAndTransition(() => app.setLocked(false))}
+            >
+              <TablerIcon className="tl-menu-icon" name="lock-open" />
+              Unlock
+              <div className="tl-menu-right-slot">
+                <span className="keyboard-shortcut">
+                <code>{MOD_KEY}</code> <code>⇧</code> <code>L</code>
+                </span>
+              </div>
+            </ReactContextMenu.Item>
+          )}
+          {app.selectedShapes?.size > 0 &&
+            !app.readOnly &&
+            app.selectedShapesArray?.some(s => !s.props.isLocked) && (
+              <>
                 <ReactContextMenu.Item
                   className="tl-menu-item"
-                  onClick={() => {
-                    if (app.selectedShapesArray.length === 1) {
-                      console.log(toJS(app.selectedShapesArray[0].serialized))
-                    } else {
-                      console.log(app.selectedShapesArray.map(s => toJS(s.serialized)))
-                    }
-                  }}
+                  onClick={() => runAndTransition(app.api.deleteShapes)}
                 >
-                  (Dev) Print shape props
+                  <TablerIcon className="tl-menu-icon" name="backspace" />
+                  Delete
+                  <div className="tl-menu-right-slot">
+                    <span className="keyboard-shortcut">
+                      <code>Del</code>
+                    </span>
+                  </div>
                 </ReactContextMenu.Item>
-              )}
-            </>
-          )}
+                {app.selectedShapes?.size > 1 && !app.readOnly && (
+                  <>
+                    <ReactContextMenu.Separator className="menu-separator" />
+                    <ReactContextMenu.Item
+                      className="tl-menu-item"
+                      onClick={() => runAndTransition(app.flipHorizontal)}
+                    >
+                      <TablerIcon className="tl-menu-icon" name="flip-horizontal" />
+                      Flip horizontally
+                    </ReactContextMenu.Item>
+                    <ReactContextMenu.Item
+                      className="tl-menu-item"
+                      onClick={() => runAndTransition(app.flipVertical)}
+                    >
+                      <TablerIcon className="tl-menu-icon" name="flip-vertical" />
+                      Flip vertically
+                    </ReactContextMenu.Item>
+                  </>
+                )}
+                {!app.readOnly && (
+                  <>
+                    <ReactContextMenu.Separator className="menu-separator" />
+                    <ReactContextMenu.Item
+                      className="tl-menu-item"
+                      onClick={() => runAndTransition(app.bringToFront)}
+                    >
+                      Move to front
+                      <div className="tl-menu-right-slot">
+                        <span className="keyboard-shortcut">
+                          <code>⇧</code> <code>]</code>
+                        </span>
+                      </div>
+                    </ReactContextMenu.Item>
+                    <ReactContextMenu.Item
+                      className="tl-menu-item"
+                      onClick={() => runAndTransition(app.sendToBack)}
+                    >
+                      Move to back
+                      <div className="tl-menu-right-slot">
+                        <span className="keyboard-shortcut">
+                          <code>⇧</code> <code>[</code>
+                        </span>
+                      </div>
+                    </ReactContextMenu.Item>
+                  </>
+                )}
+
+                {developerMode && (
+                  <ReactContextMenu.Item
+                    className="tl-menu-item"
+                    onClick={() => {
+                      if (app.selectedShapesArray.length === 1) {
+                        console.log(toJS(app.selectedShapesArray[0].serialized))
+                      } else {
+                        console.log(app.selectedShapesArray.map(s => toJS(s.serialized)))
+                      }
+                    }}
+                  >
+                    (Dev) Print shape props
+                  </ReactContextMenu.Item>
+                )}
+              </>
+            )}
         </div>
       </ReactContextMenu.Content>
     </ReactContextMenu.Root>

+ 1 - 62
tldraw/apps/tldraw-logseq/src/components/Devtools/Devtools.tsx

@@ -1,59 +1,12 @@
-import { useApp, useRendererContext } from '@tldraw/react'
-import { autorun } from 'mobx'
+import { useRendererContext } from '@tldraw/react'
 import { observer } from 'mobx-react-lite'
 import React from 'react'
 import ReactDOM from 'react-dom'
-import type { Shape } from '../../lib'
 
 const printPoint = (point: number[]) => {
   return `[${point.map(d => d?.toFixed(2) ?? '-').join(', ')}]`
 }
 
-const HistoryStack = observer(function HistoryStack() {
-  const app = useApp<Shape>()
-  const anchorRef = React.useRef<HTMLDivElement>()
-  const [_, setTick] = React.useState(0)
-
-  React.useEffect(() => {
-    anchorRef.current = document.createElement('div')
-    anchorRef.current.style.display = 'contents'
-    document.body.append(anchorRef.current)
-    setTick(tick => tick + 1)
-    return () => {
-      anchorRef.current?.remove()
-    }
-  }, [])
-
-  React.useEffect(() => {
-    requestAnimationFrame(() => {
-      anchorRef.current
-        ?.querySelector(`[data-item-index="${app.history.pointer}"]`)
-        ?.scrollIntoView()
-    })
-  }, [app.history.pointer])
-
-  return anchorRef.current
-    ? ReactDOM.createPortal(
-        <div className="fixed z-[1000] left-4 max-w-[400px] top-4 overflow-scroll bg-gray-200 flex gap-2 p-2">
-          {app.history.stack.map((item, i) => (
-            <div
-              data-item-index={i}
-              style={{
-                background: app.history.pointer === i ? 'pink' : 'grey',
-              }}
-              key={i}
-              onClick={() => app.history.setPointer(i)}
-              className="flex items-center rounded-lg px-2 h-[32px] whitespace-nowrap"
-            >
-              {item.pages[0].nonce}
-            </div>
-          ))}
-        </div>,
-        anchorRef.current
-      )
-    : null
-})
-
 export const DevTools = observer(() => {
   const {
     viewport: {
@@ -63,13 +16,9 @@ export const DevTools = observer(() => {
     inputs,
   } = useRendererContext()
 
-  const canvasAnchorRef = React.useRef<HTMLElement | null>()
   const statusbarAnchorRef = React.useRef<HTMLElement | null>()
 
   React.useEffect(() => {
-    const canvasAnchor = document.getElementById('tl-dev-tools-canvas-anchor')
-    canvasAnchorRef.current = canvasAnchor
-
     const statusbarAnchor = document.getElementById('tl-statusbar-anchor')
     statusbarAnchorRef.current = statusbarAnchor
   }, [])
@@ -84,15 +33,6 @@ export const DevTools = observer(() => {
     .map(p => p.join(''))
     .join('|')
 
-  const originPoint = canvasAnchorRef.current
-    ? ReactDOM.createPortal(
-        <svg className="tl-renderer-dev-tools tl-grid">
-          <circle cx={point[0] * zoom} cy={point[1] * zoom} r="4" fill="red" />
-        </svg>,
-        canvasAnchorRef.current
-      )
-    : null
-
   const rendererStatus = statusbarAnchorRef.current
     ? ReactDOM.createPortal(
         <div
@@ -110,7 +50,6 @@ export const DevTools = observer(() => {
 
   return (
     <>
-      {originPoint}
       {rendererStatus}
     </>
   )

+ 33 - 23
tldraw/apps/tldraw-logseq/src/components/GeometryTools/GeometryTools.tsx

@@ -1,11 +1,22 @@
-import { useApp } from '@tldraw/react'
 import { observer } from 'mobx-react-lite'
-import * as React from 'react'
+import type { Side } from '@radix-ui/react-popper'
 import { ToolButton } from '../ToolButton'
 import * as Popover from '@radix-ui/react-popover'
 import { TablerIcon } from '../icons'
 
-export const GeometryTools = observer(function GeometryTools() {
+interface GeometryToolsProps extends React.HTMLAttributes<HTMLElement> {
+  popoverSide?: Side
+  activeGeometry?: string
+  setGeometry: (e: React.MouseEvent<HTMLButtonElement>) => void
+  chevron?: boolean
+}
+
+export const GeometryTools = observer(function GeometryTools({
+  popoverSide = "left",
+  setGeometry,
+  activeGeometry,
+  chevron = true,
+  ...rest}: GeometryToolsProps) {
   const geometries = [
     {
       id: 'box',
@@ -24,34 +35,33 @@ export const GeometryTools = observer(function GeometryTools() {
     },
   ]
 
-  const app = useApp()
-  const [activeGeomId, setActiveGeomId] = React.useState(
-    () => (geometries.find(geo => geo.id === app.selectedTool.id) ?? geometries[0]).id
-  )
+  const shapes = {
+    id: 'shapes',
+    icon: 'triangle-square-circle',
+    tooltip: 'Shape',
+  }
 
-  React.useEffect(() => {
-    setActiveGeomId(prevId => {
-      return geometries.find(geo => geo.id === app.selectedTool.id)?.id ?? prevId
-    })
-  }, [app.selectedTool.id])
+  const activeTool = activeGeometry ? geometries.find(geo => geo.id === activeGeometry) : shapes
 
   return (
     <Popover.Root>
-      <Popover.Trigger asChild>
-        <div className="tl-geometry-tools-pane-anchor">
-          <ToolButton {...geometries.find(geo => geo.id === activeGeomId)!} />
-          <TablerIcon
-            data-selected={geometries.some(geo => geo.id === app.selectedTool.id)}
-            className="tl-popover-indicator"
-            name="chevron-down-left"
-          />
+      <Popover.Trigger asChild >
+        <div {...rest} className="tl-geometry-tools-pane-anchor">
+          <ToolButton {...activeTool} tooltipSide={popoverSide} />
+          {chevron &&
+            <TablerIcon
+              data-selected={activeGeometry}
+              className="tl-popover-indicator"
+              name="chevron-down-left"
+            />
+          }
         </div>
       </Popover.Trigger>
 
-      <Popover.Content className="tl-popover-content" side="left" sideOffset={15}>
-        <div className="tl-toolbar tl-geometry-toolbar">
+      <Popover.Content className="tl-popover-content" side={popoverSide} sideOffset={15}>
+        <div className={`tl-toolbar tl-geometry-toolbar ${["left", "right"].includes(popoverSide) ? "flex-col" : "flex-row" }`}>
           {geometries.map(props => (
-            <ToolButton key={props.id} {...props} />
+            <ToolButton key={props.id} id={props.id} icon={props.icon} tooltip={activeGeometry ? props.tooltip : ''} handleClick={setGeometry} tooltipSide={popoverSide} />
           ))}
         </div>
 

+ 29 - 8
tldraw/apps/tldraw-logseq/src/components/PrimaryTools/PrimaryTools.tsx

@@ -1,4 +1,5 @@
 import { useApp } from '@tldraw/react'
+import { Geometry } from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
 import * as React from 'react'
 import { ToolButton } from '../ToolButton'
@@ -14,23 +15,43 @@ export const PrimaryTools = observer(function PrimaryTools() {
     app.api.setColor(color)
   }, [])
 
+  const handleToolClick = React.useCallback(
+    (e: React.MouseEvent<HTMLButtonElement>) => {
+      const tool = e.currentTarget.dataset.tool
+      if (tool) app.selectTool(tool)
+    },
+    []
+  )
+
+  const [activeGeomId, setActiveGeomId] = React.useState(
+    () => (Object.values(Geometry).find((geo: string) => geo === app.selectedTool.id) ?? Object.values(Geometry)[0])
+  )
+
+  React.useEffect(() => {
+    setActiveGeomId((prevId: Geometry) => {
+      return Object.values(Geometry).find((geo: string) => geo === app.selectedTool.id) ?? prevId
+    })
+  }, [app.selectedTool.id])
+
+
   return (
     <div className="tl-primary-tools" data-html2canvas-ignore="true">
       <div className="tl-toolbar tl-tools-floating-panel">
-        <ToolButton tooltip="Select" id="select" icon="select-cursor" />
+        <ToolButton handleClick={() =>app.selectTool("select")} tooltip="Select" id="select" icon="select-cursor" />
         <ToolButton
+         handleClick={() =>app.selectTool("move")}
           tooltip="Move"
           id="move"
           icon={app.isIn('move.panning') ? 'hand-grab' : 'hand-stop'}
         />
         <Separator.Root className="tl-toolbar-separator" orientation="horizontal" />
-        <ToolButton tooltip="Add block or page" id="logseq-portal" icon="circle-plus" />
-        <ToolButton tooltip="Draw" id="pencil" icon="ballpen" />
-        <ToolButton tooltip="Highlight" id="highlighter" icon="highlight" />
-        <ToolButton tooltip="Eraser" id="erase" icon="eraser" />
-        <ToolButton tooltip="Connector" id="line" icon="connector" />
-        <ToolButton tooltip="Text" id="text" icon="text" />
-        <GeometryTools />
+        <ToolButton handleClick={() =>app.selectTool("logseq-portal")} tooltip="Add block or page" id="logseq-portal" icon="circle-plus" />
+        <ToolButton handleClick={() =>app.selectTool("pencil")} tooltip="Draw" id="pencil" icon="ballpen" />
+        <ToolButton handleClick={() =>app.selectTool("highlighter")} tooltip="Highlight" id="highlighter" icon="highlight" />
+        <ToolButton handleClick={() =>app.selectTool("erase")} tooltip="Eraser" id="erase" icon="eraser" />
+        <ToolButton handleClick={() =>app.selectTool("line")} tooltip="Connector" id="line" icon="connector" />
+        <ToolButton handleClick={() =>app.selectTool("text")} tooltip="Text" id="text" icon="text" />
+        <GeometryTools activeGeometry={activeGeomId} setGeometry={handleToolClick}/>
         <Separator.Root
           className="tl-toolbar-separator"
           orientation="horizontal"

+ 9 - 14
tldraw/apps/tldraw-logseq/src/components/ToolButton/ToolButton.tsx

@@ -1,7 +1,8 @@
 import { TLMoveTool, TLSelectTool } from '@tldraw/core'
 import { useApp } from '@tldraw/react'
+import type { Side } from '@radix-ui/react-popper'
 import { observer } from 'mobx-react-lite'
-import * as React from 'react'
+import type * as React from 'react'
 import { Button } from '../Button'
 import { TablerIcon } from '../icons'
 
@@ -9,25 +10,19 @@ export interface ToolButtonProps extends React.ButtonHTMLAttributes<HTMLButtonEl
   id: string
   icon: string | React.ReactNode
   tooltip: string
+  tooltipSide?: Side
+  handleClick: (e: React.MouseEvent<HTMLButtonElement>) => void
 }
 
-export const ToolButton = observer(({ id, icon, tooltip, ...props }: ToolButtonProps) => {
+export const ToolButton = observer(({ id, icon, tooltip, tooltipSide = "left", handleClick, ...props }: ToolButtonProps) => {
   const app = useApp()
 
-  const handleToolClick = React.useCallback(
-    (e: React.MouseEvent<HTMLButtonElement>) => {
-      const tool = e.currentTarget.dataset.tool
-      if (tool) app.selectTool(tool)
-    },
-    [app]
-  )
-
   // Tool must exist
   const Tool = [...app.Tools, TLSelectTool, TLMoveTool]?.find(T => T.id === id)
 
-  const shortcuts = (Tool as any)['shortcut']
+  const shortcuts = (Tool as any)?.['shortcut']
 
-  const tooltipContent = shortcuts ? (
+  const tooltipContent = shortcuts && tooltip ? (
     <>
       {tooltip}
       <span className="ml-2 keyboard-shortcut">
@@ -43,11 +38,11 @@ export const ToolButton = observer(({ id, icon, tooltip, ...props }: ToolButtonP
   return (
     <Button
       {...props}
-      tooltipSide="left"
+      tooltipSide={tooltipSide}
       tooltip={tooltipContent}
       data-tool={id}
       data-selected={id === app.selectedTool.id}
-      onClick={handleToolClick}
+      onClick={handleClick}
     >
       {typeof icon === 'string' ? <TablerIcon name={icon} /> : icon}
     </Button>

+ 1 - 1
tldraw/apps/tldraw-logseq/src/components/ZoomMenu/ZoomMenu.tsx

@@ -41,7 +41,7 @@ export const ZoomMenu = observer(function ZoomMenu(): JSX.Element {
           Zoom to fit selection
           <div className="tl-menu-right-slot">
             <span className="keyboard-shortcut">
-              <code>{MOD_KEY}</code> <code>⇧</code> <code>1</code>
+              <code>⇧</code> <code>2</code>
             </span>
           </div>
         </DropdownMenuPrimitive.Item>

+ 1 - 0
tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts

@@ -399,6 +399,7 @@ const handleCreatingShapes = async (
     return {
       ...shape,
       parentId: app.currentPageId,
+      isLocked: false,
       id: validUUID(shape.id) ? shape.id : uniqueId(),
     }
   })

+ 9 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx

@@ -166,12 +166,20 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
       props: {
         size: [w, h],
         borderRadius,
+        isLocked,
       },
     } = this
 
     return (
       <g>
-        <rect width={w} height={h} rx={borderRadius} ry={borderRadius} fill="transparent" />
+        <rect
+          width={w}
+          height={h}
+          rx={borderRadius}
+          ry={borderRadius}
+          fill="transparent"
+          strokeDasharray={isLocked ? '8 2' : undefined}
+        />
       </g>
     )
   })

+ 10 - 2
tldraw/apps/tldraw-logseq/src/lib/shapes/DotShape.tsx

@@ -43,8 +43,16 @@ export class DotShape extends TLDotShape<DotShapeProps> {
   })
 
   ReactIndicator = observer(() => {
-    const { radius } = this.props
-    return <circle cx={radius} cy={radius} r={radius} pointerEvents="all" />
+    const { radius, isLocked } = this.props
+    return (
+      <circle
+        cx={radius}
+        cy={radius}
+        r={radius}
+        pointerEvents="all"
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 
   validateProps = (props: Partial<DotShapeProps>) => {

+ 10 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/EllipseShape.tsx

@@ -154,11 +154,20 @@ export class EllipseShape extends TLEllipseShape<EllipseShapeProps> {
   ReactIndicator = observer(() => {
     const {
       size: [w, h],
+      isLocked,
     } = this.props
 
     return (
       <g>
-        <ellipse cx={w / 2} cy={h / 2} rx={w / 2} ry={h / 2} strokeWidth={2} fill="transparent" />
+        <ellipse
+          cx={w / 2}
+          cy={h / 2}
+          rx={w / 2}
+          ry={h / 2}
+          strokeWidth={2}
+          fill="transparent"
+          strokeDasharray={isLocked ? '8 2' : 'undefined'}
+        />
       </g>
     )
   })

+ 9 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/HTMLShape.tsx

@@ -136,9 +136,17 @@ export class HTMLShape extends TLBoxShape<HTMLShapeProps> {
     const {
       props: {
         size: [w, h],
+        isLocked,
       },
     } = this
-    return <rect width={w} height={h} fill="transparent" />
+    return (
+      <rect
+        width={w}
+        height={h}
+        fill="transparent"
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 
   validateProps = (props: Partial<HTMLShapeProps>) => {

+ 4 - 2
tldraw/apps/tldraw-logseq/src/lib/shapes/HighlighterShape.tsx

@@ -83,8 +83,10 @@ export class HighlighterShape extends TLDrawShape<HighlighterShapeProps> {
   }
 
   ReactIndicator = observer(() => {
-    const { pointsPath } = this
-    return <path d={pointsPath} fill="none" />
+    const { pointsPath, props } = this
+    return (
+      <path d={pointsPath} fill="none" strokeDasharray={props.isLocked ? '8 2' : 'undefined'} />
+    )
   })
 
   validateProps = (props: Partial<HighlighterShapeProps>) => {

+ 11 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/IFrameShape.tsx

@@ -82,8 +82,18 @@ export class IFrameShape extends TLBoxShape<IFrameShapeProps> {
     const {
       props: {
         size: [w, h],
+        isLocked,
       },
     } = this
-    return <rect width={w} height={h} fill="transparent" rx={8} ry={8} />
+    return (
+      <rect
+        width={w}
+        height={h}
+        fill="transparent"
+        rx={8}
+        ry={8}
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 }

+ 9 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/ImageShape.tsx

@@ -74,9 +74,17 @@ export class ImageShape extends TLImageShape<ImageShapeProps> {
     const {
       props: {
         size: [w, h],
+        isLocked,
       },
     } = this
-    return <rect width={w} height={h} fill="transparent" />
+    return (
+      <rect
+        width={w}
+        height={h}
+        fill="transparent"
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 
   getShapeSVGJsx({ assets }: { assets: TLAsset[] }) {

+ 8 - 2
tldraw/apps/tldraw-logseq/src/lib/shapes/LineShape.tsx

@@ -77,7 +77,7 @@ export class LineShape extends TLLineShape<LineShapeProps> {
     const labelSize =
       label || isEditing
         ? getTextLabelSize(
-            label || "Enter text",
+            label || 'Enter text',
             { fontFamily: 'var(--ls-font-family)', fontSize, lineHeight: 1, fontWeight },
             6
           )
@@ -99,7 +99,11 @@ export class LineShape extends TLLineShape<LineShapeProps> {
       [label]
     )
     return (
-      <div {...events} style={{ width: '100%', height: '100%', overflow: 'hidden' }} className="tl-line-container">
+      <div
+        {...events}
+        style={{ width: '100%', height: '100%', overflow: 'hidden' }}
+        className="tl-line-container"
+      >
         <TextLabel
           font={font}
           text={label}
@@ -146,6 +150,7 @@ export class LineShape extends TLLineShape<LineShapeProps> {
       fontSize,
       fontWeight,
       handles: { start, end },
+      isLocked,
     } = this.props
     const bounds = this.getBounds()
     const labelSize =
@@ -176,6 +181,7 @@ export class LineShape extends TLLineShape<LineShapeProps> {
             decorations?.start,
             decorations?.end
           )}
+          strokeDasharray={isLocked ? '8 2' : 'undefined'}
         />
         {label && !isEditing && (
           <rect

+ 63 - 64
tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx

@@ -15,7 +15,6 @@ import { action, computed, makeObservable } from 'mobx'
 import { observer } from 'mobx-react-lite'
 import * as React from 'react'
 import type { Shape, SizeLevel } from '.'
-import { CircleButton } from '../../components/Button'
 import { LogseqQuickSearch } from '../../components/QuickSearch'
 import { useCameraMovingRef } from '../../hooks/useCameraMoving'
 import { LogseqContext } from '../logseq-context'
@@ -63,11 +62,11 @@ const LogseqPortalShapeHeader = observer(
         : 'var(--ls-tertiary-background-color)'
 
     const fillGradient =
-        fill && fill !== 'var(--ls-secondary-background-color)'
-          ? isBuiltInColor(fill)
-            ? `var(--ls-highlight-color-${fill})`
-            : fill
-          : 'var(--ls-secondary-background-color)'
+      fill && fill !== 'var(--ls-secondary-background-color)'
+        ? isBuiltInColor(fill)
+          ? `var(--ls-highlight-color-${fill})`
+          : fill
+        : 'var(--ls-secondary-background-color)'
 
     return (
       <div
@@ -79,7 +78,8 @@ const LogseqPortalShapeHeader = observer(
           className="absolute inset-0 tl-logseq-portal-header-bg"
           style={{
             opacity,
-            background: type === 'P' ? bgColor : `linear-gradient(0deg, ${fillGradient}, ${bgColor})`,
+            background:
+              type === 'P' ? bgColor : `linear-gradient(0deg, ${fillGradient}, ${bgColor})`,
           }}
         ></div>
         <div className="relative">{children}</div>
@@ -148,8 +148,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     return this.props.blockType === 'B' ? this.props.compact : this.props.collapsed
   }
 
-  @action toggleCollapsed = async () => {
-    const collapsed = !this.collapsed
+  @action setCollapsed = async (collapsed: boolean) => {
     if (this.props.blockType === 'B') {
       this.update({ compact: collapsed })
       this.canResize[1] = !collapsed
@@ -191,28 +190,30 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     const [size, setSize] = React.useState<[number, number]>([0, 0])
     const app = useApp<Shape>()
     React.useEffect(() => {
-      if (ref?.current) {
-        const el = selector ? ref.current.querySelector<HTMLElement>(selector) : ref.current
-        if (el) {
-          const updateSize = () => {
-            const { width, height } = el.getBoundingClientRect()
-            const bound = Vec.div([width, height], app.viewport.camera.zoom) as [number, number]
-            setSize(bound)
-            return bound
-          }
-          updateSize()
-          // Hacky, I know 🤨
-          this.getInnerHeight = () => updateSize()[1]
-          const resizeObserver = new ResizeObserver(() => {
+      setTimeout(() => {
+        if (ref?.current) {
+          const el = selector ? ref.current.querySelector<HTMLElement>(selector) : ref.current
+          if (el) {
+            const updateSize = () => {
+              const { width, height } = el.getBoundingClientRect()
+              const bound = Vec.div([width, height], app.viewport.camera.zoom) as [number, number]
+              setSize(bound)
+              return bound
+            }
             updateSize()
-          })
-          resizeObserver.observe(el)
-          return () => {
-            resizeObserver.disconnect()
+            // Hacky, I know 🤨
+            this.getInnerHeight = () => updateSize()[1]
+            const resizeObserver = new ResizeObserver(() => {
+              updateSize()
+            })
+            resizeObserver.observe(el)
+            return () => {
+              resizeObserver.disconnect()
+            }
           }
         }
-      }
-      return () => {}
+        return () => {}
+      }, 10);
     }, [ref, selector])
     return size
   }
@@ -349,7 +350,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
   ReactComponent = observer((componentProps: TLComponentProps) => {
     const { events, isErasing, isEditing, isBinding } = componentProps
     const {
-      props: { opacity, pageId, fill, scaleLevel, strokeWidth, size },
+      props: { opacity, pageId, fill, scaleLevel, strokeWidth, size, isLocked },
     } = this
 
     const app = useApp<Shape>()
@@ -493,41 +494,30 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
               placeholder="Create or search your graph..."
             />
           ) : (
-            <>
-              <div
-                className="tl-logseq-portal-container"
-                data-collapsed={this.collapsed}
-                data-page-id={pageId}
-                data-portal-selected={portalSelected}
-                data-editing={isEditing}
-                style={portalStyle}
-              >
-                {!this.props.compact && !targetNotFound && (
-                  <LogseqPortalShapeHeader
-                    type={this.props.blockType ?? 'P'}
-                    fill={fill}
-                    opacity={opacity}
-                  >
-                    {this.props.blockType === 'P' ? (
-                      <PageName pageName={pageId} />
-                    ) : (
-                      <Breadcrumb blockId={pageId} />
-                    )}
-                  </LogseqPortalShapeHeader>
-                )}
-                {targetNotFound && <div className="tl-target-not-found">Target not found</div>}
-                {showingPortal && <PortalComponent {...componentProps} />}
-              </div>
-              {!app.readOnly && (
-                <CircleButton
-                  active={!!this.collapsed}
-                  style={{ opacity: isSelected ? 1 : 0 }}
-                  icon={this.props.blockType === 'B' ? 'block' : 'page'}
-                  onClick={this.toggleCollapsed}
-                  otherIcon={'whiteboard-element'}
-                />
+            <div
+              className="tl-logseq-portal-container"
+              data-collapsed={this.collapsed}
+              data-page-id={pageId}
+              data-portal-selected={portalSelected}
+              data-editing={isEditing}
+              style={portalStyle}
+            >
+              {!this.props.compact && !targetNotFound && (
+                <LogseqPortalShapeHeader
+                  type={this.props.blockType ?? 'P'}
+                  fill={fill}
+                  opacity={opacity}
+                >
+                  {this.props.blockType === 'P' ? (
+                    <PageName pageName={pageId} />
+                  ) : (
+                    <Breadcrumb blockId={pageId} />
+                  )}
+                </LogseqPortalShapeHeader>
               )}
-            </>
+              {targetNotFound && <div className="tl-target-not-found">Target not found</div>}
+              {showingPortal && <PortalComponent {...componentProps} />}
+            </div>
           )}
         </div>
       </HTMLContainer>
@@ -536,7 +526,16 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
 
   ReactIndicator = observer(() => {
     const bounds = this.getBounds()
-    return <rect width={bounds.width} height={bounds.height} fill="transparent" rx={8} ry={8} />
+    return (
+      <rect
+        width={bounds.width}
+        height={bounds.height}
+        fill="transparent"
+        rx={8}
+        ry={8}
+        strokeDasharray={this.props.isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 
   validateProps = (props: Partial<LogseqPortalShapeProps>) => {

+ 1 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/PenShape.tsx

@@ -66,7 +66,7 @@ export class PenShape extends TLDrawShape<PenShapeProps> {
 
   ReactIndicator = observer(() => {
     const { pointsPath } = this
-    return <path d={pointsPath} />
+    return <path d={pointsPath} strokeDasharray={this.props.isLocked ? '8 2' : 'undefined'} />
   })
 
   validateProps = (props: Partial<PenShapeProps>) => {

+ 1 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/PencilShape.tsx

@@ -133,7 +133,7 @@ export class PencilShape extends TLDrawShape<PencilShapeProps> {
 
   ReactIndicator = observer(() => {
     const { pointsPath } = this
-    return <path d={pointsPath} />
+    return <path d={pointsPath} strokeDasharray={this.props.isLocked ? '8 2' : 'undefined'} />
   })
 
   validateProps = (props: Partial<PencilShapeProps>) => {

+ 2 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/PolygonShape.tsx

@@ -168,7 +168,7 @@ export class PolygonShape extends TLPolygonShape<PolygonShapeProps> {
   ReactIndicator = observer(() => {
     const {
       offset: [x, y],
-      props: { strokeWidth },
+      props: { strokeWidth, isLocked },
     } = this
 
     return (
@@ -176,6 +176,7 @@ export class PolygonShape extends TLPolygonShape<PolygonShapeProps> {
         <polygon
           transform={`translate(${x}, ${y})`}
           points={this.getVertices(strokeWidth / 2).join()}
+          strokeDasharray={isLocked ? '8 2' : 'undefined'}
         />
       </g>
     )

+ 2 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/TextShape.tsx

@@ -240,7 +240,7 @@ export class TextShape extends TLTextShape<TextShapeProps> {
 
   ReactIndicator = observer(({ isEditing }: TLComponentProps) => {
     const {
-      props: { borderRadius },
+      props: { borderRadius, isLocked },
       bounds,
     } = this
     return isEditing ? null : (
@@ -250,6 +250,7 @@ export class TextShape extends TLTextShape<TextShapeProps> {
         rx={borderRadius}
         ry={borderRadius}
         fill="transparent"
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
       />
     )
   })

+ 16 - 11
tldraw/apps/tldraw-logseq/src/lib/shapes/TweetShape.tsx

@@ -90,10 +90,10 @@ export class TweetShape extends TLBoxShape<TweetShapeProps> {
           }}
         >
           {this.embedId ? (
-              <div ref={cpRefContainer}>
-                <Tweet tweetId={this.embedId}/>
-              </div>
-          ) : (null)}
+            <div ref={cpRefContainer}>
+              <Tweet tweetId={this.embedId} />
+            </div>
+          ) : null}
         </div>
       </HTMLContainer>
     )
@@ -103,9 +103,19 @@ export class TweetShape extends TLBoxShape<TweetShapeProps> {
     const {
       props: {
         size: [w, h],
+        isLocked,
       },
     } = this
-    return <rect width={w} height={h} fill="transparent" rx={8} ry={8} />
+    return (
+      <rect
+        width={w}
+        height={h}
+        fill="transparent"
+        rx={8}
+        ry={8}
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 
   useComponentSize<T extends HTMLElement>(ref: React.RefObject<T> | null, selector = '') {
@@ -191,12 +201,7 @@ export class TweetShape extends TLBoxShape<TweetShapeProps> {
     if (embedId) {
       return (
         <g>
-          <rect
-            width={bounds.width}
-            height={bounds.height}
-            fill="#15202b"
-            rx={8}
-            ry={8} />
+          <rect width={bounds.width} height={bounds.height} fill="#15202b" rx={8} ry={8} />
           <svg
             x={bounds.width / 4}
             y={bounds.height / 4}

+ 9 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/VideoShape.tsx

@@ -90,8 +90,16 @@ export class VideoShape extends TLBoxShape<VideoShapeProps> {
     const {
       props: {
         size: [w, h],
+        isLocked,
       },
     } = this
-    return <rect width={w} height={h} fill="transparent" />
+    return (
+      <rect
+        width={w}
+        height={h}
+        fill="transparent"
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 }

+ 11 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/YouTubeShape.tsx

@@ -119,9 +119,19 @@ export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
     const {
       props: {
         size: [w, h],
+        isLocked,
       },
     } = this
-    return <rect width={w} height={h} fill="transparent" rx={8} ry={8} />
+    return (
+      <rect
+        width={w}
+        height={h}
+        fill="transparent"
+        rx={8}
+        ry={8}
+        strokeDasharray={isLocked ? '8 2' : 'undefined'}
+      />
+    )
   })
 
   validateProps = (props: Partial<YouTubeShapeProps>) => {

+ 10 - 49
tldraw/apps/tldraw-logseq/src/styles.css

@@ -246,6 +246,10 @@ html[data-theme='light'] {
       outline: none;
     }
   }
+
+  .tl-geometry-tools-pane-anchor > .tl-button {
+    border: 1px solid var(--ls-secondary-border-color);
+  }
 }
 
 .tl-statusbar {
@@ -318,7 +322,7 @@ button.tl-select-input-trigger {
   @apply flex items-center px-3;
   box-shadow: 0 0 0 1px var(--ls-secondary-border-color);
   background-color: var(--ls-secondary-background-color);
-  border-radius: 8px;
+  border-radius: 0.25rem;
   font-size: 16px;
   height: 100%;
   color: var(--ls-secondary-text-color);
@@ -543,7 +547,7 @@ button.tl-select-input-trigger {
 }
 
 .tl-circle-button {
-  @apply absolute flex items-center justify-center transition-all rounded-full shadow;
+  @apply absolute flex items-center justify-center rounded-full shadow;
 
   color: var(--ls-primary-text-color);
   background-color: var(--ls-secondary-background-color);
@@ -553,60 +557,14 @@ button.tl-select-input-trigger {
   width: 34px;
   border: 2px solid var(--ls-secondary-background-color);
   top: 2px;
-  transition-delay: 0;
 
   .tie {
     transform: translateY(-100%);
   }
 
-  &[data-active='false']:hover:not([data-recently-changed='true']) {
-    .tie {
-      transform: translateY(0);
-
-      &:first-of-type {
-        opacity: 0.6;
-      }
-    }
-  }
-
-  &[data-active='true'] {
-    background-color: var(--ls-active-primary-color);
-    color: var(--ls-block-highlight-color);
-    border: 2px solid var(--ls-active-primary-color);
-
-    .tie {
-      transform: translateY(0);
-
-      &:last-of-type {
-        opacity: 0.6;
-      }
-    }
-
-    &:hover:not([data-recently-changed='true']) {
-      color: var(--ls-primary-text-color);
-      background-color: var(--ls-secondary-background-color);
-
-      .tie {
-        transform: translateY(-100%);
-      }
-    }
-  }
-
   .tl-circle-button-icons-wrapper {
     @apply flex flex-col;
   }
-
-  i.tie {
-    transition: transform 0.2s ease-in-out;
-    transition-delay: 0;
-  }
-
-  .tl-circle-button-icons-wrapper[data-icons-count='2'] {
-    position: relative;
-    width: 22px;
-    height: 22px;
-    overflow: hidden;
-  }
 }
 
 .tl-quick-search-input-container {
@@ -957,7 +915,6 @@ html[data-theme='dark'] {
 
 .tl-geometry-toolbar {
   box-shadow: none;
-  flex-flow: column;
 }
 
 .tl-popover-arrow {
@@ -1214,6 +1171,10 @@ button.tl-shape-links-panel-item-remove-button {
   fill: var(--ls-secondary-background-color);
 }
 
+.tl-locked {
+  stroke-dasharray: calc(3px * var(--tl-scale)) calc(3px * var(--tl-scale));
+}
+
 @keyframes fadeIn {
   from {
     opacity: 0;

+ 30 - 5
tldraw/packages/core/src/lib/TLApi/TLApi.ts

@@ -11,8 +11,10 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
     this.app = app
   }
 
-  editShape = (shape: string | S | undefined): this => {
-    this.app.transition('select').selectedTool.transition('editingShape', { shape })
+  editShape = (shape: S | undefined): this => {
+    if (!shape?.props.isLocked)
+      this.app.transition('select').selectedTool.transition('editingShape', { shape })
+
     return this
   }
 
@@ -41,7 +43,7 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
    *
    * @param shapes The serialized shape changes to apply.
    */
-  updateShapes = <T extends S>(...shapes: ({ id: string } & Partial<T['props']>)[]): this => {
+  updateShapes = <T extends S>(...shapes: ({ id: string, type: string } & Partial<T['props']>)[]): this => {
     this.app.updateShapes(shapes)
     return this
   }
@@ -174,7 +176,7 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
     settings.update({ color: color })
 
     this.app.selectedShapesArray.forEach(s => {
-      s.update({ fill: color, stroke: color })
+      if (!s.props.isLocked) s.update({ fill: color, stroke: color })
     })
     this.app.persist()
 
@@ -187,7 +189,7 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
     settings.update({ scaleLevel })
 
     this.app.selectedShapes.forEach(shape => {
-      shape.setScaleLevel(scaleLevel)
+      if (!shape.props.isLocked) shape.setScaleLevel(scaleLevel)
     })
     this.app.persist()
 
@@ -425,4 +427,27 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
       this.app.setSelectedShapes(shapesInGroups)
     }
   }
+
+  convertShapes = (type: string, shapes: S[] = this.app.allSelectedShapesArray) => {
+    const ShapeClass = this.app.getShapeClass(type)
+
+    this.app.currentPage.removeShapes(...shapes)
+    const clones = shapes.map(s => {
+      return new ShapeClass({
+        ...s.serialized,
+        type: type,
+      })
+    })
+    this.app.currentPage.addShapes(...clones)
+    this.app.persist()
+    this.app.setSelectedShapes(clones)
+  }
+
+  setCollapsed = (collapsed: boolean, shapes: S[] = this.app.allSelectedShapesArray) => {
+    shapes.forEach(shape => {
+      if (shape.props.type === 'logseq-portal')
+        shape.setCollapsed(collapsed)
+    })
+    this.app.persist()
+  }
 }

+ 42 - 3
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -113,9 +113,17 @@ export class TLApp<
         fn: () => this.api.zoomToFit(),
       },
       {
-        keys: 'mod+shift+1',
+        keys: 'shift+2',
         fn: () => this.api.zoomToSelection(),
       },
+      {
+        keys: 'mod+up',
+        fn: () => this.api.setCollapsed(true),
+      },
+      {
+        keys: 'mod+down',
+        fn: () => this.api.setCollapsed(false),
+      },
       {
         keys: 'mod+-',
         fn: () => this.api.zoomOut(),
@@ -197,6 +205,18 @@ export class TLApp<
           this.api.toggleGrid()
         },
       },
+      {
+        keys: 'mod+l',
+        fn: () => {
+          this.setLocked(true)
+        },
+      },
+      {
+        keys: 'mod+shift+l',
+        fn: () => {
+          this.setLocked(false)
+        },
+      },
     ]
     // eslint-disable-next-line @typescript-eslint/ban-ts-comment
     // @ts-ignore
@@ -335,10 +355,16 @@ export class TLApp<
     return this
   }
 
-  @action updateShapes = <T extends S>(shapes: ({ id: string } & Partial<T['props']>)[]): this => {
+  @action updateShapes = <T extends S>(shapes: ({ id: string, type: string } & Partial<T['props']>)[]): this => {
     if (this.readOnly) return this
 
-    shapes.forEach(shape => this.getShapeById(shape.id)?.update(shape))
+    shapes.forEach(shape => {
+      const oldShape = this.getShapeById(shape.id)
+      oldShape?.update(shape)
+      if (shape.type !== oldShape?.type) {
+        this.api.convertShapes(shape.type , [oldShape])
+      }
+    })
     this.persist()
     return this
   }
@@ -348,6 +374,7 @@ export class TLApp<
     const normalizedShapes: S[] = shapes
       .map(shape => (typeof shape === 'string' ? this.getShapeById(shape) : shape))
       .filter(isNonNullable)
+      .filter(s => !s.props.isLocked)
 
     // delete a group shape should also delete its children
     const shapesInGroups = this.shapesInGroups(normalizedShapes)
@@ -381,6 +408,7 @@ export class TLApp<
     }
 
     this.currentPage.shapes
+      .filter(s => !s.props.isLocked)
       .flatMap(s => Object.values(s.props.handles ?? {}))
       .flatMap(h => h.bindingId)
       .filter(isNonNullable)
@@ -519,6 +547,17 @@ export class TLApp<
     return this
   }
 
+  setLocked = (locked: boolean): this => {
+    if (this.selectedShapesArray.length === 0 || this.readOnly) return this
+
+    this.selectedShapesArray.forEach(shape => {
+      shape.update({ isLocked: locked })
+    })
+
+    this.persist()
+    return this
+  }
+
   /* --------------------- Assets --------------------- */
 
   @observable assets: Record<string, TLAsset> = {}

+ 1 - 1
tldraw/packages/core/src/lib/tools/TLSelectTool/states/HoveringSelectionHandleState.ts

@@ -74,7 +74,7 @@ export class HoveringSelectionHandleState<
     if (!isSingle) return
     const selectedShape = getFirstFromSet(this.app.selectedShapes)
 
-    if (selectedShape.canEdit && !this.app.readOnly) {
+    if (selectedShape.canEdit && !this.app.readOnly && !selectedShape.props.isLocked) {
       switch (info.type) {
         case TLTargetType.Shape: {
           this.tool.transition('editingShape', info)

+ 1 - 1
tldraw/packages/core/src/lib/tools/TLSelectTool/states/IdleState.ts

@@ -124,7 +124,7 @@ export class IdleState<
     if (info.order || this.app.selectedShapesArray.length !== 1 || this.app.readOnly) return
 
     const selectedShape = this.app.selectedShapesArray[0]
-    if (!selectedShape.canEdit) return
+    if (!selectedShape.canEdit || selectedShape.props.isLocked) return
 
     switch (info.type) {
       case TLTargetType.Shape: {

+ 1 - 0
tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingSelectedShapeState.ts

@@ -49,6 +49,7 @@ export class PointingSelectedShapeState<
       selectedShapesArray.length === 1 &&
       this.pointedSelectedShape.canEdit &&
       !this.app.readOnly &&
+      !this.pointedSelectedShape.props.isLocked &&
       this.pointedSelectedShape instanceof TLBoxShape &&
       PointUtils.pointInBounds(currentPoint, this.pointedSelectedShape.bounds)
     ) {

+ 3 - 3
tldraw/packages/core/src/lib/tools/TLSelectTool/states/TranslatingState.ts

@@ -30,7 +30,6 @@ export class TranslatingState<
 
   private moveSelectedShapesToPointer() {
     const {
-      selectedShapes,
       inputs: { shiftKey, originPoint, currentPoint },
     } = this.app
 
@@ -47,8 +46,8 @@ export class TranslatingState<
     }
 
     transaction(() => {
-      this.app.allSelectedShapes.forEach(shape => {
-        shape.update({ point: Vec.add(initialPoints[shape.id], delta) })
+      this.app.allSelectedShapesArray.forEach(shape => {
+        if (!shape.props.isLocked) shape.update({ point: Vec.add(initialPoints[shape.id], delta) })
       })
     })
   }
@@ -66,6 +65,7 @@ export class TranslatingState<
           type: shape.type,
           point: this.initialPoints[shape.id],
           rotation: shape.props.rotation,
+          isLocked: false,
         })
         return clone
       })

+ 6 - 0
tldraw/packages/core/src/types/types.ts

@@ -14,6 +14,12 @@ export enum Color {
   Default = '',
 }
 
+export enum Geometry {
+    Box = 'box',
+    Ellipse = 'ellipse',
+    Polygon = 'polygon',
+}
+
 export enum AlignType {
   Top = 'top',
   CenterVertical = 'centerVertical',

+ 3 - 2
tldraw/packages/react/src/components/Canvas/Canvas.tsx

@@ -168,6 +168,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
                 isHovered={false}
                 isBinding={false}
                 isSelected={true}
+                isLocked={shape.props.isLocked}
               />
             ))}
           {hoveredShapes.map(
@@ -194,7 +195,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
                   zIndex={editingShape && selectedShapes.includes(editingShape) ? 1002 : 10002}
                 >
                   <components.SelectionForeground
-                    shapes={selectedShapes}
+                    shapes={selectedShapes.filter(shape => !shape.props.isLocked)}
                     bounds={selectionBounds}
                     showResizeHandles={showResizeHandles}
                     showRotateHandles={showRotateHandles}
@@ -251,7 +252,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
             {selectedShapes && components.ContextBar && (
               <ContextBarContainer
                 key={'context' + selectedShapes.map(shape => shape.id).join('')}
-                shapes={selectedShapes}
+                shapes={selectedShapes.filter(s => !s.props.isLocked)}
                 hidden={!showContextBar}
                 bounds={singleSelectedShape ? singleSelectedShape.bounds : selectionBounds}
                 rotation={singleSelectedShape ? singleSelectedShape.props.rotation : 0}

+ 8 - 1
tldraw/packages/react/src/components/Indicator/Indicator.tsx

@@ -10,6 +10,7 @@ interface IndicatorProps {
   isSelected?: boolean
   isBinding?: boolean
   isEditing?: boolean
+  isLocked?: boolean
   meta?: any
 }
 
@@ -19,6 +20,7 @@ export const Indicator = observer(function Shape({
   isSelected = false,
   isBinding = false,
   isEditing = false,
+  isLocked = false,
   meta,
 }: IndicatorProps) {
   const {
@@ -38,11 +40,16 @@ export const Indicator = observer(function Shape({
       zIndex={isEditing ? 1000 : 10000}
     >
       <SVGContainer>
-        <g className={`tl-indicator-container ${isSelected ? 'tl-selected' : 'tl-hovered'}`}>
+        <g
+          className={`tl-indicator-container ${isSelected ? 'tl-selected' : 'tl-hovered'} ${
+            isLocked ? 'tl-locked' : ''
+          }`}
+        >
           <ReactIndicator
             isEditing={isEditing}
             isBinding={isBinding}
             isHovered={isHovered}
+            isLocked={isLocked}
             isSelected={isSelected}
             isErasing={false}
             meta={meta}

+ 5 - 1
tldraw/packages/react/src/components/Shape/Shape.tsx

@@ -38,10 +38,14 @@ export const Shape = observer(function Shape({
   } = shape
   const app = useApp<Shape>()
   const events = useShapeEvents(shape)
+  const parentGroup = app.getParentGroup(shape)
+  const isParentGrpupSelected = app.selectedIds.has(parentGroup?.id)
+  const ignoreExport = !isSelected && !isParentGrpupSelected && app.selectedShapes.size !== 0 || null
+
   return (
     <Container
       data-shape-id={shape.id}
-      data-html2canvas-ignore={(!isSelected && app.selectedShapes.size !== 0) || null}
+      data-html2canvas-ignore={ignoreExport}
       zIndex={zIndex}
       data-type="Shape"
       bounds={bounds}

+ 107 - 101
tldraw/packages/react/src/components/ui/SelectionForeground/SelectionForeground.tsx

@@ -26,119 +26,125 @@ export const SelectionForeground = observer(function SelectionForeground<S exten
   const borderRadius = app.editingShape?.props['borderRadius'] ?? 0
 
   return (
-    <SVGContainer>
-      {!app.editingShape && (<rect
-        className="tl-bounds-fg"
-        width={Math.max(width, 1)}
-        height={Math.max(height, 1)}
-        rx={borderRadius}
-        ry={borderRadius}
-        pointerEvents="none"
-      />)}
-      <EdgeHandle
-        x={targetSize * 2}
-        y={0}
-        width={width - targetSize * 4}
-        height={0}
-        targetSize={targetSize}
-        edge={TLResizeEdge.Top}
-        disabled={!canResize[1]}
-        isHidden={!showResizeHandles}
-      />
-      <EdgeHandle
-        x={width}
-        y={targetSize * 2}
-        width={0}
-        height={height - targetSize * 4}
-        targetSize={targetSize}
-        edge={TLResizeEdge.Right}
-        disabled={!canResize[0]}
-        isHidden={!showResizeHandles}
-      />
-      <EdgeHandle
-        x={targetSize * 2}
-        y={height}
-        width={width - targetSize * 4}
-        height={0}
-        targetSize={targetSize}
-        edge={TLResizeEdge.Bottom}
-        disabled={!canResize[1]}
-        isHidden={!showResizeHandles}
-      />
-      <EdgeHandle
-        x={0}
-        y={targetSize * 2}
-        width={0}
-        height={height - targetSize * 4}
-        targetSize={targetSize}
-        edge={TLResizeEdge.Left}
-        disabled={!canResize[0]}
-        isHidden={!showResizeHandles}
-      />
-      <RotateCornerHandle
-        cx={0}
-        cy={0}
-        targetSize={targetSize}
-        corner={TLRotateCorner.TopLeft}
-        isHidden={!showRotateHandles}
-      />
-      <RotateCornerHandle
-        cx={width + targetSize * 2}
-        cy={0}
-        targetSize={targetSize}
-        corner={TLRotateCorner.TopRight}
-        isHidden={!showRotateHandles}
-      />
-      <RotateCornerHandle
-        cx={width + targetSize * 2}
-        cy={height + targetSize * 2}
-        targetSize={targetSize}
-        corner={TLRotateCorner.BottomRight}
-        isHidden={!showRotateHandles}
-      />
-      <RotateCornerHandle
-        cx={0}
-        cy={height + targetSize * 2}
-        targetSize={targetSize}
-        corner={TLRotateCorner.BottomLeft}
-        isHidden={!showRotateHandles}
-      />
-      {canResize?.every(r => r) && (
-        <>
-          <CornerHandle
-            cx={0}
-            cy={0}
-            size={size}
+    <>
+      {shapes.length > 0 && (
+        <SVGContainer>
+          {!app.editingShape && (
+            <rect
+              className="tl-bounds-fg"
+              width={Math.max(width, 1)}
+              height={Math.max(height, 1)}
+              rx={borderRadius}
+              ry={borderRadius}
+              pointerEvents="none"
+            />
+          )}
+          <EdgeHandle
+            x={targetSize * 2}
+            y={0}
+            width={width - targetSize * 4}
+            height={0}
             targetSize={targetSize}
-            corner={TLResizeCorner.TopLeft}
+            edge={TLResizeEdge.Top}
+            disabled={!canResize[1]}
             isHidden={!showResizeHandles}
           />
-          <CornerHandle
-            cx={width}
-            cy={0}
-            size={size}
+          <EdgeHandle
+            x={width}
+            y={targetSize * 2}
+            width={0}
+            height={height - targetSize * 4}
             targetSize={targetSize}
-            corner={TLResizeCorner.TopRight}
+            edge={TLResizeEdge.Right}
+            disabled={!canResize[0]}
             isHidden={!showResizeHandles}
           />
-          <CornerHandle
-            cx={width}
-            cy={height}
-            size={size}
+          <EdgeHandle
+            x={targetSize * 2}
+            y={height}
+            width={width - targetSize * 4}
+            height={0}
             targetSize={targetSize}
-            corner={TLResizeCorner.BottomRight}
+            edge={TLResizeEdge.Bottom}
+            disabled={!canResize[1]}
             isHidden={!showResizeHandles}
           />
-          <CornerHandle
-            cx={0}
-            cy={height}
-            size={size}
+          <EdgeHandle
+            x={0}
+            y={targetSize * 2}
+            width={0}
+            height={height - targetSize * 4}
             targetSize={targetSize}
-            corner={TLResizeCorner.BottomLeft}
+            edge={TLResizeEdge.Left}
+            disabled={!canResize[0]}
             isHidden={!showResizeHandles}
           />
-        </>
+          <RotateCornerHandle
+            cx={0}
+            cy={0}
+            targetSize={targetSize}
+            corner={TLRotateCorner.TopLeft}
+            isHidden={!showRotateHandles}
+          />
+          <RotateCornerHandle
+            cx={width + targetSize * 2}
+            cy={0}
+            targetSize={targetSize}
+            corner={TLRotateCorner.TopRight}
+            isHidden={!showRotateHandles}
+          />
+          <RotateCornerHandle
+            cx={width + targetSize * 2}
+            cy={height + targetSize * 2}
+            targetSize={targetSize}
+            corner={TLRotateCorner.BottomRight}
+            isHidden={!showRotateHandles}
+          />
+          <RotateCornerHandle
+            cx={0}
+            cy={height + targetSize * 2}
+            targetSize={targetSize}
+            corner={TLRotateCorner.BottomLeft}
+            isHidden={!showRotateHandles}
+          />
+          {canResize?.every(r => r) && (
+            <>
+              <CornerHandle
+                cx={0}
+                cy={0}
+                size={size}
+                targetSize={targetSize}
+                corner={TLResizeCorner.TopLeft}
+                isHidden={!showResizeHandles}
+              />
+              <CornerHandle
+                cx={width}
+                cy={0}
+                size={size}
+                targetSize={targetSize}
+                corner={TLResizeCorner.TopRight}
+                isHidden={!showResizeHandles}
+              />
+              <CornerHandle
+                cx={width}
+                cy={height}
+                size={size}
+                targetSize={targetSize}
+                corner={TLResizeCorner.BottomRight}
+                isHidden={!showResizeHandles}
+              />
+              <CornerHandle
+                cx={0}
+                cy={height}
+                size={size}
+                targetSize={targetSize}
+                corner={TLResizeCorner.BottomLeft}
+                isHidden={!showResizeHandles}
+              />
+            </>
+          )}
+        </SVGContainer>
       )}
-    </SVGContainer>
+    </>
   )
 })

+ 1 - 0
tldraw/packages/react/src/lib/TLReactShape.tsx

@@ -8,6 +8,7 @@ export interface TLCommonShapeProps<M = unknown> {
   isHovered: boolean
   isSelected: boolean
   isErasing: boolean
+  isLocked: boolean
   asset?: TLAsset
 }
 

+ 4 - 4
yarn.lock

@@ -487,10 +487,10 @@
     "@jridgewell/resolve-uri" "^3.0.3"
     "@jridgewell/sourcemap-codec" "^1.4.10"
 
-"@logseq/[email protected]2":
-  version "0.0.22"
-  resolved "https://registry.yarnpkg.com/@logseq/capacitor-file-sync/-/capacitor-file-sync-0.0.22.tgz#3fa94d40e5c44c70a12537ce17cf3089ff72f93b"
-  integrity sha512-lb0+43YAaWy0umBCP2mPKyAPlIr2YHrLBfqGkCJUGAbrhTCAj37KZzb3snwSqeLA8dUSks9PcAN3jSgS74VMMw==
+"@logseq/[email protected]4":
+  version "0.0.24"
+  resolved "https://registry.yarnpkg.com/@logseq/capacitor-file-sync/-/capacitor-file-sync-0.0.24.tgz#be7b69492b92df9c4e899502c632deebe179746b"
+  integrity sha512-CBIXEPYVp1ddjyYN+Z2dTQ9gwF0KYwZwlvwnl+zmR0Q3ODXxg75BExh5vAU8khXkSNZjZXgZT/J61/kn9xN11w==
 
 "@logseq/diff-merge@^0.0.2":
   version "0.0.2"