1
0
Эх сурвалжийг харах

Merge branch 'whiteboards' into enhance/whiteboards-ui

Konstantinos Kaloutas 3 жил өмнө
parent
commit
c0092f0e50
94 өөрчлөгдсөн 2804 нэмэгдсэн , 1926 устгасан
  1. 1 1
      CODEBASE_OVERVIEW.md
  2. 1 1
      android/app/src/main/java/com/logseq/app/FolderPicker.java
  3. 1 1
      android/app/src/main/java/com/logseq/app/FsWatcher.java
  4. 38 36
      e2e-tests/editor.spec.ts
  5. 1 3
      e2e-tests/hotkey.spec.ts
  6. 39 41
      e2e-tests/logseq-url.spec.ts
  7. 2 0
      resources/css/common.css
  8. 24 12
      resources/css/tabler-extension.css
  9. BIN
      resources/fonts/tabler-icons-extension.woff2
  10. 3 3
      src/electron/electron/backup_file.cljs
  11. 22 20
      src/main/frontend/components/block.cljs
  12. 75 50
      src/main/frontend/components/page.cljs
  13. 4 4
      src/main/frontend/components/repo.cljs
  14. 29 20
      src/main/frontend/components/whiteboard.cljs
  15. 9 2
      src/main/frontend/components/whiteboard.css
  16. 15 3
      src/main/frontend/config.cljs
  17. 5 2
      src/main/frontend/dicts.cljc
  18. 25 11
      src/main/frontend/extensions/tldraw.cljs
  19. 3 2
      src/main/frontend/format/block.cljs
  20. 107 93
      src/main/frontend/fs/capacitor_fs.cljs
  21. 5 2
      src/main/frontend/fs/watcher_handler.cljs
  22. 28 15
      src/main/frontend/handler.cljs
  23. 5 13
      src/main/frontend/handler/editor.cljs
  24. 5 4
      src/main/frontend/handler/events.cljs
  25. 22 7
      src/main/frontend/handler/file.cljs
  26. 15 13
      src/main/frontend/handler/repo.cljs
  27. 7 3
      src/main/frontend/handler/whiteboard.cljs
  28. 3 2
      src/main/frontend/mobile/core.cljs
  29. 6 10
      src/main/frontend/modules/file/core.cljs
  30. 1 1
      src/main/frontend/modules/outliner/core.cljs
  31. 1 0
      src/main/frontend/modules/shortcut/dicts.cljc
  32. 5 5
      src/main/frontend/util.cljc
  33. 15 0
      src/main/frontend/utils.js
  34. 8 4
      tldraw/apps/tldraw-logseq/package.json
  35. 25 13
      tldraw/apps/tldraw-logseq/src/app.tsx
  36. 24 92
      tldraw/apps/tldraw-logseq/src/components/ContextBar/ContextBar.tsx
  37. 418 0
      tldraw/apps/tldraw-logseq/src/components/ContextBar/contextBarActionFactory.tsx
  38. 2 2
      tldraw/apps/tldraw-logseq/src/components/icons/TablerIcon.tsx
  39. 2 6
      tldraw/apps/tldraw-logseq/src/components/inputs/ColorInput.tsx
  40. 49 8
      tldraw/apps/tldraw-logseq/src/components/inputs/SelectInput.tsx
  41. 4 6
      tldraw/apps/tldraw-logseq/src/components/inputs/SwitchInput.tsx
  42. 7 5
      tldraw/apps/tldraw-logseq/src/components/inputs/TextInput.tsx
  43. 70 0
      tldraw/apps/tldraw-logseq/src/components/inputs/ToggleGroupInput.tsx
  44. 17 0
      tldraw/apps/tldraw-logseq/src/components/inputs/ToggleInput.tsx
  45. 38 25
      tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts
  46. 15 1
      tldraw/apps/tldraw-logseq/src/index.ts
  47. 2 0
      tldraw/apps/tldraw-logseq/src/lib/logseq-context.ts
  48. 9 5
      tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx
  49. 2 0
      tldraw/apps/tldraw-logseq/src/lib/shapes/DotShape.tsx
  50. 45 3
      tldraw/apps/tldraw-logseq/src/lib/shapes/EllipseShape.tsx
  51. 59 25
      tldraw/apps/tldraw-logseq/src/lib/shapes/HTMLShape.tsx
  52. 3 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/HighlighterShape.tsx
  53. 1 5
      tldraw/apps/tldraw-logseq/src/lib/shapes/ImageShape.tsx
  54. 4 0
      tldraw/apps/tldraw-logseq/src/lib/shapes/LineShape.tsx
  55. 120 87
      tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx
  56. 2 0
      tldraw/apps/tldraw-logseq/src/lib/shapes/PenShape.tsx
  57. 5 2
      tldraw/apps/tldraw-logseq/src/lib/shapes/PencilShape.tsx
  58. 38 6
      tldraw/apps/tldraw-logseq/src/lib/shapes/PolygonShape.tsx
  59. 28 2
      tldraw/apps/tldraw-logseq/src/lib/shapes/TextShape.tsx
  60. 97 0
      tldraw/apps/tldraw-logseq/src/lib/shapes/VideoShape.tsx
  61. 24 61
      tldraw/apps/tldraw-logseq/src/lib/shapes/YouTubeShape.tsx
  62. 2 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/arrow/Arrow.tsx
  63. 5 12
      tldraw/apps/tldraw-logseq/src/lib/shapes/index.ts
  64. 2 12
      tldraw/apps/tldraw-logseq/src/lib/shapes/style-props.tsx
  65. 172 21
      tldraw/apps/tldraw-logseq/src/styles.css
  66. 1 1
      tldraw/apps/tldraw-logseq/tsconfig.json
  67. 3 3
      tldraw/demo/package.json
  68. 2 6
      tldraw/demo/src/App.jsx
  69. 0 215
      tldraw/demo/yarn.lock
  70. 6 4
      tldraw/packages/core/src/lib/TLApi/TLApi.ts
  71. 5 1
      tldraw/packages/core/src/lib/TLApp/TLApp.ts
  72. 1 0
      tldraw/packages/core/src/lib/TLHistory.ts
  73. 1 1
      tldraw/packages/core/src/lib/TLInputs.ts
  74. 2 21
      tldraw/packages/core/src/lib/TLViewport.ts
  75. 1 1
      tldraw/packages/core/src/lib/shapes/TLImageShape/TLImageShape.ts
  76. 9 7
      tldraw/packages/core/src/lib/shapes/TLShape/TLShape.tsx
  77. 4 2
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/HoveringSelectionHandleState.ts
  78. 24 22
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/ResizingState.ts
  79. 3 0
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/TranslatingState.ts
  80. 1 1
      tldraw/packages/core/src/types/types.ts
  81. 36 8
      tldraw/packages/core/src/utils/BoundsUtils.ts
  82. 24 4
      tldraw/packages/core/src/utils/DataUtils.ts
  83. 10 1
      tldraw/packages/core/src/utils/index.ts
  84. 4 10
      tldraw/packages/react/src/components/App.tsx
  85. 1 4
      tldraw/packages/react/src/components/Canvas/Canvas.tsx
  86. 1 0
      tldraw/packages/react/src/components/ContextBarContainer/ContextBarContainer.tsx
  87. 0 1
      tldraw/packages/react/src/components/ui/SelectionBackground/SelectionBackground.tsx
  88. 4 5
      tldraw/packages/react/src/components/ui/SelectionForeground/SelectionForeground.tsx
  89. 3 3
      tldraw/packages/react/src/hooks/useGestureEvents.ts
  90. 7 2
      tldraw/packages/react/src/hooks/useKeyboardEvents.ts
  91. 0 14
      tldraw/packages/react/src/hooks/useStylesheet.ts
  92. 1 1
      tldraw/packages/react/src/types/component-props.ts
  93. 1 1
      tldraw/tsconfig.base.json
  94. 828 802
      tldraw/yarn.lock

+ 1 - 1
CODEBASE_OVERVIEW.md

@@ -24,7 +24,7 @@ For other tasks like bundling static resources and building the desktop app, whi
 
 [React](https://reactjs.org/) is a library for building data-driven UI declaratively. Comparing to the imperative ways (such as DOM manipulation or using jQuery), it's simpler and easier to code correctly.
 
-[Rum](https://github.com/tonsky/rum) is a React wrapper in ClojureScript. More than just providing the familiar React APIs, Rum adds many Clojure flavors to React, especially on the state management part. As a result, if you have experience with React, read Rum's [README]((https://github.com/tonsky/rum) before diving into the code.
+[Rum](https://github.com/tonsky/rum) is a React wrapper in ClojureScript. More than just providing the familiar React APIs, Rum adds many Clojure flavors to React, especially on the state management part. As a result, if you have experience with React, read Rum's [README](https://github.com/tonsky/rum) before diving into the code.
 
 ### DataScript
 

+ 1 - 1
android/app/src/main/java/com/logseq/app/FolderPicker.java

@@ -63,7 +63,7 @@ public class FolderPicker extends Plugin {
         if (path == null || path.isEmpty()) {
             call.reject("Cannot support this directory type: " + docUri);
         } else {
-            ret.put("path", path);
+            ret.put("path", "file://" + path);
             call.resolve(ret);
         }
     }

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

@@ -133,7 +133,7 @@ public class FsWatcher extends Plugin {
         // path.
         File f = new File(path);
         obj.put("path", Uri.fromFile(f));
-        obj.put("dir", mPath);
+        obj.put("dir", "file://" + mPath);
 
         switch (event) {
             case FileObserver.CLOSE_WRITE:

+ 38 - 36
e2e-tests/editor.spec.ts

@@ -143,41 +143,41 @@ test(
   })
 
 test('copy & paste block ref and replace its content', async ({ page, block }) => {
-    await createRandomPage(page)
+  await createRandomPage(page)
 
-    await block.mustFill('Some random text')
-    // FIXME: copy instantly will make content disappear
-    await page.waitForTimeout(1000)
-    if (IsMac) {
-        await page.keyboard.press('Meta+c')
-    } else {
-        await page.keyboard.press('Control+c')
-    }
+  await block.mustFill('Some random text')
+  // FIXME: copy instantly will make content disappear
+  await page.waitForTimeout(1000)
+  if (IsMac) {
+    await page.keyboard.press('Meta+c')
+  } else {
+    await page.keyboard.press('Control+c')
+  }
 
-    await page.press('textarea >> nth=0', 'Enter')
-    if (IsMac) {
-        await page.keyboard.press('Meta+v')
-    } else {
-        await page.keyboard.press('Control+v')
-    }
-    await page.keyboard.press('Enter')
+  await page.press('textarea >> nth=0', 'Enter')
+  if (IsMac) {
+    await page.keyboard.press('Meta+v')
+  } else {
+    await page.keyboard.press('Control+v')
+  }
+  await page.keyboard.press('Enter')
 
-    const blockRef = page.locator('.block-ref >> text="Some random text"');
+  const blockRef = page.locator('.block-ref >> text="Some random text"');
 
-    // Check if the newly created block-ref has the same referenced content
-    await expect(blockRef).toHaveCount(1);
+  // Check if the newly created block-ref has the same referenced content
+  await expect(blockRef).toHaveCount(1);
 
-    // Move cursor into the block ref
-    for (let i = 0; i < 4; i++) {
-        await page.press('textarea >> nth=0', 'ArrowLeft')
-}
+  // Move cursor into the block ref
+  for (let i = 0; i < 4; i++) {
+    await page.press('textarea >> nth=0', 'ArrowLeft')
+  }
 
-    // Trigger replace-block-reference-with-content-at-point
-    if (IsMac) {
-        await page.keyboard.press('Meta+Shift+r')
-    } else {
-        await page.keyboard.press('Control+Shift+v')
-    }
+  // Trigger replace-block-reference-with-content-at-point
+  if (IsMac) {
+    await page.keyboard.press('Meta+Shift+r')
+  } else {
+    await page.keyboard.press('Control+Shift+v')
+  }
 })
 
 test('copy and paste block after editing new block #5962', async ({ page, block }) => {
@@ -204,9 +204,9 @@ test('copy and paste block after editing new block #5962', async ({ page, block
 
   // Quickly paste the copied block
   if (IsMac) {
-      await page.keyboard.press('Meta+v')
+    await page.keyboard.press('Meta+v')
   } else {
-      await page.keyboard.press('Control+v')
+    await page.keyboard.press('Control+v')
   }
 
   await expect(page.locator('text="Typed block"')).toHaveCount(1);
@@ -216,13 +216,13 @@ test('undo and redo after starting an action should not destroy text #6267', asy
   await createRandomPage(page)
 
   // Get one piece of undo state onto the stack
-  await block.mustFill('text1 ')
-  await page.waitForTimeout(550) // Wait for 500ms autosave period to expire
+  await block.mustType('text1 ')
+  await page.waitForTimeout(500) // Wait for 500ms autosave period to expire
 
   // Then type more, start an action prompt, and undo
-  await page.keyboard.type('text2 ')
+  await page.keyboard.type('text2 ', { delay: 50 })
   for (const char of '[[') {
-    await page.keyboard.type(char)
+    await page.keyboard.type(char, { delay: 50 })
   }
   await expect(page.locator(`[data-modal-name="page-search"]`)).toBeVisible()
   if (IsMac) {
@@ -295,6 +295,7 @@ test('#6266 moving cursor outside of brackets should close autocomplete menu', a
       await page.keyboard.type(char)
       await page.waitForTimeout(10) // Sometimes it doesn't trigger without this
     }
+    await page.waitForTimeout(100)
     await autocompleteMenu.expectVisible(modalName)
 
     await page.waitForTimeout(100)
@@ -461,7 +462,8 @@ test('pressing backspace and remaining inside of brackets should NOT close autoc
     await page.keyboard.press('Backspace')
     await page.waitForTimeout(100)
     await autocompleteMenu.expectVisible(modalName)
-  }})
+  }
+})
 test('press escape when autocomplete menu is open, should close autocomplete menu only #6270', async ({ page, block }) => {
   for (const [commandTrigger, modalName] of [['[[', 'page-search'], ['/', 'commands']]) {
     await createRandomPage(page)

+ 1 - 3
e2e-tests/hotkey.spec.ts

@@ -5,10 +5,8 @@ import { createRandomPage, newBlock, lastBlock, IsMac, IsLinux } from './utils'
 test('open search dialog', async ({ page }) => {
   if (IsMac) {
     await page.keyboard.press('Meta+k')
-  } else if (IsLinux) {
-    await page.keyboard.press('Control+k')
   } else {
-    expect(false, "TODO: test on Windows and other platforms").toBeTruthy()
+    await page.keyboard.press('Control+k')
   }
 
   await page.waitForSelector('[placeholder="Search or create page"]')

+ 39 - 41
e2e-tests/logseq-url.spec.ts

@@ -2,46 +2,44 @@ import { expect } from '@playwright/test'
 import { test } from './fixtures'
 import { createRandomPage, lastBlock, IsMac, IsLinux } from './utils'
 
-test(
-  "Logseq URLs (same graph)",
-  async ({ page, block }) => {
-    let paste_key = IsMac ? 'Meta+v' : 'Control+v'
-    // create a page with identify block
-    let identify_text = "URL redirect target"
-    let page_title = await createRandomPage(page)
-    await block.mustFill(identify_text)
+test("Logseq URLs (same graph)", async ({ page, block }) => {
+  let paste_key = IsMac ? 'Meta+v' : 'Control+v'
+  // create a page with identify block
+  let identify_text = "URL redirect target"
+  let page_title = await createRandomPage(page)
+  await block.mustFill(identify_text)
 
-    // paste current page's URL to another page, then redirect throught the URL
-    await page.click('.ui__dropdown-trigger')
-    await page.locator("text=Copy page URL").click()
-    await createRandomPage(page)
-    await block.mustFill("") // to enter editing mode
-    await page.keyboard.press(paste_key)
-    let cursor_locator = page.locator('textarea >> nth=0')
-    expect(await cursor_locator.inputValue()).toContain("page=" + page_title)
-    await cursor_locator.press("Enter")
-    if (!IsLinux) { // FIXME: support Logseq URL on Linux (XDG)
-        page.locator('a.external-link >> nth=0').click()
-        await page.waitForNavigation()
-        await page.waitForTimeout(500)
-        cursor_locator = await lastBlock(page)
-        expect(await cursor_locator.inputValue()).toBe(identify_text)
-    }
+  // paste current page's URL to another page, then redirect throught the URL
+  await page.click('.ui__dropdown-trigger')
+  await page.locator("text=Copy page URL").click()
+  await createRandomPage(page)
+  await block.mustFill("") // to enter editing mode
+  await page.keyboard.press(paste_key)
+  let cursor_locator = page.locator('textarea >> nth=0')
+  expect(await cursor_locator.inputValue()).toContain("page=" + page_title)
+  await cursor_locator.press("Enter")
+  if (IsMac) { // FIXME: support Logseq URL on Linux (XDG)
+    page.locator('a.external-link >> nth=0').click()
+    await page.waitForNavigation()
+    await page.waitForTimeout(500)
+    cursor_locator = await lastBlock(page)
+    expect(await cursor_locator.inputValue()).toBe(identify_text)
+  }
 
-    // paste the identify block's URL to another page, then redirect throught the URL
-    await page.click('span.bullet >> nth=0', { button: "right" })
-    await page.locator("text=Copy block URL").click()
-    await createRandomPage(page)
-    await block.mustFill("") // to enter editing mode
-    await page.keyboard.press(paste_key)
-    cursor_locator = page.locator('textarea >> nth=0')
-    expect(await cursor_locator.inputValue()).toContain("block-id=")
-    await cursor_locator.press("Enter")
-    if (!IsLinux) { // FIXME: support Logseq URL on Linux (XDG)
-        page.locator('a.external-link >> nth=0').click()
-        await page.waitForNavigation()
-        await page.waitForTimeout(500)
-        cursor_locator = await lastBlock(page)
-        expect(await cursor_locator.inputValue()).toBe(identify_text)
-    }
-})
+  // paste the identify block's URL to another page, then redirect throught the URL
+  await page.click('span.bullet >> nth=0', { button: "right" })
+  await page.locator("text=Copy block URL").click()
+  await createRandomPage(page)
+  await block.mustFill("") // to enter editing mode
+  await page.keyboard.press(paste_key)
+  cursor_locator = page.locator('textarea >> nth=0')
+  expect(await cursor_locator.inputValue()).toContain("block-id=")
+  await cursor_locator.press("Enter")
+  if (IsMac) { // FIXME: support Logseq URL on Linux (XDG)
+    page.locator('a.external-link >> nth=0').click()
+    await page.waitForNavigation()
+    await page.waitForTimeout(500)
+    cursor_locator = await lastBlock(page)
+    expect(await cursor_locator.inputValue()).toBe(identify_text)
+  }
+})

+ 2 - 0
resources/css/common.css

@@ -85,6 +85,7 @@ html[data-theme='dark'] {
   --ls-search-icon-color: var(--ls-link-text-color);
   --ls-a-chosen-bg: var(--ls-secondary-background-color);
   --ls-right-sidebar-code-bg-color: #04303c;
+  --ls-focus-ring-color: rgba(18, 98, 119, 0.5);
   --color-level-1: var(--ls-secondary-background-color);
   --color-level-2: var(--ls-tertiary-background-color);
   --color-level-3: var(--ls-quaternary-background-color);
@@ -145,6 +146,7 @@ html[data-theme='light'] {
   --ls-search-icon-color: var(--ls-icon-color);
   --ls-a-chosen-bg: #f7f7f7;
   --ls-right-sidebar-code-bg-color: var(--ls-secondary-background-color);
+  --ls-focus-ring-color: rgba(66, 133, 244, 0.5);
   --color-level-1: var(--ls-secondary-background-color);
   --color-level-2: var(--ls-tertiary-background-color);
   --color-level-3: var(--ls-quaternary-background-color);

+ 24 - 12
resources/css/tabler-extension.css

@@ -8,7 +8,7 @@
 
 @font-face {
   font-family: 'tabler-icons-extension';
-  src: url('../fonts/tabler-icons-extension.woff2?0wlio9') format('woff2');
+  src: url('../fonts/tabler-icons-extension.woff2?6rsxel') format('woff2');
   font-style: normal;
   font-weight: 400;
 }
@@ -27,46 +27,58 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
-.tie-block::before {
+.tie-app-feature::before {
   content: '\ea01';
 }
 
-.tie-block-search::before {
+.tie-block::before {
   content: '\ea02';
 }
 
-.tie-connector::before {
+.tie-block-search::before {
   content: '\ea03';
 }
 
-.tie-page::before {
+.tie-connector::before {
   content: '\ea04';
 }
 
-.tie-page-search::before {
+.tie-object-compact::before {
   content: '\ea05';
 }
 
-.tie-references-hide::before {
+.tie-object-expanded::before {
   content: '\ea06';
 }
 
-.tie-references-show::before {
+.tie-page::before {
   content: '\ea07';
 }
 
-.tie-select-cursor::before {
+.tie-page-search::before {
   content: '\ea08';
 }
 
-.tie-text::before {
+.tie-references-hide::before {
   content: '\ea09';
 }
 
-.tie-whiteboard::before {
+.tie-references-show::before {
   content: '\ea0a';
 }
 
-.tie-whiteboard-element::before {
+.tie-select-cursor::before {
   content: '\ea0b';
 }
+
+.tie-text::before {
+  content: '\ea0c';
+}
+
+.tie-whiteboard::before {
+  content: '\ea0d';
+}
+
+.tie-whiteboard-element::before {
+  content: '\ea0e';
+}

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


+ 3 - 3
src/electron/electron/backup_file.cljs

@@ -25,11 +25,11 @@
   (get-backup-dir* repo relative-path version-file-dir))
 
 (defn- truncate-old-versioned-files!
-  "reserve the latest 3 version files"
+  "reserve the latest 6 version files"
   [dir]
   (let [files (fs/readdirSync dir (clj->js {:withFileTypes true}))
         files (mapv #(.-name %) files)
-        old-versioned-files (drop 3 (reverse (sort files)))]
+        old-versioned-files (drop 6 (reverse (sort files)))]
     (doseq [file old-versioned-files]
       (fs-extra/removeSync (path/join dir file)))))
 
@@ -44,7 +44,7 @@
                :version-file-dir (get-version-file-dir repo relative-path))
         new-path (path/join dir*
                             (str (string/replace (.toISOString (js/Date.)) ":" "_")
-                                 ext))]
+                                 ".Desktop" ext))]
     (fs-extra/ensureDirSync dir*)
     (fs/writeFileSync new-path content)
     (fs/statSync new-path)

+ 22 - 20
src/main/frontend/components/block.cljs

@@ -2202,24 +2202,25 @@
                                            (editor-handler/unhighlight-blocks!)
                                            (state/set-editing! edit-input-id (:block/content block) block ""))}})
             (block-content config block edit-input-id block-id slide?))]
-          [:div.flex.flex-row.items-center
-           (when (and (:embed? config)
-                      (:embed-parent config))
-             [:a.opacity-70.hover:opacity-100.svg-small.inline
-              {:on-mouse-down (fn [e]
-                                (util/stop e)
-                                (when-let [block (:embed-parent config)]
-                                  (editor-handler/edit-block! block :max (:block/uuid block))))}
-              svg/edit])
-
-           (when block-reference-only?
-             [:a.opacity-70.hover:opacity-100.svg-small.inline
-              {:on-mouse-down (fn [e]
-                                (util/stop e)
-                                (editor-handler/edit-block! block :max (:block/uuid block)))}
-              svg/edit])
-
-           (when-not hide-block-refs-count? (block-refs-count block *hide-block-refs?))]]
+          (when-not hide-block-refs-count?
+            [:div.flex.flex-row.items-center
+             (when (and (:embed? config)
+                        (:embed-parent config))
+               [:a.opacity-70.hover:opacity-100.svg-small.inline
+                {:on-mouse-down (fn [e]
+                                  (util/stop e)
+                                  (when-let [block (:embed-parent config)]
+                                    (editor-handler/edit-block! block :max (:block/uuid block))))}
+                svg/edit])
+
+             (when block-reference-only?
+               [:a.opacity-70.hover:opacity-100.svg-small.inline
+                {:on-mouse-down (fn [e]
+                                  (util/stop e)
+                                  (editor-handler/edit-block! block :max (:block/uuid block)))}
+                svg/edit])
+
+             (block-refs-count block *hide-block-refs?)])]
 
          (when (and (not @*hide-block-refs?) (> refs-count 0))
            (let [refs-cp (state/get-component :block/linked-references)]
@@ -2242,6 +2243,7 @@
                               (let [id' (swap! *blocks-container-id inc)]
                                 (reset! *init-blocks-container-id id')
                                 id'))
+        block-el-id (str "ls-block-" blocks-container-id "-" uuid)
         config {:id (str uuid)
                 :db/id (:db/id block-entity)
                 :block? true
@@ -2250,10 +2252,10 @@
         edit? (state/sub [:editor/editing? edit-input-id])
         block (block/parse-title-and-body block)]
     (when (:block/content block)
-      [:div.single-block
+      [:div.single-block.ls-block
        {:class (str block-uuid)
         :id (str "ls-block-" blocks-container-id "-" block-uuid)}
-       (block-content-or-editor config block edit-input-id uuid (:block/heading-level block) edit? true)])))
+       (block-content-or-editor config block edit-input-id block-el-id (:block/heading-level block) edit? true)])))
 
 (rum/defc single-block-cp
   [block-uuid]

+ 75 - 50
src/main/frontend/components/page.cljs

@@ -190,29 +190,33 @@
               original-name]])]
          {:default-collapsed? false})]])))
 
-(rum/defcs page-title <
+(rum/defcs page-title < rum/reactive
   (rum/local false ::edit?)
+  (rum/local "" ::input-value)
   {:init (fn [state]
            (assoc state ::title-value (atom (nth (:rum/args state) 2))))}
   [state page-name icon title _format fmt-journal?]
   (when title
     (let [*title-value (get state ::title-value)
           *edit? (get state ::edit?)
+          *input-value (get state ::input-value)
           input-ref (rum/create-ref)
           repo (state/get-current-repo)
           hls-file? (pdf-assets/hls-file? title)
+          whiteboard-page? (model/whiteboard-page? page-name)
+          untitled? (and whiteboard-page? (parse-uuid page-name)) ;; normal page cannot be untitled right?
           title (if hls-file?
                   (pdf-assets/human-hls-filename-display title)
                   (if fmt-journal? (date/journal-title->custom-format title) title))
           old-name (or title page-name)
+          collide? #(and (not= (util/page-name-sanity-lc page-name)
+                               (util/page-name-sanity-lc @*title-value))
+                         (db/page-exists? page-name)
+                         (db/page-exists? @*title-value))
           confirm-fn (fn []
-                       (let [new-page-name (string/trim @*title-value)
-                             merge? (and (not= (util/page-name-sanity-lc page-name)
-                                               (util/page-name-sanity-lc @*title-value))
-                                         (db/page-exists? page-name)
-                                         (db/page-exists? @*title-value))]
+                       (let [new-page-name (string/trim @*title-value)]
                          (ui/make-confirm-modal
-                          {:title         (if merge?
+                          {:title         (if (collide?)
                                             (str "Page “" @*title-value "” already exists, merge to it?")
                                             (str "Do you really want to change the page name to “" new-page-name "”?"))
                            :on-confirm    (fn [_e {:keys [close-fn]}]
@@ -222,12 +226,13 @@
                            :on-cancel     (fn []
                                             (reset! *title-value old-name)
                                             (gobj/set (rum/deref input-ref) "value" old-name)
-                                            (reset! *edit? true))})))
+                                            (reset! *edit? true)
+                                            (.focus (rum/deref input-ref)))})))
           rollback-fn #(do
                          (reset! *title-value old-name)
                          (gobj/set (rum/deref input-ref) "value" old-name)
                          (reset! *edit? false)
-                         (notification/show! "Illegal page name, can not rename!" :warning))
+                         (when-not untitled? (notification/show! "Illegal page name, can not rename!" :warning)))
           blur-fn (fn [e]
                     (when (gp-util/wrapped-by-quotes? @*title-value)
                       (swap! *title-value gp-util/unquote-string)
@@ -239,51 +244,71 @@
                       (string/blank? @*title-value)
                       (rollback-fn)
 
+                      (and (collide?) whiteboard-page?)
+                      (notification/show! (str "Page “" @*title-value "” already exists!") :error)
+
+                      untitled?
+                      (page-handler/rename! (or title page-name) @*title-value)
+
                       :else
                       (state/set-modal! (confirm-fn)))
                     (util/stop e))]
-      (if @*edit?
-        [:span
-         {:class (util/classnames [{:editing @*edit?}])
-          :style {:width "600px"}}
-         [:input.edit-input
-          {:type          "text"
-           :ref           input-ref
-           :auto-focus    true
-           :style         {:outline "none"
-                           :width "100%"
-                           :font-weight 600}
-           :auto-complete (if (util/chrome?) "chrome-off" "off") ; off not working here
-           :default-value old-name
-           :on-change     (fn [^js e]
-                            (let [value (util/evalue e)]
-                              (reset! *title-value (string/trim value))))
-           :on-blur       blur-fn
-           :on-key-down   (fn [^js e]
-                            (when (= (gobj/get e "key") "Enter")
-                              (blur-fn e)))
-           :on-key-up     (fn [^js e]
+      [:h1.page-title.flex.gap-1
+       {:on-mouse-down (fn [e]
+                         (when (util/right-click? e)
+                           (state/set-state! :page-title/context {:page page-name})))
+        :on-click (fn [e]
+                    (.preventDefault e)
+                    (if (gobj/get e "shiftKey")
+                      (when-let [page (db/pull repo '[*] [:block/name page-name])]
+                        (state/sidebar-add-block!
+                         repo
+                         (:db/id page)
+                         :page))
+                      (when (and (not hls-file?) (not fmt-journal?))
+                        (reset! *input-value (if untitled? "" old-name))
+                        (reset! *edit? true))))}
+       (when (not= icon "") [:span.page-icon icon])
+       [:div.page-title-sizer-wrapper.relative
+        (when (rum/react *edit?)
+          [:span.absolute.inset-0
+           {:class (util/classnames [{:editing @*edit?}])}
+           [:input.edit-input
+            {:type          "text"
+             :ref           input-ref
+             :auto-focus    true
+             :style         {:outline "none"
+                             :width "100%"
+                             :font-weight "inherit"}
+             :auto-complete (if (util/chrome?) "chrome-off" "off") ; off not working here
+             :value         (rum/react *input-value)
+             :on-change     (fn [^js e]
+                              (let [value (util/evalue e)]
+                                (reset! *title-value (string/trim value))
+                                (reset! *input-value value)))
+             :on-blur       blur-fn
+             :on-key-down   (fn [^js e]
+                              (when (= (gobj/get e "key") "Enter")
+                                (blur-fn e)))
+             :placeholder   (when untitled? (t :untitled))
+             :on-key-up     (fn [^js e]
                             ;; Esc
-                            (when (= 27 (.-keyCode e))
-                              (reset! *title-value old-name)
-                              (reset! *edit? false)))
-           :on-focus (fn [] (js/setTimeout #(.select input-ref.current)))}]]
-        [:a.page-title {:on-mouse-down (fn [e]
-                                         (when (util/right-click? e)
-                                           (state/set-state! :page-title/context {:page page-name})))
-                        :on-click (fn [e]
-                                    (.preventDefault e)
-                                    (if (gobj/get e "shiftKey")
-                                      (when-let [page (db/pull repo '[*] [:block/name page-name])]
-                                        (state/sidebar-add-block!
-                                         repo
-                                         (:db/id page)
-                                         :page))
-                                      (when (and (not hls-file?) (not fmt-journal?))
-                                        (reset! *edit? true))))}
-         [:span.title {:data-ref page-name}
-          (when (not= icon "") [:span.page-icon icon])
-          title]]))))
+                              (when (= 27 (.-keyCode e))
+                                (reset! *title-value old-name)
+                                (reset! *edit? false)))
+             :on-focus (fn []
+                         (when untitled? (reset! *title-value ""))
+                         (js/setTimeout #(when-let [input (rum/deref input-ref)] (.select input))))}]])
+        [:span.title.inline-block
+         {:data-value (rum/react *input-value)
+          :data-ref page-name
+          :style {:opacity (when @*edit? 0)
+                  :pointer-events "none"
+                  :font-weight "inherit"
+                  :min-width "80px"}}
+         (cond @*edit? [:span {:style {:white-space "pre"}} (rum/react *input-value)]
+               untitled? [:span.opacity-50 (t :untitled)]
+               :else title)]]])))
 
 (defn- page-mouse-over
   [e *control-show? *all-collapsed?]

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

@@ -37,7 +37,7 @@
         repos (util/distinct-by :url repos)]
     (if (seq repos)
       [:div#graphs
-       [:h1.title "All Graphs"]
+       [:h1.title (t :all-graphs)]
        [:p.ml-2.opacity-70
         "A \"graph\" in Logseq means a local directory."]
 
@@ -49,9 +49,9 @@
             (ui/button
               (t :open-a-directory)
               :on-click #(page-handler/ls-dir-files! shortcut/refresh!))])]
-        (for [{:keys [id url] :as repo} repos]
+        (for [{:keys [url] :as repo} repos]
           (let [local? (config/local-db? url)]
-            [:div.flex.justify-between.mb-4 {:key id}
+            [:div.flex.justify-between.mb-4 {:key (str "id-" url)}
              (if local?
                (let [local-dir (config/get-local-dir url)
                      graph-name (text-util/get-graph-name-from-path local-dir)]
@@ -71,7 +71,7 @@
                {:title "No worries, unlink this graph will clear its cache only, it does not remove your files on the disk."
                 :on-click (fn []
                             (repo-handler/remove-repo! repo))}
-               "Unlink"]]]))]]
+               (t :unlink)]]]))]]
       (widgets/add-graph))))
 
 (defn refresh-cb []

+ 29 - 20
src/main/frontend/components/whiteboard.cljs

@@ -3,6 +3,7 @@
             [datascript.core :as d]
             [frontend.components.page :as page]
             [frontend.components.reference :as reference]
+            [frontend.context.i18n :refer [t]]
             [frontend.db.model :as model]
             [frontend.handler.route :as route-handler]
             [frontend.handler.whiteboard :as whiteboard-handler]
@@ -83,16 +84,18 @@
 (defn- get-page-display-name
   [page-name]
   (let [page-entity (model/get-page page-name)]
-    (or (get-in page-entity [:block/properties :title] nil)
-        (:block/original-name page-entity)
-        page-name)))
+    (or
+     (get-in page-entity [:block/properties :title] nil)
+     (:block/original-name page-entity)
+     page-name)))
 
 ;; This is not accurate yet
-;; (defn- get-page-human-update-time
-;;   [page-name]
-;;   (let [page-entity (model/get-page page-name)
-;;         updated-at (:block/updated-at page-entity)]
-;;     (str "Edited at " (util/time-ago (js/Date. updated-at)))))
+(defn- get-page-human-update-time
+  [page-name]
+  (let [page-entity (model/get-page page-name)
+        {:block/keys [updated-at created-at]} page-entity]
+    (str (if (= created-at updated-at) "Created " "Edited ")
+         (util/time-ago (js/Date. updated-at)))))
 
 (rum/defc dashboard-preview-card
   [page-name]
@@ -103,14 +106,15 @@
       (route-handler/redirect-to-whiteboard! page-name))}
    [:div.dashboard-card-title
     [:div.flex.w-full
-     [:div.dashboard-card-title-name (get-page-display-name page-name)]
+     [:div.dashboard-card-title-name.font-bold
+      (if (parse-uuid page-name)
+        [:span.opacity-50 (t :untitled)]
+        (get-page-display-name page-name))]
+     [:div.flex-1]]
+    [:div.flex.w-full.opacity-50
+     [:div (get-page-human-update-time page-name)]
      [:div.flex-1]
-     (page-refs-count page-name nil)]
-    ;; [:div.flex.w-full
-    ;;  [:div (get-page-human-update-time page-name)]
-    ;;  [:div.flex-1]
-    ;;  (page-refs-count page-name)]
-    ]
+     (page-refs-count page-name nil)]]
    [:div.p-4.h-64.flex.justify-center
     (tldraw-preview page-name)]])
 
@@ -145,7 +149,9 @@
 
 (rum/defc whiteboard-dashboard
   []
-  (let [whiteboards (model/get-all-whiteboards (state/get-current-repo))
+  (let [whiteboards (->> (model/get-all-whiteboards (state/get-current-repo))
+                         (sort-by :block/updated-at)
+                         reverse)
         whiteboard-names (map :block/name whiteboards)
         ref (rum/use-ref nil)
         rect (use-component-size ref)
@@ -187,12 +193,15 @@
     [:span.whiteboard-page-title
      {:style {:color "var(--ls-primary-text-color)"
               :user-select "none"}}
-     (page/page-title name [:span.tie.tie-whiteboard
-                            {:style {:font-size "0.9em"}}]
-                      (get-page-display-name name) nil false)]
+     (page/page-title name
+                      [:span.tie.tie-whiteboard
+                       {:style {:font-size "0.9em"}}]
+                      (get-page-display-name name)
+                      nil
+                      false)]
 
     (page-refs-count name
-                     "text-md px-3 py-1 cursor-default whiteboard-page-refs-count"
+                     "text-md px-3 py-2 cursor-default whiteboard-page-refs-count"
                      (fn [open?] [:<> "Reference" (ui/icon (if open? "references-hide" "references-show"))]))]
    (tldraw-app name block-id)])
 

+ 9 - 2
src/main/frontend/components/whiteboard.css

@@ -51,7 +51,7 @@
 }
 
 .dashboard-card-title {
-  @apply px-4 py-1 flex flex-col;
+  @apply px-4 py-3 flex flex-col;
   gap: 4px;
   border-bottom: 1px solid var(--ls-border-color);
   background-color: var(--ls-secondary-background-color);
@@ -107,15 +107,22 @@
   border-radius: 0 0 12px 12px;
   z-index: 2000;
   gap: 4px;
+  line-height: 1.4;
 }
 
 .whiteboard-page-title {
-  @apply inline-flex px-2;
+  @apply inline-flex px-2 py-1;
   font-size: 20px;
   border-radius: 8px;
+  border: 1px solid transparent;
   background: var(--ls-secondary-background-color);
 }
 
 .whiteboard-page-title:hover {
   background-color: var(--ls-tertiary-background-color);
 }
+
+.whiteboard-page-title:focus-within {
+  border: 1px solid var(--ls-border-color);
+  box-shadow: 0 0 0 4px var(--ls-focus-ring-color);
+}

+ 15 - 3
src/main/frontend/config.cljs

@@ -310,11 +310,15 @@
     path
     (util/node-path.join (get-repo-dir repo-url) path)))
 
+;; FIXME: There is another get-file-path at src/main/frontend/fs/capacitor_fs.cljs
 (defn get-file-path
   "Normalization happens here"
   [repo-url relative-path]
   (when (and repo-url relative-path)
     (let [path (cond
+                 (demo-graph?)
+                 nil
+
                  (and (util/electron?) (local-db? repo-url))
                  (let [dir (get-repo-dir repo-url)]
                    (if (string/starts-with? relative-path dir)
@@ -324,7 +328,7 @@
 
                  (and (mobile-util/native-ios?) (local-db? repo-url))
                  (let [dir (get-repo-dir repo-url)]
-                   (js/decodeURI (str dir relative-path)))
+                   (str dir relative-path))
 
                  (and (mobile-util/native-android?) (local-db? repo-url))
                  (let [dir (get-repo-dir repo-url)
@@ -332,14 +336,22 @@
                                    (string/starts-with? dir "content:"))
                              dir
                              (str "file:///" (string/replace dir #"^/+" "")))]
-                   (str (string/replace dir #"/+$" "") "/" relative-path))
+                   (util/safe-path-join dir relative-path))
 
                  (= "/" (first relative-path))
                  (subs relative-path 1)
 
                  :else
                  relative-path)]
-      (gp-util/path-normalize path))))
+      (and (not-empty path) (gp-util/path-normalize path)))))
+
+(defn get-page-file-path
+  "Get the path to the page file for the given page. This is used when creating new files."
+  [repo-url sub-dir page-name ext]
+  (let [page-basename (if (mobile-util/native-platform?)
+                        (util/url-encode page-name)
+                        page-name)]
+    (get-file-path repo-url (str sub-dir "/" page-basename "." ext))))
 
 (defn get-config-path
   ([]

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

@@ -55,6 +55,7 @@
         :highlight "Highlight"
         :strikethrough "Strikethrough"
         :code "Code"
+        :untitled "Untitled"
         :right-side-bar/help "Help"
         :right-side-bar/switch-theme "Theme modes"
         :right-side-bar/theme "{1} theme"
@@ -617,8 +618,7 @@
         :user/delete-your-account "Ihr Konto löschen"
 
         :file-sync/other-user-graph "Aktuelle lokale Grafik ist an das Remote-Graph des anderen Benutzers gebunden. Kann also nicht mit der Synchronisierung beginnen."
-        :file-sync/graph-deleted "Das aktuelle Ferndiagramm wurde gelöscht"
-        }
+        :file-sync/graph-deleted "Das aktuelle Ferndiagramm wurde gelöscht"}
    :nl {
         :all-files "Alle bestanden"
         :all-graphs "Alle grafieken"
@@ -1158,6 +1158,7 @@
            :highlight "高亮"
            :strikethrough "删除线"
            :code "代码"
+           :untitled "未命名"
            :discourse-title "我们的论坛"
            :export-datascript-edn "导出 datascript EDN"
            :export-edn "导出为 EDN"
@@ -4083,6 +4084,7 @@
         :right-side-bar/all-pages "Bütün sayfalar"
         :right-side-bar/flashcards "Bilgi kartları"
         :right-side-bar/new-page "Yeni sayfa"
+        :right-side-bar/show-journals "Günlükleri Göster"
         :left-side-bar/journals "Günlük"
         :left-side-bar/new-page "Yeni sayfa"
         :left-side-bar/nav-favorites "Sık kullanılanlar"
@@ -4131,6 +4133,7 @@
         :page/created-at "Oluşturulma Zamanı"
         :page/updated-at "Güncellenme Zamanı"
         :page/backlinks "Geri Bağlantılar"
+        :linked-references/filter-search "Bağlantılı sayfalarda ara"
         :editor/block-search "Blok ara"
         :editor/image-uploading "Karşıya yükleniyor"
         :draw/invalid-file "Bu geçersiz excalidraw dosyası yüklenemedi"

+ 25 - 11
src/main/frontend/extensions/tldraw.cljs

@@ -4,6 +4,7 @@
             [frontend.components.page :as page]
             [frontend.db.model :as model]
             [frontend.handler.editor :as editor-handler]
+            [frontend.handler.route :as route-handler]
             [frontend.handler.search :as search]
             [frontend.handler.whiteboard :as whiteboard-handler]
             [frontend.rum :as r]
@@ -54,6 +55,28 @@
          (when-let [[asset-file-name _ full-file-path] (and (seq res) (first res))]
            (editor-handler/resolve-relative-path (or full-file-path asset-file-name)))))))
 
+(def tldraw-renderers {:Page page-cp
+                       :Block block-cp
+                       :Breadcrumb breadcrumb
+                       :PageNameLink page-name-link})
+
+(defn get-tldraw-handlers [name]
+  {:search search-handler
+   :queryBlockByUUID #(clj->js (model/query-block-by-uuid (parse-uuid %)))
+   :isWhiteboardPage model/whiteboard-page?
+   :saveAsset save-asset-handler
+   :makeAssetUrl editor-handler/make-asset-url
+   :addNewBlock (fn [content]
+                  (str (whiteboard-handler/add-new-block! name content)))
+   :sidebarAddBlock (fn [uuid type]
+                      (state/sidebar-add-block! (state/get-current-repo)
+                                                (:db/id (model/get-page uuid))
+                                                (keyword type)))
+   :redirectToPage (fn [page-name]
+                     (if (model/whiteboard-page? page-name)
+                         (route-handler/redirect-to-whiteboard! page-name)
+                         (route-handler/redirect-to-page! page-name)))})
+
 (rum/defc tldraw-app
   [name block-id]
   (let [data (whiteboard-handler/page-name->tldr! name block-id)
@@ -77,17 +100,8 @@
         ;; wheel -> overscroll may cause browser navigation
         :on-wheel util/stop-propagation}
 
-       (tldraw {:renderers {:Page page-cp
-                            :Block block-cp
-                            :Breadcrumb breadcrumb
-                            :PageNameLink page-name-link}
-                :handlers (clj->js {:search search-handler
-                                    :queryBlockByUUID #(clj->js (model/query-block-by-uuid (parse-uuid %)))
-                                    :isWhiteboardPage model/whiteboard-page?
-                                    :saveAsset save-asset-handler
-                                    :makeAssetUrl editor-handler/make-asset-url
-                                    :addNewBlock (fn [content]
-                                                   (str (whiteboard-handler/add-new-block! name content)))})
+       (tldraw {:renderers tldraw-renderers
+                :handlers (get-tldraw-handlers name)
                 :onMount (fn [app] (set-tln ^js app))
                 :onPersist (fn [app]
                              (let [document (gobj/get app "serialized")]

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

@@ -70,13 +70,14 @@ and handles unexpected failure."
          (let [ast (->> (format/to-edn content format (gp-mldoc/default-config format))
                         (map first))
                title (when (gp-block/heading-block? (first ast))
-                       (:title (second (first ast))))
+                       (second (first ast)))
                body (vec (if title (rest ast) ast))
                body (drop-while gp-property/properties-ast? body)
                result (cond->
                        (if (seq body) {:block/body body} {})
                         title
-                        (assoc :block/title title))]
+                        (assoc :block/title (:title title)
+                               :block/heading-level (:size title)))]
            (state/add-block-ast-cache! block-uuid content result)
            result))))))
 

+ 107 - 93
src/main/frontend/fs/capacitor_fs.cljs

@@ -18,27 +18,58 @@
     []
     (.ensureDocuments mobile-util/ios-file-container)))
 
-(defn check-permission-android []
-  (p/let [permission (.checkPermissions Filesystem)
-          permission (-> permission
-                         bean/->clj
-                         :publicStorage)]
-    (when-not (= permission "granted")
-      (p/do!
-       (.requestPermissions Filesystem)))))
+(when (mobile-util/native-android?)
+  (defn- android-check-permission []
+    (p/let [permission (.checkPermissions Filesystem)
+            permission (-> permission
+                           bean/->clj
+                           :publicStorage)]
+      (when-not (= permission "granted")
+        (p/do!
+         (.requestPermissions Filesystem))))))
 
-(defn- clean-uri
-  [uri]
-  (when (string? uri)
-    (util/url-decode uri)))
+(defn- <write-file-with-utf8
+  [path content]
+  (when-not (string/blank? path)
+    (-> (p/chain (.writeFile Filesystem (clj->js {:path path
+                                                  :data content
+                                                  :encoding (.-UTF8 Encoding)
+                                                  :recursive true}))
+                 #(js->clj % :keywordize-keys true))
+        (p/catch (fn [error]
+                   (js/console.error "writeFile Error: " path ": " error)
+                   nil)))))
 
-(defn- read-file-utf8
+(defn- <read-file-with-utf8
   [path]
   (when-not (string/blank? path)
-    (.readFile Filesystem
-               (clj->js
-                {:path path
-                 :encoding (.-UTF8 Encoding)}))))
+    (-> (p/chain (.readFile Filesystem (clj->js {:path path
+                                                 :encoding (.-UTF8 Encoding)}))
+                 #(js->clj % :keywordize-keys true)
+                 #(get % :data nil))
+        (p/catch (fn [error]
+                   (js/console.error "readFile Error: " path ": " error)
+                   nil)))))
+
+(defn- <readdir [path]
+  (-> (p/chain (.readdir Filesystem (clj->js {:path path}))
+               js->clj
+               #(get % "files" nil))
+      (p/catch (fn [error]
+                 (js/console.error "readdir Error: " path ": " error)
+                 nil))))
+
+(defn- <stat [path]
+  (-> (p/chain (.stat Filesystem (clj->js {:path path}))
+               #(js->clj % :keywordize-keys true)
+               #(update % :type (fn [v]
+                                  (case v
+                                    "NSFileTypeDirectory" "directory"
+                                    "NSFileTypeRegular" "file"
+                                    v))))
+      (p/catch (fn [error]
+                 (js/console.error "stat Error: " path ": " error)
+                 nil))))
 
 (defn readdir
   "readdir recursively"
@@ -48,10 +79,7 @@
                    (if (empty? dirs)
                      result
                      (p/let [d (first dirs)
-                             files (.readdir Filesystem (clj->js {:path d}))
-                             files (-> files
-                                       js->clj
-                                       (get "files" []))
+                             files (<readdir d)
                              files (->> files
                                         (remove (fn [file]
                                                   (or (string/starts-with? file ".")
@@ -61,44 +89,31 @@
                                                       (= file "bak")))))
                              files (->> files
                                         (map (fn [file]
+                                               ;; TODO: use uri-join
                                                (str (string/replace d #"/+$" "")
                                                     "/"
                                                     (if (mobile-util/native-ios?)
-                                                      (util/url-encode file)
+                                                      (js/encodeURI file)
                                                       file)))))
-                             files-with-stats (p/all
-                                               (mapv
-                                                (fn [file]
-                                                  (p/chain
-                                                   (.stat Filesystem (clj->js {:path file}))
-                                                   #(js->clj % :keywordize-keys true)))
-                                                files))
+                             files-with-stats (p/all (mapv <stat files))
                              files-dir (->> files-with-stats
-                                            (filterv
-                                             (fn [{:keys [type]}]
-                                               (contains? #{"directory" "NSFileTypeDirectory"} type)))
+                                            (filterv #(= (:type %) "directory"))
                                             (mapv :uri))
                              files-result
                              (p/all
                               (->> files-with-stats
-                                   (filter
-                                    (fn [{:keys [type]}]
-                                      (contains? #{"file" "NSFileTypeRegular"} type)))
+                                   (filter #(= (:type %) "file"))
                                    (filter
                                     (fn [{:keys [uri]}]
                                       (some #(string/ends-with? uri %)
                                             [".md" ".markdown" ".org" ".edn" ".css"])))
                                    (mapv
                                     (fn [{:keys [uri] :as file-result}]
-                                      (p/chain
-                                       (read-file-utf8 uri)
-                                       #(js->clj % :keywordize-keys true)
-                                       :data
-                                       #(assoc file-result :content %))))))]
+                                      (p/chain (<read-file-with-utf8 uri)
+                                               #(assoc file-result :content %))))))]
                        (p/recur (concat result files-result)
-                                (concat (rest dirs) files-dir)))))
-          result (js->clj result :keywordize-keys true)]
-    (map (fn [result] (update result :uri clean-uri)) result)))
+                                (concat (rest dirs) files-dir)))))]
+    (js->clj result :keywordize-keys true)))
 
 (defn- contents-matched?
   [disk-content db-content]
@@ -113,36 +128,50 @@
   [repo-dir path ext]
   (let [relative-path (-> (string/replace path repo-dir "")
                           (string/replace (str "." ext) ""))]
-    (str repo-dir backup-dir "/" relative-path)))
+    (util/safe-path-join repo-dir (str backup-dir "/" relative-path))))
 
 (defn- truncate-old-versioned-files!
-  "reserve the latest 3 version files"
+  "reserve the latest 6 version files"
   [dir]
   (p/let [files (readdir dir)
           files (js->clj files :keywordize-keys true)
-          old-versioned-files (drop 3 (reverse (sort-by :mtime files)))]
+          old-versioned-files (drop 6 (reverse (sort-by :mtime files)))]
     (mapv (fn [file]
-            (.deleteFile Filesystem (clj->js {:path (js/encodeURI (:uri file))})))
+            (.deleteFile Filesystem (clj->js {:path (:uri file)})))
           old-versioned-files)))
 
 (defn backup-file
   [repo-dir path content ext]
   (let [backup-dir (get-backup-dir repo-dir path ext)
         new-path (str backup-dir "/" (string/replace (.toISOString (js/Date.)) ":" "_") "." ext)]
-    (.writeFile Filesystem (clj->js {:data content
-                                     :path new-path
-                                     :encoding (.-UTF8 Encoding)
-                                     :recursive true}))
+    (<write-file-with-utf8 new-path content)
     (truncate-old-versioned-files! backup-dir)))
 
+(defn backup-file-handle-changed!
+  [repo-dir file-path content]
+  (let [divider-schema    "://"
+        file-schema       (string/split file-path divider-schema)
+        file-schema       (if (> (count file-schema) 1) (first file-schema) "")
+        dir-schema?       (and (string? repo-dir)
+                               (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-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))]
+    (<write-file-with-utf8 file-path content)
+    (truncate-old-versioned-files! file-root)))
+
 (defn- write-file-impl!
   [_this repo _dir path content {:keys [ok-handler error-handler old-content skip-compare?]} stat]
   (if skip-compare?
     (p/catch
-     (p/let [result (.writeFile Filesystem (clj->js {:path path
-                                                     :data content
-                                                     :encoding (.-UTF8 Encoding)
-                                                     :recursive true}))]
+     (p/let [result (<write-file-with-utf8 path content)]
        (when ok-handler
          (ok-handler repo path result)))
      (fn [error]
@@ -150,16 +179,12 @@
          (error-handler error)
          (log/error :write-file-failed error))))
 
-    (p/let [disk-content (-> (p/chain (read-file-utf8 path)
-                                      #(js->clj % :keywordize-keys true)
-                                      :data)
-                             (p/catch (fn [error]
-                                        (js/console.error error)
-                                        nil)))
+    ;; Compare with disk content and backup if not equal
+    (p/let [disk-content (<read-file-with-utf8 path)
             disk-content (or disk-content "")
             repo-dir (config/get-local-dir repo)
-            ext (string/lower-case (util/get-file-ext path))
-            db-content (or old-content (db/get-file repo (js/decodeURI path)) "")
+            ext (util/get-file-ext path)
+            db-content (or old-content (db/get-file repo path) "")
             contents-matched? (contents-matched? disk-content db-content)
             pending-writes (state/get-write-chan-length)]
       (cond
@@ -174,10 +199,7 @@
 
         :else
         (->
-         (p/let [result (.writeFile Filesystem (clj->js {:path path
-                                                         :data content
-                                                         :encoding (.-UTF8 Encoding)
-                                                         :recursive true}))
+         (p/let [result (<write-file-with-utf8 path content)
                  mtime (-> (js->clj stat :keywordize-keys true)
                            :mtime)]
            (when-not contents-matched?
@@ -186,7 +208,7 @@
            (p/let [content (if (encrypt/encrypted-db? (state/get-current-repo))
                              (encrypt/decrypt content)
                              content)]
-             (db/set-file-content! repo (js/decodeURI path) content))
+             (db/set-file-content! repo path content))
            (when ok-handler
              (ok-handler repo path result))
            result)
@@ -196,25 +218,19 @@
                       (log/error :write-file-failed error)))))))))
 
 (defn get-file-path [dir path]
-  (let [[dir path] (map #(some-> %
-                                 js/decodeURI)
-                        [dir path])
-        dir (some-> dir (string/replace #"/+$" ""))
-        path (some-> path (string/replace #"^/+" ""))
-        path (cond (nil? path)
-                   dir
+  (let [dir (some-> dir (string/replace #"/+$" ""))
+        path (some-> path (string/replace #"^/+" ""))]
+    (cond (nil? path)
+          dir
 
-                   (nil? dir)
-                   path
+          (nil? dir)
+          path
 
-                   (string/starts-with? path dir)
-                   path
+          (string/starts-with? path dir)
+          path
 
-                   :else
-                   (str dir "/" path))]
-    (if (mobile-util/native-ios?)
-      (js/encodeURI (js/decodeURI path))
-      path)))
+          :else
+          (str dir "/" path))))
 
 (defn- local-container-path?
   "Check whether `path' is logseq's container `localDocumentsPath' on iOS"
@@ -263,7 +279,7 @@
                    (string/replace-first path "file://" "")
                    path)
             repo-dir (config/get-local-dir repo)
-            recycle-dir (str repo-dir config/app-name "/.recycle")
+            recycle-dir (str repo-dir config/app-name "/.recycle") ;; logseq/.recycle
             file-name (-> (string/replace path repo-dir "")
                           (string/replace "/" "_")
                           (string/replace "\\" "_"))
@@ -276,11 +292,7 @@
   (read-file [_this dir path _options]
     (let [path (get-file-path dir path)]
       (->
-       (p/let [content (read-file-utf8 path)
-               content (-> (js->clj content :keywordize-keys true)
-                           :data
-                           clj->js)]
-         content)
+       (<read-file-with-utf8 path)
        (p/catch (fn [error]
                   (log/error :read-file-failed error))))))
   (write-file! [this repo dir path content opts]
@@ -288,6 +300,7 @@
       (p/let [stat (p/catch
                     (.stat Filesystem (clj->js {:path path}))
                     (fn [_e] :not-found))]
+        ;; `path` is full-path
         (write-file-impl! this repo dir path content opts stat))))
   (rename! [_this _repo old-path new-path]
     (let [[old-path new-path] (map #(get-file-path "" %) [old-path new-path])]
@@ -306,16 +319,17 @@
                                          }))]
         result)))
   (open-dir [_this _ok-handler]
-    (p/let [_    (when (= (mobile-util/platform) "android") (check-permission-android))
+    (p/let [_ (when (mobile-util/native-android?) (android-check-permission))
             {:keys [path localDocumentsPath]} (-> (.pickFolder mobile-util/folder-picker)
                                                   (p/then #(js->clj % :keywordize-keys true))
                                                   (p/catch (fn [e]
                                                              (js/alert (str e))
-                                                             nil))) ;; NOTE: Can not pick folder, let it crash
+                                                             nil))) ;; NOTE: If pick folder fails, let it crash
             _ (when (and (mobile-util/native-ios?)
                          (not (or (local-container-path? path localDocumentsPath)
                                   (mobile-util/iCloud-container-path? path))))
                 (state/pub-event! [:modal/show-instruction]))
+            _ (js/console.log "Opening or Creating graph at directory: " path)
             files (readdir path)
             files (js->clj files :keywordize-keys true)]
       (into [] (concat [{:path path}] files))))
@@ -324,6 +338,6 @@
   (watch-dir! [_this dir]
     (p/do!
      (.unwatch mobile-util/fs-watcher)
-     (.watch mobile-util/fs-watcher #js {:path dir})))
+     (.watch mobile-util/fs-watcher (clj->js {:path dir}))))
   (unwatch-dir! [_this _dir]
     (.unwatch mobile-util/fs-watcher)))

+ 5 - 2
src/main/frontend/fs/watcher_handler.cljs

@@ -11,7 +11,6 @@
             [logseq.graph-parser.util :as gp-util]
             [logseq.graph-parser.util.block-ref :as block-ref]
             [lambdaisland.glogi :as log]
-            [electron.ipc :as ipc]
             [promesa.core :as p]
             [frontend.state :as state]
             [frontend.encrypt :as encrypt]
@@ -35,7 +34,11 @@
   [repo path content db-content mtime backup?]
   (p/let [
           ;; save the previous content in a versioned bak file to avoid data overwritten.
-          _ (when backup? (ipc/ipc "backupDbFile" (config/get-local-dir repo) path db-content content))
+          _ (when backup?
+              (-> (when-let [repo-dir (config/get-local-dir repo)]
+                    (file-handler/backup-file! repo-dir path db-content content))
+                  (p/catch #(js/console.error "❌ Bak Error: " path %))))
+
           _ (file-handler/alter-file repo path content {:re-render-root? true
                                                         :from-disk? true})]
     (set-missing-block-ids! content)

+ 28 - 15
src/main/frontend/handler.cljs

@@ -1,5 +1,6 @@
 (ns frontend.handler
   (:require [cljs.reader :refer [read-string]]
+            [clojure.string :as string]
             [electron.ipc :as ipc]
             [electron.listener :as el]
             [frontend.components.block :as block]
@@ -8,7 +9,7 @@
             [frontend.components.reference :as reference]
             [frontend.components.whiteboard :as whiteboard]
             [frontend.config :as config]
-            [frontend.context.i18n :as i18n]
+            [frontend.context.i18n :as i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db.conn :as conn]
             [frontend.db.persist :as db-persist]
@@ -30,6 +31,7 @@
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.state :as state]
             [frontend.storage :as storage]
+            [frontend.ui :as ui]
             [frontend.util :as util]
             [frontend.util.persist-var :as persist-var]
             [goog.object :as gobj]
@@ -85,7 +87,7 @@
        {:repos repos}
        old-db-schema
        (fn [repo]
-         (file-handler/restore-config! repo false)))
+         (file-handler/restore-config! repo)))
       (p/then
        (fn []
          ;; try to load custom css only for current repo
@@ -107,7 +109,7 @@
            (state/set-db-restoring! false))))
       (p/then
        (fn []
-         (prn "db restored, setting up repo hooks")
+         (js/console.log "db restored, setting up repo hooks")
          (store-schema!)
 
          (state/pub-event! [:modal/nfs-ask-permission])
@@ -146,18 +148,6 @@
                                (.postMessage js/window #js {":datalog-console.remote/remote-message" (pr-str db)} "*")
 
                                nil)))))))
-(defn- get-repos
-  []
-  (p/let [nfs-dbs (db-persist/get-all-graphs)
-          nfs-dbs (map (fn [db]
-                         {:url db :nfs? true}) nfs-dbs)]
-    (cond
-      (seq nfs-dbs)
-      nfs-dbs
-
-      :else
-      [{:url config/local-repo
-        :example? true}])))
 
 (defn clear-cache!
   []
@@ -171,6 +161,29 @@
               (js/window.location.reload)))
      2000)))
 
+(defn- get-repos
+  []
+  (p/let [nfs-dbs (db-persist/get-all-graphs)]
+    ;; TODO: Better IndexDB migration handling
+    (cond
+      (and (mobile-util/native-platform?)
+           (some #(or (string/includes? % " ")
+                      (string/includes? % "logseq_local_/")) nfs-dbs))
+      (do (notification/show! ["DB version is not compatible, please clear cache then re-add your graph back."
+                               (ui/button
+                                (t :settings-page/clear-cache)
+                                :class    "text-sm p-1"
+                                :on-click clear-cache!)] :error false)
+          {:url config/local-repo
+           :example? true})
+
+      (seq nfs-dbs)
+      (map (fn [db] {:url db :nfs? true}) nfs-dbs)
+
+      :else
+      [{:url config/local-repo
+        :example? true}])))
+
 (defn- register-components-fns!
   []
   (state/set-page-blocks-cp! page/page-blocks-cp)

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

@@ -1771,19 +1771,11 @@
 (defn close-autocomplete-if-outside
   [input]
   (when (and input
-             (state/get-editor-action)
-             (not (wrapped-by? input page-ref/left-brackets page-ref/right-brackets)))
-    (let [value (gobj/get input "value")
-          pos (state/get-editor-last-pos)
-          current-pos (cursor/pos input)
-          between (gp-util/safe-subs value (min pos current-pos) (max pos current-pos))]
-      (when (and between
-                 (or
-                  (string/includes? between "[")
-                  (string/includes? between "]")
-                  (string/includes? between "(")
-                  (string/includes? between ")")))
-        (state/clear-editor-action!)))))
+             (contains? #{:page-search :page-search-hashtag :block-search} (state/get-editor-action))
+             (not (wrapped-by? input page-ref/left-brackets page-ref/right-brackets))
+             (not (wrapped-by? input block-ref/left-parens block-ref/right-parens))
+             (not (wrapped-by? input "#" "")))
+    (state/clear-editor-action!)))
 
 (defn resize-image!
   [block-id metadata full_text size]

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

@@ -396,7 +396,7 @@
               (state/set-current-repo! current-repo)
               (db/listen-and-persist! current-repo)
               (db/persist-if-idle! current-repo)
-              (file-handler/restore-config! current-repo false)
+              (file-handler/restore-config! current-repo)
               (.watch mobile-util/fs-watcher #js {:path current-repo-dir})
               (when graph-switch-f (graph-switch-f current-repo true)))))))))
 
@@ -453,10 +453,11 @@
 (defmethod handle :file-watcher/changed [[_ ^js event]]
   (let [type (.-event event)
         payload (-> event
-                    (js->clj :keywordize-keys true)
-                    (update :path js/decodeURI))]
+                    (js->clj :keywordize-keys true))
+        ;; TODO: remove this
+        payload' (-> payload (update :path js/decodeURI))]
     (fs-watcher/handle-changed! type payload)
-    (sync/file-watch-handler type payload)))
+    (sync/file-watch-handler type payload')))
 
 (defmethod handle :rebuild-slash-commands-list [[_]]
   (page-handler/rebuild-slash-commands-list!))

+ 22 - 7
src/main/frontend/handler/file.cljs

@@ -8,11 +8,13 @@
             [frontend.db :as db]
             [frontend.fs :as fs]
             [frontend.fs.nfs :as nfs]
+            [frontend.fs.capacitor-fs :as capacitor-fs]
             [frontend.handler.common :as common-handler]
             [frontend.handler.ui :as ui-handler]
             [frontend.state :as state]
             [frontend.util :as util]
             [logseq.graph-parser.util :as gp-util]
+            [electron.ipc :as ipc]
             [lambdaisland.glogi :as log]
             [promesa.core :as p]
             [frontend.mobile.util :as mobile]
@@ -53,9 +55,9 @@
   (keep-formats files (gp-config/img-formats)))
 
 (defn restore-config!
-  ([repo-url project-changed-check?]
-   (restore-config! repo-url nil project-changed-check?))
-  ([repo-url config-content _project-changed-check?]
+  ([repo-url]
+   (restore-config! repo-url nil))
+  ([repo-url config-content]
    (let [config-content (if config-content config-content
                             (common-handler/get-config repo-url))]
      (when config-content
@@ -80,13 +82,26 @@
                    (log/error :nfs/load-files-error repo-url)
                    (log/error :exception error))))))
 
+(defn backup-file!
+  "Backup db content to bak directory"
+  [repo-url path db-content content]
+  (cond
+    (util/electron?)
+    (ipc/ipc "backupDbFile" repo-url path db-content content)
+
+    (mobile/native-platform?)
+    (capacitor-fs/backup-file-handle-changed! repo-url path db-content)
+
+    :else
+    nil))
+
 (defn- page-exists-in-another-file
   "Conflict of files towards same page"
   [repo-url page file]
   (when-let [page-name (:block/name page)]
     (let [current-file (:file/path (db/get-page-file repo-url page-name))]
       (when (not= file current-file)
-       current-file))))
+        current-file))))
 
 (defn- get-delete-blocks [repo-url first-page file]
   (let [delete-blocks (->
@@ -178,7 +193,7 @@
     (util/p-handle (write-file!)
                    (fn [_]
                      (when (= path (config/get-config-path repo))
-                       (restore-config! repo true))
+                       (restore-config! repo))
                      (when (= path (config/get-custom-css-path repo))
                        (ui-handler/add-style-if-exists!))
                      (when re-render-root? (ui-handler/re-render-root!)))
@@ -277,7 +292,7 @@
         path (str config/app-name "/" config/metadata-file)
         file-path (str "/" path)
         default-content (if encrypted? "{:db/encrypted? true}" "{}")]
-    (p/let [_ (fs/mkdir-if-not-exists (str repo-dir "/" config/app-name))
+    (p/let [_ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir config/app-name))
             file-exists? (fs/create-if-not-exists repo-url repo-dir file-path default-content)]
       (when-not file-exists?
         (reset-file! repo-url path default-content)))))
@@ -288,7 +303,7 @@
         path (str config/app-name "/" config/pages-metadata-file)
         file-path (str "/" path)
         default-content "{}"]
-    (p/let [_ (fs/mkdir-if-not-exists (str repo-dir "/" config/app-name))
+    (p/let [_ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir config/app-name))
             file-exists? (fs/create-if-not-exists repo-url repo-dir file-path default-content)]
       (when-not file-exists?
         (reset-file! repo-url path default-content)))))

+ 15 - 13
src/main/frontend/handler/repo.cljs

@@ -61,7 +61,7 @@
                               "org" (rc/inline "contents.org")
                               "markdown" (rc/inline "contents.md")
                               "")]
-        (p/let [_ (fs/mkdir-if-not-exists (str repo-dir "/" pages-dir))
+        (p/let [_ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir pages-dir))
                 file-exists? (fs/create-if-not-exists repo-url repo-dir file-path default-content)]
           (when-not file-exists?
             (file-handler/reset-file! repo-url path default-content)))))))
@@ -73,7 +73,7 @@
         path (str config/app-name "/" config/custom-css-file)
         file-path (str "/" path)
         default-content ""]
-    (p/let [_ (fs/mkdir-if-not-exists (str repo-dir "/" config/app-name))
+    (p/let [_ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir config/app-name))
             file-exists? (fs/create-if-not-exists repo-url repo-dir file-path default-content)]
       (when-not file-exists?
         (file-handler/reset-file! repo-url path default-content)))))
@@ -84,7 +84,7 @@
   (let [repo-dir (config/get-repo-dir repo-url)
         path (str (config/get-pages-directory) "/how_to_make_dummy_notes.md")
         file-path (str "/" path)]
-    (p/let [_ (fs/mkdir-if-not-exists (str repo-dir "/" (config/get-pages-directory)))
+    (p/let [_ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir (config/get-pages-directory)))
             _file-exists? (fs/create-if-not-exists repo-url repo-dir file-path content)]
       (file-handler/reset-file! repo-url path content))))
 
@@ -110,14 +110,14 @@
 
                     :else
                     default-content)
-          path (str (config/get-journals-directory) "/" file-name "."
-                    (config/get-file-extension format))
+          path (util/safe-path-join (config/get-journals-directory) (str file-name "."
+                                                                         (config/get-file-extension format)))
           file-path (str "/" path)
           page-exists? (db/entity repo-url [:block/name (util/page-name-sanity-lc title)])
           empty-blocks? (db/page-empty? repo-url (util/page-name-sanity-lc title))]
       (when (or empty-blocks? (not page-exists?))
         (p/let [_ (nfs/check-directory-permission! repo-url)
-                _ (fs/mkdir-if-not-exists (str repo-dir "/" (config/get-journals-directory)))
+                _ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir (config/get-journals-directory)))
                 file-exists? (fs/file-exists? repo-dir file-path)]
           (when-not file-exists?
             (p/let [_ (file-handler/reset-file! repo-url path content)]
@@ -133,9 +133,9 @@
   ([repo-url encrypted?]
    (spec/validate :repos/url repo-url)
    (let [repo-dir (config/get-repo-dir repo-url)]
-     (p/let [_ (fs/mkdir-if-not-exists (str repo-dir "/" config/app-name))
-             _ (fs/mkdir-if-not-exists (str repo-dir "/" config/app-name "/" config/recycle-dir))
-             _ (fs/mkdir-if-not-exists (str repo-dir "/" (config/get-journals-directory)))
+     (p/let [_ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir config/app-name))
+             _ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir (str config/app-name "/" config/recycle-dir)))
+             _ (fs/mkdir-if-not-exists (util/safe-path-join repo-dir (config/get-journals-directory)))
              _ (file-handler/create-metadata-file repo-url encrypted?)
              _ (create-config-file-if-not-exists repo-url)
              _ (create-contents-file repo-url)
@@ -277,10 +277,12 @@
   (spec/validate :repos/url repo-url)
   (route-handler/redirect-to-home!)
   (state/set-parsing-state! {:graph-loading? true})
-  (let [config (or (state/get-config repo-url)
-                   (when-let [content (some-> (first (filter #(= (config/get-config-path repo-url) (:file/path %)) nfs-files))
+  (let [config (or (when-let [content (some-> (first (filter #(= (config/get-config-path repo-url) (:file/path %)) nfs-files))
                                               :file/content)]
-                     (common-handler/read-config content)))
+                     (common-handler/read-config content))
+                   (state/get-config repo-url))
+        ;; NOTE: Use config while parsing. Make sure it's the corrent journal title format
+        _ (state/set-config! repo-url config)
         relate-path-fn (fn [m k]
                          (some-> (get m k)
                                  (string/replace (js/decodeURI (config/get-local-dir repo-url)) "")))
@@ -386,7 +388,7 @@
   [repo]
   (p/let [_ (state/set-db-restoring! true)
           _ (db/restore-graph! repo)]
-         (file-handler/restore-config! repo false)
+         (file-handler/restore-config! repo)
          ;; Don't have to unlisten the old listerner, as it will be destroyed with the conn
          (db/listen-and-persist! repo)
          (ui-handler/add-style-if-exists!)

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

@@ -3,6 +3,7 @@
             [frontend.db.model :as model]
             [frontend.db.utils :as db-utils]
             [frontend.handler.editor :as editor-handler]
+            [frontend.modules.outliner.core :as outliner]
             [frontend.modules.outliner.file :as outliner-file]
             [frontend.state :as state]
             [frontend.util :as util]
@@ -55,9 +56,12 @@
 
 (defn- tldr-page->blocks-tx [page-name tldr-data]
   (let [page-name (util/page-name-sanity-lc page-name)
-        page-block {:block/name page-name
-                    :block/whiteboard? true
-                    :block/properties (dissoc tldr-data :shapes)}
+        page-entity (model/get-page page-name)
+        page-block (merge {:block/name page-name
+                           :block/whiteboard? true
+                           :block/properties (dissoc tldr-data :shapes)}
+                          (when page-entity (select-keys page-entity [:block/created-at])))
+        page-block (outliner/block-with-timestamps page-block)
         ;; todo: use get-paginated-blocks instead?
         existing-blocks (model/get-page-blocks-no-cache (state/get-current-repo)
                                                         page-name

+ 3 - 2
src/main/frontend/mobile/core.cljs

@@ -2,6 +2,7 @@
   (:require ["@capacitor/app" :refer [^js App]]
             ["@capacitor/keyboard" :refer [^js Keyboard]]
             [clojure.string :as string]
+            [promesa.core :as p]
             [frontend.fs.capacitor-fs :as mobile-fs]
             [frontend.handler.editor :as editor-handler]
             [frontend.mobile.deeplink :as deeplink]
@@ -20,8 +21,8 @@
 (defn- ios-init
   "Initialize iOS-specified event listeners"
   []
-  (let [path (mobile-fs/iOS-ensure-documents!)]
-    (println "iOS container path: " path))
+  (p/let [path (mobile-fs/iOS-ensure-documents!)]
+    (println "iOS container path: " (js->clj path)))
 
   (state/pub-event! [:validate-appId])
   

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

@@ -125,16 +125,12 @@
                        (date/date->file-name journal-page?)
                        (-> (or (:block/original-name page) (:block/name page))
                            (util/file-name-sanity)))
-            path (str
-                  (cond
-                    journal-page?    (config/get-journals-directory)
-                    whiteboard-page? (config/get-whiteboards-directory)
-                    :else            (config/get-pages-directory))
-                  "/"
-                  filename
-                  "."
-                  (if (= format "markdown") "md" format))
-            file-path (config/get-file-path repo path)
+            sub-dir (cond
+                      journal-page?    (config/get-journals-directory)
+                      whiteboard-page? (config/get-whiteboards-directory)
+                      :else            (config/get-pages-directory))
+            ext (if (= format "markdown") "md" format)
+            file-path (config/get-page-file-path repo sub-dir filename ext)
             file {:file/path file-path}
             tx [{:file/path file-path}
                 {:block/name (:block/name page)

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

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

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

@@ -1331,6 +1331,7 @@
              :command.graph/remove                   "Bir grafiği kaldır"
              :command.graph/add                      "Grafik ekle"
              :command.graph/save                     "Mevcut grafiği diske kaydet"
+             :command.graph/re-index                 "Mevcut grafiği yeniden oluştur"
              :command.command/run                    "Git komutunu çalıştır"
              :command.go/home                        "Ana sayfaya git"
              :command.go/all-pages                   "Bütün sayfalara git"

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

@@ -10,6 +10,7 @@
             [cljs-bean.core :as bean]
             [cljs-time.coerce :as tc]
             [cljs-time.core :as t]
+            [clojure.pprint]
             [dommy.core :as d]
             [frontend.mobile.util :refer [native-platform?]]
             [logseq.graph-parser.util :as gp-util]
@@ -487,6 +488,10 @@
   (if (string? s)
     (string/lower-case s) s))
 
+#?(:cljs
+   (defn safe-path-join [prefix & paths]
+     (apply node-path.join (cons prefix paths))))
+
 (defn trim-safe
   [s]
   (when s
@@ -891,11 +896,6 @@
      [string]
      (some-> string str (js/encodeURIComponent) (.replace "+" "%20"))))
 
-#?(:cljs
-   (defn url-decode
-     [string]
-     (some-> string str (js/decodeURIComponent))))
-
 (def windows-reserved-chars #"[:\\*\\?\"<>|]+")
 
 #?(:cljs

+ 15 - 0
src/main/frontend/utils.js

@@ -310,5 +310,20 @@ export const nodePath = Object.assign({}, path, {
   extname (input) {
     input = toPosixPath(input)
     return path.extname(input)
+  },
+
+  join (input, ...paths) {
+    let orURI = null
+
+    try {
+      orURI = new URL(input)
+      input = input.replace(orURI.protocol + '//', '')
+        .replace(orURI.protocol, '')
+        .replace(/^\/+/, '/')
+    } catch (_e) {}
+
+    input = path.join(input, ...paths)
+
+    return (orURI ? (orURI.protocol + '//') : '') + input
   }
 })

+ 8 - 4
tldraw/apps/tldraw-logseq/package.json

@@ -1,9 +1,8 @@
 {
   "version": "0.0.0-dev",
-  "name": "tldraw-logseq",
+  "name": "@tldraw/logseq",
   "license": "MIT",
-  "main": "dist/index.js",
-  "module": "dist/index.mjs",
+  "module": "./src/index.ts",
   "scripts": {
     "build": "zx build.mjs",
     "build:packages": "yarn build",
@@ -31,8 +30,13 @@
     "tsup": "^6.1.2",
     "typescript": "^4.7.3",
     "zx": "^6.2.4",
+    "polished": "^4.0.0",
+    "@radix-ui/react-dropdown-menu": "^1.0.0",
+    "@radix-ui/react-select": "^1.0.0",
     "@radix-ui/react-switch": "^1.0.0",
-    "@radix-ui/react-dropdown-menu": "^1.0.0"
+    "@radix-ui/react-toggle-group": "^1.0.0",
+    "@radix-ui/react-toggle": "^1.0.0",
+    "@radix-ui/react-separator": "^1.0.0"
   },
   "peerDependencies": {
     "react": "^16.8.0 || ^17.0.0 || ^18.0.0",

+ 25 - 13
tldraw/apps/tldraw-logseq/src/app.tsx

@@ -1,6 +1,6 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import type { TLDocumentModel } from '@tldraw/core'
+import { deepEqual, TLApp, TLAsset, TLDocumentModel } from '@tldraw/core'
 import {
   AppCanvas,
   AppProvider,
@@ -58,37 +58,49 @@ interface LogseqTldrawProps {
   onPersist?: TLReactCallbacks<Shape>['onPersist']
 }
 
-export const App = function App(props: LogseqTldrawProps): JSX.Element {
-  const renderers: any = React.useMemo(() => {
+export const App = function App({
+  onPersist,
+  handlers,
+  renderers,
+  model,
+  ...rest
+}: LogseqTldrawProps): JSX.Element {
+  const memoRenders: any = React.useMemo(() => {
     return Object.fromEntries(
-      Object.entries(props.renderers).map(([key, comp]) => {
+      Object.entries(renderers).map(([key, comp]) => {
         return [key, React.memo(comp)]
       })
     )
   }, [])
   const contextValue = {
-    renderers,
-    handlers: props.handlers,
+    renderers: memoRenders,
+    handlers: handlers,
   }
 
   const onFileDrop = useFileDrop(contextValue)
   const onPaste = usePaste(contextValue)
   const onQuickAdd = useQuickAdd()
 
+  const onPersistOnDiff: TLReactCallbacks<Shape>['onPersist'] = React.useCallback(
+    (app, info) => {
+      if (!deepEqual(app.serialized, model)) {
+        onPersist?.(app, info)
+      }
+    },
+    [model]
+  )
+
   return (
-    <LogseqContext.Provider
-      value={{
-        renderers,
-        handlers: props.handlers,
-      }}
-    >
+    <LogseqContext.Provider value={contextValue}>
       <AppProvider
         Shapes={shapes}
         Tools={tools}
         onFileDrop={onFileDrop}
         onPaste={onPaste}
         onCanvasDBClick={onQuickAdd}
-        {...props}
+        onPersist={onPersistOnDiff}
+        model={model}
+        {...rest}
       >
         <div className="logseq-tldraw logseq-tldraw-wrapper">
           <AppCanvas components={components}>

+ 24 - 92
tldraw/apps/tldraw-logseq/src/components/ContextBar/ContextBar.tsx

@@ -1,58 +1,21 @@
-import * as React from 'react'
 import {
+  getContextBarTranslation,
   HTMLContainer,
   TLContextBarComponent,
   useApp,
-  getContextBarTranslation,
 } from '@tldraw/react'
 import { observer } from 'mobx-react-lite'
-import type { TextShape, Shape } from '~lib/shapes'
-import { NumberInput } from '~components/inputs/NumberInput'
-import { ColorInput } from '~components/inputs/ColorInput'
-import { SwitchInput } from '../inputs/SwitchInput'
+import * as Separator from '@radix-ui/react-separator'
 
-const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets }) => {
+import * as React from 'react'
+import type { Shape } from '~lib/shapes'
+import { getContextBarActionsForTypes as getContextBarActionsForShapes } from './contextBarActionFactory'
+
+const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets, hidden }) => {
   const app = useApp()
   const rSize = React.useRef<[number, number] | null>(null)
   const rContextBar = React.useRef<HTMLDivElement>(null)
 
-  const updateStroke = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(e => {
-    shapes.forEach(shape => shape.update({ stroke: e.currentTarget.value }))
-    app.persist()
-  }, [])
-
-  const updateFill = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(e => {
-    shapes.forEach(shape => shape.update({ fill: e.currentTarget.value }))
-    app.persist()
-  }, [])
-
-  const updateStrokeWidth = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(e => {
-    shapes.forEach(shape => shape.update({ strokeWidth: +e.currentTarget.value }))
-    app.persist()
-  }, [])
-
-  const updateOpacity = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(e => {
-    shapes.forEach(shape => shape.update({ opacity: +e.currentTarget.value }))
-    app.persist()
-  }, [])
-
-  const updateFontSize = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(e => {
-    textShapes.forEach(shape => shape.update({ fontSize: +e.currentTarget.value }))
-    app.persist()
-  }, [])
-
-  const updateFontWeight = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(e => {
-    textShapes.forEach(shape => shape.update({ fontWeight: +e.currentTarget.value }))
-    app.persist()
-  }, [])
-
-  const updateTransparent = React.useCallback(transparent => {
-    // const transparent = shapes.some(s => s.props.fill !== 'transparent')
-    console.log(transparent)
-    shapes.forEach(shape => shape.update({ fill: transparent ? 'transparent' : '#fff' }))
-    app.persist()
-  }, [])
-
   React.useLayoutEffect(() => {
     setTimeout(() => {
       const elm = rContextBar.current
@@ -72,57 +35,26 @@ const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets }) => {
 
   if (!app) return null
 
-  const textShapes = shapes.filter(shape => shape.type === 'text') as TextShape[]
-  const ShapeContent =
-    shapes.length === 1 && 'ReactContextBar' in shapes[0] ? shapes[0]['ReactContextBar'] : null
-  const transparent = shapes.every(s => s.props.fill === 'transparent')
+  const Actions = getContextBarActionsForShapes(shapes)
 
   return (
     <HTMLContainer centered>
-      <div ref={rContextBar} className="tl-contextbar">
-        {ShapeContent ? (
-          <ShapeContent />
-        ) : (
-          <>
-            <ColorInput label="Stroke" value={shapes[0].props.stroke} onChange={updateStroke} />
-            {!transparent && <ColorInput label="Fill" value={shapes[0].props.fill} onChange={updateFill} />}
-            <SwitchInput
-              label="Transparent"
-              checked={transparent}
-              onCheckedChange={updateTransparent}
-            />
-            <NumberInput
-              label="Width"
-              value={Math.max(...shapes.map(shape => shape.props.strokeWidth))}
-              onChange={updateStrokeWidth}
-              style={{ width: 48 }}
-            />
-            <NumberInput
-              label="Opacity"
-              value={Math.max(...shapes.map(shape => shape.props.opacity))}
-              onChange={updateOpacity}
-              step={0.1}
-              style={{ width: 48 }}
-            />
-            {textShapes.length > 0 ? (
-              <>
-                <NumberInput
-                  label="Size"
-                  value={Math.max(...textShapes.map(shape => shape.props.fontSize))}
-                  onChange={updateFontSize}
-                  style={{ width: 48 }}
-                />
-                <NumberInput
-                  label=" Weight"
-                  value={Math.max(...textShapes.map(shape => shape.props.fontWeight))}
-                  onChange={updateFontWeight}
-                  style={{ width: 48 }}
-                />
-              </>
-            ) : null}
-          </>
-        )}
-      </div>
+      {Actions.length > 0 && (
+        <div
+          ref={rContextBar}
+          className="tl-contextbar"
+          style={{ pointerEvents: hidden ? 'none' : 'all' }}
+        >
+          {Actions.map((Action, idx) => (
+            <React.Fragment key={idx}>
+              <Action />
+              {idx < Actions.length - 1 && (
+                <Separator.Root className="tl-contextbar-separator" orientation="vertical" />
+              )}
+            </React.Fragment>
+          ))}
+        </div>
+      )}
     </HTMLContainer>
   )
 }

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

@@ -0,0 +1,418 @@
+import { isNonNullable, debounce, Decoration, TLLineShapeProps } from '@tldraw/core'
+import { useApp } from '@tldraw/react'
+import { observer } from 'mobx-react-lite'
+import React from 'react'
+import { TablerIcon } from '~components/icons'
+import { ColorInput } from '~components/inputs/ColorInput'
+import { SelectInput, SelectOption } from '~components/inputs/SelectInput'
+import { TextInput } from '~components/inputs/TextInput'
+import {
+  ToggleGroupInput,
+  ToggleGroupInputOption,
+  ToggleGroupMultipleInput,
+} from '~components/inputs/ToggleGroupInput'
+import { ToggleInput } from '~components/inputs/ToggleInput'
+import { tint } from 'polished'
+import type {
+  BoxShape,
+  EllipseShape,
+  HTMLShape,
+  LineShape,
+  LogseqPortalShape,
+  PencilShape,
+  PolygonShape,
+  Shape,
+  TextShape,
+  YouTubeShape,
+} from '~lib'
+import { LogseqContext } from '~lib/logseq-context'
+
+export const contextBarActionTypes = [
+  // Order matters
+  'Edit',
+  'Swatch',
+  'NoFill',
+  'ResetBounds',
+  'StrokeType',
+  'ScaleLevel',
+  'YoutubeLink',
+  'LogseqPortalViewMode',
+  'ArrowMode',
+  'OpenPage',
+] as const
+
+type ContextBarActionType = typeof contextBarActionTypes[number]
+const singleShapeActions: ContextBarActionType[] = ['Edit', 'YoutubeLink', 'OpenPage']
+
+const contextBarActionMapping = new Map<ContextBarActionType, React.FC>()
+
+type ShapeType = Shape['props']['type']
+
+const shapeMapping: Partial<Record<ShapeType, ContextBarActionType[]>> = {
+  'logseq-portal': ['Edit', 'LogseqPortalViewMode', 'ScaleLevel', 'OpenPage', 'ResetBounds'],
+  youtube: ['YoutubeLink'],
+  box: ['Swatch', 'NoFill', 'StrokeType'],
+  ellipse: ['Swatch', 'NoFill', 'StrokeType'],
+  polygon: ['Swatch', 'NoFill', 'StrokeType'],
+  line: ['Edit', 'Swatch', 'ArrowMode'],
+  pencil: ['Swatch'],
+  highlighter: ['Swatch'],
+  text: ['Edit', 'Swatch', 'ScaleLevel', 'ResetBounds'],
+  html: ['ScaleLevel', 'ResetBounds'],
+}
+
+const noStrokeShapes = Object.entries(shapeMapping)
+  .filter(([key, types]) => {
+    return !types.includes('NoFill') && types.includes('Swatch')
+  })
+  .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 app = useApp<Shape>()
+  const shape = filterShapeByAction(app.selectedShapesArray, 'Edit')[0]
+
+  return (
+    <button
+      className="tl-contextbar-button"
+      type="button"
+      onClick={() => {
+        app.api.editShape(shape)
+        app.api.zoomToSelection()
+        if (shape.props.type === 'logseq-portal') {
+          let uuid = shape.props.pageId
+          if (shape.props.blockType === 'P') {
+            const firstNonePropertyBlock = window.logseq?.api
+              ?.get_page_blocks_tree?.(shape.props.pageId)
+              .find(b => !('propertiesOrder' in b))
+            uuid = firstNonePropertyBlock.uuid
+          }
+          window.logseq?.api?.edit_block?.(uuid)
+        }
+      }}
+    >
+      <TablerIcon name="text" />
+    </button>
+  )
+})
+
+const ResetBoundsAction = observer(() => {
+  const app = useApp<Shape>()
+  const shapes = filterShapeByAction<LogseqPortalShape | TextShape | HTMLShape>(
+    app.selectedShapesArray,
+    'ResetBounds'
+  )
+
+  return (
+    <button
+      className="tl-contextbar-button"
+      type="button"
+      onClick={() => {
+        shapes.forEach(s => {
+          s.onResetBounds({ zoom: app.viewport.camera.zoom })
+        })
+        app.persist()
+      }}
+    >
+      <TablerIcon name="dimensions" />
+    </button>
+  )
+})
+
+const LogseqPortalViewModeAction = observer(() => {
+  const app = useApp<Shape>()
+  const shapes = filterShapeByAction<LogseqPortalShape>(
+    app.selectedShapesArray,
+    'LogseqPortalViewMode'
+  )
+
+  const collapsed = shapes.every(s => s.collapsed)
+  const ViewModeOptions: ToggleGroupInputOption[] = [
+    {
+      value: '1',
+      icon: 'object-compact',
+    },
+    {
+      value: '0',
+      icon: 'object-expanded',
+    },
+  ]
+  return (
+    <ToggleGroupInput
+      options={ViewModeOptions}
+      value={collapsed ? '1' : '0'}
+      onValueChange={v => {
+        shapes.forEach(shape => {
+          shape.setCollapsed(v === '1' ? true : false)
+        })
+        app.persist()
+      }}
+    />
+  )
+})
+
+const ScaleLevelAction = observer(() => {
+  const app = useApp<Shape>()
+  const shapes = filterShapeByAction<LogseqPortalShape>(app.selectedShapesArray, 'ScaleLevel')
+  const scaleLevel = new Set(shapes.map(s => s.scaleLevel)).size > 1 ? '' : shapes[0].scaleLevel
+  const sizeOptions: SelectOption[] = [
+    {
+      label: 'Extra Small',
+      value: 'xs',
+    },
+    {
+      label: 'Small',
+      value: 'sm',
+    },
+    {
+      label: 'Medium',
+      value: 'md',
+    },
+    {
+      label: 'Large',
+      value: 'lg',
+    },
+    {
+      label: 'Extra Large',
+      value: 'xl',
+    },
+    {
+      label: 'Huge',
+      value: 'xxl',
+    },
+  ]
+  return (
+    <SelectInput
+      options={sizeOptions}
+      value={scaleLevel}
+      onValueChange={v => {
+        shapes.forEach(shape => {
+          shape.setScaleLevel(v as LogseqPortalShape['props']['scaleLevel'])
+        })
+        app.persist()
+      }}
+    />
+  )
+})
+
+const OpenPageAction = observer(() => {
+  const { handlers } = React.useContext(LogseqContext)
+  const app = useApp<Shape>()
+  const shapes = filterShapeByAction<LogseqPortalShape>(app.selectedShapesArray, 'OpenPage')
+  const shape = shapes[0]
+  const { pageId, blockType } = shape.props
+
+  return (
+    <span className="flex gap-1">
+      <button
+        className="tl-contextbar-button"
+        type="button"
+        onClick={() => handlers?.sidebarAddBlock(pageId, blockType === 'B' ? 'block' : 'page')}
+      >
+        <TablerIcon name="layout-sidebar-right" />
+      </button>
+      <button
+        className="tl-contextbar-button"
+        type="button"
+        onClick={() => handlers?.redirectToPage(pageId)}
+      >
+        <TablerIcon name="external-link" />
+      </button>
+    </span>
+  )
+})
+
+const YoutubeLinkAction = observer(() => {
+  const app = useApp<Shape>()
+  const shape = filterShapeByAction<YouTubeShape>(app.selectedShapesArray, 'YoutubeLink')[0]
+  const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    shape.onYoutubeLinkChange(e.target.value)
+    app.persist()
+  }, [])
+
+  return (
+    <span className="flex gap-3">
+      <TextInput className="tl-youtube-link" value={`${shape.props.url}`} onChange={handleChange} />
+      <button
+        className="tl-contextbar-button"
+        type="button"
+        onClick={() => window.logseq?.api?.open_external_link?.(shape.props.url)}
+      >
+        <TablerIcon name="external-link" />
+      </button>
+    </span>
+  )
+})
+
+const NoFillAction = observer(() => {
+  const app = useApp<Shape>()
+  const shapes = filterShapeByAction<BoxShape | PolygonShape | EllipseShape>(
+    app.selectedShapesArray,
+    'NoFill'
+  )
+  const handleChange = React.useCallback((v: boolean) => {
+    shapes.forEach(s => s.update({ noFill: v }))
+    app.persist()
+  }, [])
+
+  const noFill = shapes.every(s => s.props.noFill)
+
+  return (
+    <ToggleInput className="tl-contextbar-button" pressed={noFill} onPressedChange={handleChange}>
+      {noFill ? <TablerIcon name="eye-off" /> : <TablerIcon name="eye" />}
+    </ToggleInput>
+  )
+})
+
+const SwatchAction = observer(() => {
+  const app = useApp<Shape>()
+  // Placeholder
+  const shapes = filterShapeByAction<
+    BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape | TextShape
+  >(app.selectedShapesArray, 'Swatch')
+  const handleChange = React.useMemo(() => {
+    let latestValue = ''
+    const handler: React.ChangeEventHandler<HTMLInputElement> = e => {
+      const strokeColor = tint(0.4, latestValue)
+      shapes.forEach(s => {
+        const strokeOnly = noStrokeShapes.includes(s.props.type)
+        s.update(
+          strokeOnly
+            ? { stroke: latestValue, fill: latestValue }
+            : { fill: latestValue, stroke: strokeColor }
+        )
+      })
+      app.persist(true)
+    }
+    return debounce(handler, 100, e => {
+      latestValue = e.target.value
+    })
+  }, [])
+
+  const value = shapes[0].props.noFill ? shapes[0].props.stroke : shapes[0].props.fill
+  return <ColorInput value={value} onChange={handleChange} />
+})
+
+const StrokeTypeAction = observer(() => {
+  const app = useApp<Shape>()
+  const shapes = filterShapeByAction<
+    BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape
+  >(app.selectedShapesArray, 'StrokeType')
+
+  const StrokeTypeOptions: ToggleGroupInputOption[] = [
+    {
+      value: 'line',
+      icon: 'circle',
+    },
+    {
+      value: 'dashed',
+      icon: 'circle-dashed',
+    },
+  ]
+
+  const value = shapes.every(s => s.props.strokeType === 'dashed')
+    ? 'dashed'
+    : shapes.every(s => s.props.strokeType === 'line')
+    ? 'line'
+    : 'mixed'
+
+  return (
+    <ToggleGroupInput
+      options={StrokeTypeOptions}
+      value={value}
+      onValueChange={v => {
+        shapes.forEach(shape => {
+          shape.update({
+            strokeType: v,
+          })
+        })
+        app.persist()
+      }}
+    />
+  )
+})
+
+const ArrowModeAction = observer(() => {
+  const app = useApp<Shape>()
+  const shapes = filterShapeByAction<LineShape>(app.selectedShapesArray, 'ArrowMode')
+
+  const StrokeTypeOptions: ToggleGroupInputOption[] = [
+    {
+      value: 'start',
+      icon: 'arrow-narrow-left',
+    },
+    {
+      value: 'end',
+      icon: 'arrow-narrow-right',
+    },
+  ]
+
+  const startValue = shapes.every(s => s.props.decorations?.start === Decoration.Arrow)
+  const endValue = shapes.every(s => s.props.decorations?.end === Decoration.Arrow)
+
+  const value = [startValue ? 'start' : null, endValue ? 'end' : null].filter(isNonNullable)
+
+  const valueToDecorations = (value: string[]) => {
+    return {
+      start: value.includes('start') ? Decoration.Arrow : null,
+      end: value.includes('end') ? Decoration.Arrow : null,
+    }
+  }
+
+  return (
+    <ToggleGroupMultipleInput
+      options={StrokeTypeOptions}
+      value={value}
+      onValueChange={v => {
+        shapes.forEach(shape => {
+          shape.update({
+            decorations: valueToDecorations(v),
+          })
+        })
+        app.persist()
+      }}
+    />
+  )
+})
+
+contextBarActionMapping.set('Edit', EditAction)
+contextBarActionMapping.set('ResetBounds', ResetBoundsAction)
+contextBarActionMapping.set('LogseqPortalViewMode', LogseqPortalViewModeAction)
+contextBarActionMapping.set('ScaleLevel', ScaleLevelAction)
+contextBarActionMapping.set('OpenPage', OpenPageAction)
+contextBarActionMapping.set('YoutubeLink', YoutubeLinkAction)
+contextBarActionMapping.set('NoFill', NoFillAction)
+contextBarActionMapping.set('Swatch', SwatchAction)
+contextBarActionMapping.set('StrokeType', StrokeTypeAction)
+contextBarActionMapping.set('ArrowMode', ArrowModeAction)
+
+const getContextBarActionTypes = (type: ShapeType) => {
+  return (shapeMapping[type] ?? []).filter(isNonNullable)
+}
+
+export const getContextBarActionsForTypes = (shapes: Shape[]) => {
+  const types = shapes.map(s => s.props.type)
+  const actionTypes = new Set(shapes.length > 0 ? getContextBarActionTypes(types[0]) : [])
+  for (let i = 1; i < types.length && actionTypes.size > 0; i++) {
+    const otherActionTypes = getContextBarActionTypes(types[i])
+    actionTypes.forEach(action => {
+      if (!otherActionTypes.includes(action)) {
+        actionTypes.delete(action)
+      }
+    })
+  }
+  if (shapes.length > 1) {
+    singleShapeActions.forEach(action => {
+      if (actionTypes.has(action)) {
+        actionTypes.delete(action)
+      }
+    })
+  }
+
+  return Array.from(actionTypes)
+    .sort((a, b) => contextBarActionTypes.indexOf(a) - contextBarActionTypes.indexOf(b))
+    .map(action => contextBarActionMapping.get(action)!)
+}

+ 2 - 2
tldraw/apps/tldraw-logseq/src/components/icons/TablerIcon.tsx

@@ -1,6 +1,6 @@
-import React from 'react'
-
 const extendedIcons = [
+  'object-compact',
+  'object-expanded',
   'block',
   'block-search',
   'page',

+ 2 - 6
tldraw/apps/tldraw-logseq/src/components/inputs/ColorInput.tsx

@@ -1,10 +1,8 @@
 import * as React from 'react'
 
-interface ColorInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
-  label: string
-}
+interface ColorInputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
 
-export function ColorInput({ label, value, onChange, ...rest }: ColorInputProps) {
+export function ColorInput({ value, onChange, ...rest }: ColorInputProps) {
   const ref = React.useRef<HTMLDivElement>(null)
   const [computedValue, setComputedValue] = React.useState(value)
 
@@ -21,11 +19,9 @@ export function ColorInput({ label, value, onChange, ...rest }: ColorInputProps)
 
   return (
     <div className="input" ref={ref}>
-      <label htmlFor={`color-${label}`}>{label}</label>
       <div className="color-input-wrapper">
         <input
           className="color-input"
-          name={`color-${label}`}
           type="color"
           value={computedValue}
           onChange={e => {

+ 49 - 8
tldraw/apps/tldraw-logseq/src/components/inputs/SelectInput.tsx

@@ -1,16 +1,57 @@
 import * as React from 'react'
+import * as Select from '@radix-ui/react-select'
+import { TablerIcon } from '~components/icons'
 
-interface ColorInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
-  label: string
+export interface SelectOption {
+  value: string
+  label: React.ReactNode
 }
 
-export function ColorInput({ label, ...rest }: ColorInputProps) {
+interface SelectInputProps extends React.HTMLAttributes<HTMLElement> {
+  options: SelectOption[]
+  value: string
+  onValueChange: (value: string) => void
+}
+
+export function SelectInput({ options, value, onValueChange, ...rest }: SelectInputProps) {
+  const [isOpen, setIsOpen] = React.useState(false)
   return (
-    <div className="input">
-      <label htmlFor={`color-${label}`}>{label}</label>
-      <div className="color-input-wrapper">
-        <input className="color-input" name={`color-${label}`} type="color" {...rest} />
-      </div>
+    <div {...rest} className="tl-select-input">
+      <Select.Root
+        open={isOpen}
+        onOpenChange={setIsOpen}
+        value={value}
+        onValueChange={onValueChange}
+      >
+        <Select.Trigger className="tl-select-input-trigger">
+          <div className="tl-select-input-trigger-value">
+            <Select.Value />
+          </div>
+          <Select.Icon style={{ lineHeight: 1 }}>
+            <TablerIcon name={isOpen ? 'chevron-up' : 'chevron-down'} />
+          </Select.Icon>
+        </Select.Trigger>
+
+        <Select.Portal className="tl-select-input-portal">
+          <Select.Content className="tl-select-input-content">
+            <Select.ScrollUpButton />
+            <Select.Viewport className="tl-select-input-viewport">
+              {options.map(option => {
+                return (
+                  <Select.Item
+                    className="tl-select-input-select-item"
+                    key={option.value}
+                    value={option.value}
+                  >
+                    <Select.ItemText>{option.label}</Select.ItemText>
+                  </Select.Item>
+                )
+              })}
+            </Select.Viewport>
+            <Select.ScrollDownButton />
+          </Select.Content>
+        </Select.Portal>
+      </Select.Root>
     </div>
   )
 }

+ 4 - 6
tldraw/apps/tldraw-logseq/src/components/inputs/SwitchInput.tsx

@@ -1,18 +1,16 @@
-import * as React from 'react'
 import * as Switch from '@radix-ui/react-switch'
 interface SwitchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
   label: string
   onCheckedChange: (checked: boolean) => void
 }
 
-export function SwitchInput({ label, ...rest }: SwitchInputProps) {
+export function SwitchInput({ label, onCheckedChange, checked, ...rest }: SwitchInputProps) {
   return (
-    <div className="input">
-      <label htmlFor={`switch-${label}`}>{label}</label>
+    <div {...rest} className="input">
       <Switch.Root
         className="switch-input-root"
-        checked={rest.checked}
-        onCheckedChange={rest.onCheckedChange}
+        checked={checked}
+        onCheckedChange={onCheckedChange}
       >
         <Switch.Thumb className="switch-input-thumb" />
       </Switch.Root>

+ 7 - 5
tldraw/apps/tldraw-logseq/src/components/inputs/TextInput.tsx

@@ -1,15 +1,17 @@
 import * as React from 'react'
 
 interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
-  label: string
+  autoResize?: boolean
 }
 
 export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
-  ({ label, ...rest }, ref) => {
+  ({ autoResize = true, value, className, ...rest }, ref) => {
     return (
-      <div className="tl-input">
-        <label htmlFor={`text-${label}`}>{label}</label>
-        <input ref={ref} className="tl-text-input" name={`text-${label}`} type="text" {...rest} />
+      <div className={'tl-input' + (className ? ' ' + className : '')}>
+        <div className="tl-input-sizer">
+          <div className="tl-input-hidden">{value}</div>
+          <input ref={ref} value={value} className="tl-text-input" type="text" {...rest} />
+        </div>
       </div>
     )
   }

+ 70 - 0
tldraw/apps/tldraw-logseq/src/components/inputs/ToggleGroupInput.tsx

@@ -0,0 +1,70 @@
+import * as ToggleGroup from '@radix-ui/react-toggle-group'
+import { TablerIcon } from '~components/icons'
+
+export interface ToggleGroupInputOption {
+  value: string
+  icon: string
+}
+
+interface ToggleGroupInputProps extends React.HTMLAttributes<HTMLElement> {
+  options: ToggleGroupInputOption[]
+  value: string
+  onValueChange: (value: string) => void
+}
+
+interface ToggleGroupMultipleInputProps extends React.HTMLAttributes<HTMLElement> {
+  options: ToggleGroupInputOption[]
+  value: string[]
+  onValueChange: (value: string[]) => void
+}
+
+export function ToggleGroupInput({ options, value, onValueChange }: ToggleGroupInputProps) {
+  return (
+    <ToggleGroup.Root
+      className="tl-toggle-group-input"
+      type="single"
+      value={value}
+      onValueChange={onValueChange}
+    >
+      {options.map(option => {
+        return (
+          <ToggleGroup.Item
+            className="tl-toggle-group-input-button"
+            key={option.value}
+            value={option.value}
+            disabled={option.value === value}
+          >
+            <TablerIcon name={option.icon} />
+          </ToggleGroup.Item>
+        )
+      })}
+    </ToggleGroup.Root>
+  )
+}
+
+export function ToggleGroupMultipleInput({
+  options,
+  value,
+  onValueChange,
+}: ToggleGroupMultipleInputProps) {
+  return (
+    <ToggleGroup.Root
+      className="tl-toggle-group-input"
+      type="multiple"
+      value={value}
+      onValueChange={onValueChange}
+    >
+      {options.map(option => {
+        return (
+          <ToggleGroup.Item
+            className="tl-toggle-group-input-button"
+            key={option.value}
+            value={option.value}
+          >
+            <TablerIcon name={option.icon} />
+          </ToggleGroup.Item>
+        )
+      })}
+    </ToggleGroup.Root>
+  )
+}

+ 17 - 0
tldraw/apps/tldraw-logseq/src/components/inputs/ToggleInput.tsx

@@ -0,0 +1,17 @@
+import * as Toggle from '@radix-ui/react-toggle'
+
+interface ToggleInputProps extends React.HTMLAttributes<HTMLElement> {
+  pressed: boolean
+  onPressedChange: (value: boolean) => void
+}
+
+export function ToggleInput({ pressed, onPressedChange, className, ...rest }: ToggleInputProps) {
+  return (
+    <Toggle.Root
+      {...rest}
+      className={'tl-toggle-input' + (className ? ' ' + className : '')}
+      pressed={pressed}
+      onPressedChange={onPressedChange}
+    ></Toggle.Root>
+  )
+}

+ 38 - 25
tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts

@@ -8,9 +8,10 @@ import {
   validUUID,
 } from '@tldraw/core'
 import type { TLReactCallbacks } from '@tldraw/react'
+import Vec from '@tldraw/vec'
 import * as React from 'react'
 import { NIL as NIL_UUID } from 'uuid'
-import { HTMLShape, LogseqPortalShape, Shape, YouTubeShape } from '~lib'
+import { HTMLShape, LogseqPortalShape, Shape, YouTubeShape, ImageShape, VideoShape } from '~lib'
 import type { LogseqContextValue } from '~lib/logseq-context'
 
 const isValidURL = (url: string) => {
@@ -22,17 +23,25 @@ const isValidURL = (url: string) => {
   }
 }
 
+const safeParseJson = (json: string) => {
+  try {
+    return JSON.parse(json)
+  } catch {
+    return null
+  }
+}
+
 export function usePaste(context: LogseqContextValue) {
   const { handlers } = context
 
   return React.useCallback<TLReactCallbacks<Shape>['onPaste']>(
     async (app, { point, shiftKey, files }) => {
-      const assetId = uniqueId()
-      interface ImageAsset extends TLAsset {
+      interface VideoImageAsset extends TLAsset {
         size: number[]
       }
 
-      const assetsToCreate: ImageAsset[] = []
+      const imageAssetsToCreate: VideoImageAsset[] = []
+      let assetsToClone: TLAsset[] = []
       const shapesToCreate: Shape['props'][] = []
       const bindingsToCreate: TLBinding[] = []
 
@@ -43,6 +52,7 @@ export function usePaste(context: LogseqContextValue) {
       // TODO: handle PDF?
       async function handleFiles(files: File[]) {
         const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif']
+        const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg']
 
         for (const file of files) {
           // Get extension, verify that it's an image
@@ -51,9 +61,10 @@ export function usePaste(context: LogseqContextValue) {
             continue
           }
           const extension = extensionMatch[0].toLowerCase()
-          if (!IMAGE_EXTENSIONS.includes(extension)) {
+          if (![...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS].includes(extension)) {
             continue
           }
+          const isVideo = VIDEO_EXTENSIONS.includes(extension)
           try {
             // Turn the image into a base64 dataurl
             const dataurl = await createAsset(file)
@@ -63,17 +74,17 @@ export function usePaste(context: LogseqContextValue) {
             // Do we already have an asset for this image?
             const existingAsset = Object.values(app.assets).find(asset => asset.src === dataurl)
             if (existingAsset) {
-              assetsToCreate.push(existingAsset as ImageAsset)
+              imageAssetsToCreate.push(existingAsset as VideoImageAsset)
               continue
             }
             // Create a new asset for this image
-            const asset: ImageAsset = {
-              id: assetId,
-              type: 'image',
+            const asset: VideoImageAsset = {
+              id: uniqueId(),
+              type: isVideo ? 'video' : 'image',
               src: dataurl,
-              size: await getSizeFromSrc(handlers.makeAssetUrl(dataurl)),
+              size: await getSizeFromSrc(handlers.makeAssetUrl(dataurl), isVideo),
             }
-            assetsToCreate.push(asset)
+            imageAssetsToCreate.push(asset)
           } catch (error) {
             console.error(error)
           }
@@ -120,10 +131,11 @@ export function usePaste(context: LogseqContextValue) {
       }
 
       function handleTldrawShapes(rawText: string) {
+        const data = safeParseJson(rawText)
         try {
-          const data = JSON.parse(rawText)
-          if (data.type === 'logseq/whiteboard-shapes') {
+          if (data?.type === 'logseq/whiteboard-shapes') {
             const shapes = data.shapes as TLShapeModel[]
+            assetsToClone = data.assets as TLAsset[]
             const commonBounds = BoundsUtils.getCommonBounds(
               shapes.map(shape => ({
                 minX: shape.point?.[0] ?? point[0],
@@ -176,6 +188,7 @@ export function usePaste(context: LogseqContextValue) {
                 })
               }
             })
+
             return true
           }
         } catch (err) {
@@ -186,15 +199,14 @@ export function usePaste(context: LogseqContextValue) {
 
       function handleURL(rawText: string) {
         if (isValidURL(rawText)) {
-          const getYoutubeId = (url: string) => {
-            const match = url.match(/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#&?]*).*/)
-            return match && match[2].length === 11 ? match[2] : null
+          const isYoutubeUrl = (url: string) => {
+            const youtubeRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
+            return youtubeRegex.test(url)
           }
-          const youtubeId = getYoutubeId(rawText)
-          if (youtubeId) {
+          if (isYoutubeUrl(rawText)) {
             shapesToCreate.push({
               ...YouTubeShape.defaultProps,
-              embedId: youtubeId,
+              url: rawText,
               point: [point[0], point[1]],
             })
             return true
@@ -276,11 +288,11 @@ export function usePaste(context: LogseqContextValue) {
 
       const allShapesToAdd: TLShapeModel[] = [
         // assets to images
-        ...assetsToCreate.map((asset, i) => ({
-          type: 'image',
+        ...imageAssetsToCreate.map((asset, i) => ({
+          ...(asset.type === 'video' ? VideoShape : ImageShape).defaultProps,
           // TODO: Should be place near the last edited shape
-          point: [point[0] - asset.size[0] / 2 + i * 16, point[1] - asset.size[1] / 2 + i * 16],
-          size: asset.size,
+          point: [point[0] - asset.size[0] / 4 + i * 16, point[1] - asset.size[1] / 4 + i * 16],
+          size: Vec.div(asset.size, 2),
           assetId: asset.id,
           opacity: 1,
         })),
@@ -294,8 +306,9 @@ export function usePaste(context: LogseqContextValue) {
       })
 
       app.wrapUpdate(() => {
-        if (assetsToCreate.length > 0) {
-          app.createAssets(assetsToCreate)
+        const allAssets = [...imageAssetsToCreate, ...assetsToClone]
+        if (allAssets.length > 0) {
+          app.createAssets(allAssets)
         }
         if (allShapesToAdd.length > 0) {
           app.createShapes(allShapesToAdd)

+ 15 - 1
tldraw/apps/tldraw-logseq/src/index.ts

@@ -1,2 +1,16 @@
 export * from './app'
-export * from './lib/preview-manager'
+export * from './lib/preview-manager'
+
+declare global {
+  interface Window {
+    logseq?: {
+      api?: {
+        make_asset_url?: (url: string) => string
+        get_page_blocks_tree?: (pageName: string) => any[]
+        edit_block?: (uuid: string) => void
+        set_blocks_id?: (uuids: string[]) => void
+        open_external_link?: (url: string) => void
+      }
+    }
+  }
+}

+ 2 - 0
tldraw/apps/tldraw-logseq/src/lib/logseq-context.ts

@@ -29,6 +29,8 @@ export interface LogseqContextValue {
     isWhiteboardPage: (pageName: string) => boolean
     saveAsset: (file: File) => Promise<string>
     makeAssetUrl: (relativeUrl: string) => string
+    sidebarAddBlock: (uuid: string, type: 'block' | 'page') => void
+    redirectToPage: (uuidOrPageName: string) => void
   }
 }
 

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

@@ -1,5 +1,4 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import * as React from 'react'
 import { SVGContainer, TLComponentProps } from '@tldraw/react'
 import { TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
@@ -20,9 +19,11 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
     type: 'box',
     point: [0, 0],
     size: [100, 100],
-    borderRadius: 0,
+    borderRadius: 2,
     stroke: '#000000',
     fill: '#ffffff',
+    noFill: false,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }
@@ -33,7 +34,9 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
         size: [w, h],
         stroke,
         fill,
+        noFill,
         strokeWidth,
+        strokeType,
         borderRadius,
         opacity,
       },
@@ -43,7 +46,7 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
       <SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
         {isBinding && <BindingIndicator strokeWidth={strokeWidth} size={[w, h]} />}
         <rect
-          className={isSelected || fill !== 'transparent' ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
+          className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
           x={strokeWidth / 2}
           y={strokeWidth / 2}
           rx={borderRadius}
@@ -60,8 +63,9 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
           width={Math.max(0.01, w - strokeWidth)}
           height={Math.max(0.01, h - strokeWidth)}
           strokeWidth={strokeWidth}
-          stroke={stroke}
-          fill={fill}
+          stroke={noFill ? fill : stroke}
+          strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
+          fill={noFill ? 'none' : fill}
         />
       </SVGContainer>
     )

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

@@ -19,6 +19,8 @@ export class DotShape extends TLDotShape<DotShapeProps> {
     radius: 4,
     stroke: '#000000',
     fill: '#ffffff',
+    noFill: false,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }

+ 45 - 3
tldraw/apps/tldraw-logseq/src/lib/shapes/EllipseShape.tsx

@@ -21,6 +21,8 @@ export class EllipseShape extends TLEllipseShape<EllipseShapeProps> {
     size: [100, 100],
     stroke: '#000000',
     fill: '#ffffff',
+    noFill: false,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }
@@ -30,13 +32,15 @@ export class EllipseShape extends TLEllipseShape<EllipseShapeProps> {
       size: [w, h],
       stroke,
       fill,
+      noFill,
       strokeWidth,
+      strokeType,
       opacity,
     } = this.props
     return (
       <SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
         <ellipse
-          className={isSelected || fill !== 'transparent' ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
+          className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
           cx={w / 2}
           cy={h / 2}
           rx={Math.max(0.01, (w - strokeWidth) / 2)}
@@ -48,8 +52,9 @@ export class EllipseShape extends TLEllipseShape<EllipseShapeProps> {
           rx={Math.max(0.01, (w - strokeWidth) / 2)}
           ry={Math.max(0.01, (h - strokeWidth) / 2)}
           strokeWidth={strokeWidth}
-          stroke={stroke}
-          fill={fill}
+          stroke={noFill ? fill : stroke}
+          strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
+          fill={noFill ? 'none' : fill}
         />
       </SVGContainer>
     )
@@ -71,4 +76,41 @@ export class EllipseShape extends TLEllipseShape<EllipseShapeProps> {
     }
     return withClampedStyles(props)
   }
+
+  /**
+   * Get a svg group element that can be used to render the shape with only the props data. In the
+   * base, draw any shape as a box. Can be overridden by subclasses.
+   */
+  getShapeSVGJsx(opts: any) {
+    const {
+      size: [w, h],
+      stroke,
+      fill,
+      noFill,
+      strokeWidth,
+      strokeType,
+      opacity,
+    } = this.props
+    return (
+      <g opacity={opacity}>
+        <ellipse
+          className={!noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
+          cx={w / 2}
+          cy={h / 2}
+          rx={Math.max(0.01, (w - strokeWidth) / 2)}
+          ry={Math.max(0.01, (h - strokeWidth) / 2)}
+        />
+        <ellipse
+          cx={w / 2}
+          cy={h / 2}
+          rx={Math.max(0.01, (w - strokeWidth) / 2)}
+          ry={Math.max(0.01, (h - strokeWidth) / 2)}
+          strokeWidth={strokeWidth}
+          stroke={noFill ? fill : stroke}
+          strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
+          fill={noFill ? 'none' : fill}
+        />
+      </g>
+    )
+  }
 }

+ 59 - 25
tldraw/apps/tldraw-logseq/src/lib/shapes/HTMLShape.tsx

@@ -1,15 +1,27 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import * as React from 'react'
-import { TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
+import { delay, TLBoxShape, TLBoxShapeProps, TLResetBoundsInfo } from '@tldraw/core'
 import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
+import Vec from '@tldraw/vec'
+import { action, computed } from 'mobx'
 import { observer } from 'mobx-react-lite'
-import { CustomStyleProps, withClampedStyles } from './style-props'
+import * as React from 'react'
 import { useCameraMovingRef } from '~hooks/useCameraMoving'
-import type { Shape } from '~lib'
+import type { Shape, SizeLevel } from '~lib'
+import { withClampedStyles } from './style-props'
 
-export interface HTMLShapeProps extends TLBoxShapeProps, CustomStyleProps {
+export interface HTMLShapeProps extends TLBoxShapeProps {
   type: 'html'
   html: string
+  scaleLevel?: SizeLevel
+}
+
+const levelToScale = {
+  xs: 0.5,
+  sm: 0.8,
+  md: 1,
+  lg: 1.5,
+  xl: 2,
+  xxl: 3,
 }
 
 export class HTMLShape extends TLBoxShape<HTMLShapeProps> {
@@ -21,21 +33,47 @@ export class HTMLShape extends TLBoxShape<HTMLShapeProps> {
     parentId: 'page',
     point: [0, 0],
     size: [600, 0],
-    stroke: '#000000',
-    fill: '#ffffff',
-    strokeWidth: 2,
-    opacity: 1,
     html: '',
   }
 
   canChangeAspectRatio = true
   canFlip = false
   canEdit = true
-  hideContextBar = true
+  htmlAnchorRef = React.createRef<HTMLDivElement>()
+
+  @computed get scaleLevel() {
+    return this.props.scaleLevel ?? 'md'
+  }
+
+  @action setScaleLevel = async (v?: SizeLevel) => {
+    const newSize = Vec.mul(
+      this.props.size,
+      levelToScale[(v as SizeLevel) ?? 'md'] / levelToScale[this.props.scaleLevel ?? 'md']
+    )
+    this.update({
+      scaleLevel: v,
+    })
+    await delay()
+    this.update({
+      size: newSize,
+    })
+  }
+
+  onResetBounds = (info?: TLResetBoundsInfo) => {
+    if (this.htmlAnchorRef.current) {
+      const rect = this.htmlAnchorRef.current.getBoundingClientRect()
+      const [w, h] = Vec.div([rect.width, rect.height], info?.zoom ?? 1)
+      const clamp = (v: number) => Math.max(Math.min(v || 400, 1400), 10)
+      this.update({
+        size: [clamp(w), clamp(h)],
+      })
+    }
+    return this
+  }
 
   ReactComponent = observer(({ events, isErasing, isEditing }: TLComponentProps) => {
     const {
-      props: { opacity, html },
+      props: { html, scaleLevel },
     } = this
     const isMoving = useCameraMovingRef()
     const app = useApp<Shape>()
@@ -53,13 +91,11 @@ export class HTMLShape extends TLBoxShape<HTMLShapeProps> {
       [tlEventsEnabled]
     )
 
-    const anchorRef = React.useRef<HTMLDivElement>(null)
+    const scaleRatio = levelToScale[scaleLevel ?? 'md']
 
     React.useEffect(() => {
-      if (this.props.size[1] === 0 && anchorRef.current) {
-        this.update({
-          size: [this.props.size[0], anchorRef.current.offsetHeight],
-        })
+      if (this.props.size[1] === 0) {
+        this.onResetBounds()
         app.persist(true)
       }
     }, [])
@@ -69,7 +105,7 @@ export class HTMLShape extends TLBoxShape<HTMLShapeProps> {
         style={{
           overflow: 'hidden',
           pointerEvents: 'all',
-          opacity: isErasing ? 0.2 : opacity,
+          opacity: isErasing ? 0.2 : 1,
         }}
         {...events}
       >
@@ -79,19 +115,17 @@ export class HTMLShape extends TLBoxShape<HTMLShapeProps> {
           onPointerUp={stop}
           className="tl-html-container"
           style={{
-            width: '100%',
-            height: '100%',
-            pointerEvents: isEditing ? 'all' : 'none',
-            userSelect: 'all',
-            position: 'relative',
-            margin: 0,
+            pointerEvents: !isMoving && (isEditing || isSelected) ? 'all' : 'none',
             overflow: isEditing ? 'auto' : 'hidden',
+            width: `calc(100% / ${scaleRatio})`,
+            height: `calc(100% / ${scaleRatio})`,
+            transform: `scale(${scaleRatio})`,
           }}
         >
           <div
-            ref={anchorRef}
+            ref={this.htmlAnchorRef}
             className="tl-html-anchor"
-            dangerouslySetInnerHTML={{ __html: html }}
+            dangerouslySetInnerHTML={{ __html: html.trim() }}
           />
         </div>
       </HTMLContainer>

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

@@ -26,7 +26,9 @@ export class HighlighterShape extends TLDrawShape<HighlighterShapeProps> {
     points: [],
     isComplete: false,
     stroke: '#ffcc00',
-    fill: '#ffffff',
+    fill: '#ffcc00',
+    noFill: true,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }

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

@@ -3,10 +3,9 @@ import * as React from 'react'
 import { HTMLContainer, TLComponentProps } from '@tldraw/react'
 import { TLAsset, TLImageShape, TLImageShapeProps } from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
-import type { CustomStyleProps } from './style-props'
 import { LogseqContext } from '~lib/logseq-context'
 
-export interface ImageShapeProps extends TLImageShapeProps, CustomStyleProps {
+export interface ImageShapeProps extends TLImageShapeProps {
   type: 'image'
   assetId: string
   opacity: number
@@ -21,9 +20,6 @@ export class ImageShape extends TLImageShape<ImageShapeProps> {
     type: 'image',
     point: [0, 0],
     size: [100, 100],
-    stroke: '#000000',
-    fill: '#ffffff',
-    strokeWidth: 2,
     opacity: 1,
     assetId: '',
     clipping: 0,

+ 4 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/LineShape.tsx

@@ -32,6 +32,8 @@ export class LineShape extends TLLineShape<LineShapeProps> {
     },
     stroke: 'var(--ls-primary-text-color, #000)',
     fill: '#ffffff',
+    noFill: true,
+    strokeType: 'line',
     strokeWidth: 1,
     opacity: 1,
     decorations: {
@@ -151,6 +153,7 @@ export class LineShape extends TLLineShape<LineShapeProps> {
       stroke,
       fill,
       strokeWidth,
+      strokeType,
       decorations,
       label,
       handles: { start, end },
@@ -163,6 +166,7 @@ export class LineShape extends TLLineShape<LineShapeProps> {
             stroke,
             fill,
             strokeWidth,
+            strokeType
           }}
           start={start.point}
           end={end.point}

+ 120 - 87
tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx

@@ -1,18 +1,26 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import { TLBoxShape, TLBoxShapeProps, TLResizeInfo, validUUID } from '@tldraw/core'
+import {
+  delay,
+  TLBoxShape,
+  TLBoxShapeProps,
+  TLResetBoundsInfo,
+  TLResizeInfo,
+  validUUID,
+} from '@tldraw/core'
 import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
 import Vec from '@tldraw/vec'
-import { makeObservable, runInAction } from 'mobx'
+import { action, computed, makeObservable } from 'mobx'
 import { observer } from 'mobx-react-lite'
 import * as React from 'react'
 import { TablerIcon } from '~components/icons'
-import { SwitchInput } from '~components/inputs/SwitchInput'
+import { TextInput } from '~components/inputs/TextInput'
 import { useCameraMovingRef } from '~hooks/useCameraMoving'
-import type { Shape } from '~lib'
+import type { Shape, SizeLevel } from '~lib'
 import { LogseqContext, SearchResult } from '~lib/logseq-context'
 import { CustomStyleProps, withClampedStyles } from './style-props'
 
 const HEADER_HEIGHT = 40
+const AUTO_RESIZE_THRESHOLD = 1
 
 export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProps {
   type: 'logseq-portal'
@@ -21,12 +29,22 @@ export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProp
   collapsed?: boolean
   compact?: boolean
   collapsedHeight?: number
+  scaleLevel?: SizeLevel
 }
 
 interface LogseqQuickSearchProps {
   onChange: (id: string) => void
 }
 
+const levelToScale = {
+  xs: 0.5,
+  sm: 0.8,
+  md: 1,
+  lg: 1.5,
+  xl: 2,
+  xxl: 3,
+}
+
 const LogseqTypeTag = ({
   type,
   active,
@@ -118,11 +136,14 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     collapsedHeight: 0,
     stroke: 'var(--ls-primary-text-color)',
     fill: 'var(--ls-secondary-background-color)',
+    noFill: false,
     strokeWidth: 2,
+    strokeType: 'line',
     opacity: 1,
     pageId: '',
     collapsed: false,
     compact: false,
+    scaleLevel: 'md',
   }
 
   hideRotateHandle = true
@@ -154,6 +175,49 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     return false
   }
 
+  @computed get collapsed() {
+    return this.props.blockType === 'B' ? this.props.compact : this.props.collapsed
+  }
+
+  @action setCollapsed = async (collapsed: boolean) => {
+    if (this.props.blockType === 'B') {
+      this.update({ compact: collapsed })
+      this.canResize[1] = !collapsed
+      if (!collapsed) {
+        // this will also persist the state, so we can skip persist call
+        await delay()
+        this.onResetBounds()
+      }
+      this.persist?.()
+    } else {
+      const originalHeight = this.props.size[1]
+      this.canResize[1] = !collapsed
+      this.update({
+        collapsed: collapsed,
+        size: [this.props.size[0], collapsed ? HEADER_HEIGHT : this.props.collapsedHeight],
+        collapsedHeight: collapsed ? originalHeight : this.props.collapsedHeight,
+      })
+    }
+  }
+
+  @computed get scaleLevel() {
+    return this.props.scaleLevel ?? 'md'
+  }
+
+  @action setScaleLevel = async (v?: SizeLevel) => {
+    const newSize = Vec.mul(
+      this.props.size,
+      levelToScale[(v as SizeLevel) ?? 'md'] / levelToScale[this.props.scaleLevel ?? 'md']
+    )
+    this.update({
+      scaleLevel: v,
+    })
+    await delay()
+    this.update({
+      size: newSize,
+    })
+  }
+
   useComponentSize<T extends HTMLElement>(ref: React.RefObject<T> | null, selector = '') {
     const [size, setSize] = React.useState<[number, number]>([0, 0])
     const app = useApp<Shape>()
@@ -169,7 +233,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
           }
           updateSize()
           // Hacky, I know 🤨
-          this.getInnerHeight = () => updateSize()[1] + 2 // 2 is a hack to compensate for the border
+          this.getInnerHeight = () => updateSize()[1]
           const resizeObserver = new ResizeObserver(() => {
             updateSize()
           })
@@ -184,54 +248,6 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     return size
   }
 
-  ReactContextBar = observer(() => {
-    const app = useApp<Shape>()
-    return (
-      <>
-        {this.props.blockType !== 'B' && (
-          <SwitchInput
-            label="Collapsed"
-            checked={this.props.collapsed}
-            onCheckedChange={collapsing => {
-              runInAction(() => {
-                const originalHeight = this.props.size[1]
-                this.canResize[1] = !collapsing
-                this.update({
-                  collapsed: collapsing,
-                  size: [
-                    this.props.size[0],
-                    collapsing ? HEADER_HEIGHT : this.props.collapsedHeight,
-                  ],
-                  collapsedHeight: collapsing ? originalHeight : this.props.collapsedHeight,
-                })
-                app.persist()
-              })
-            }}
-          />
-        )}
-
-        {this.props.blockType === 'B' && (
-          <SwitchInput
-            label="Compact"
-            checked={this.props.compact}
-            onCheckedChange={compact => {
-              runInAction(() => {
-                this.update({ compact })
-                this.canResize[1] = !compact
-                if (!compact) {
-                  // this will also persist the state, so we can skip persist call
-                  this.autoResizeHeight()
-                } else {
-                  app.persist()
-                }
-              })
-            }}
-          />
-        )}
-      </>
-    )
-  })
-
   shouldAutoResizeHeight() {
     return this.props.blockType === 'B' && this.props.compact
   }
@@ -247,17 +263,15 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     return null
   }
 
-  autoResizeHeight(replace: boolean = false) {
-    setTimeout(() => {
-      const height = this.getAutoResizeHeight()
-      if (height !== null) {
-        this.update({
-          size: [this.props.size[0], height],
-        })
-        this.persist?.(replace)
-        this.initialHeightCalculated = true
-      }
-    })
+  onResetBounds = (info?: TLResetBoundsInfo) => {
+    const height = this.getAutoResizeHeight()
+    if (height !== null && Math.abs(height - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
+      this.update({
+        size: [this.props.size[0], height],
+      })
+      this.initialHeightCalculated = true
+    }
+    return this
   }
 
   onResize = (initialProps: any, info: TLResizeInfo): this => {
@@ -300,7 +314,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
         finishCreating(uuid)
         // wait until the editor is mounted
         setTimeout(() => {
-          app.setEditingShape(this)
+          app.api.editShape(this)
           window.logseq?.api?.edit_block?.(uuid)
         })
       }
@@ -527,22 +541,19 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
               </div>
             </div>
           )}
-          <div className="tl-quick-search-input-sizer" data-value={q}>
-            <div className="tl-quick-search-input-hidden">{q}</div>
-            <input
-              ref={rInput}
-              type="text"
-              value={q}
-              placeholder="Create or search your graph..."
-              onChange={q => setQ(q.target.value)}
-              onKeyDown={e => {
-                if (e.key === 'Enter') {
-                  finishCreating(q)
-                }
-              }}
-              className="tl-quick-search-input"
-            />
-          </div>
+          <TextInput
+            ref={rInput}
+            type="text"
+            value={q}
+            className="tl-quick-search-input"
+            placeholder="Create or search your graph..."
+            onChange={q => setQ(q.target.value)}
+            onKeyDown={e => {
+              if (e.key === 'Enter') {
+                finishCreating(q)
+              }
+            }}
+          />
         </div>
         <div className="tl-quick-search-options" ref={optionsWrapperRef}>
           {options.map(({ actionIcon, onChosen, element }, index) => {
@@ -597,8 +608,8 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
 
     React.useEffect(() => {
       if (this.shouldAutoResizeHeight()) {
-        const newHeight = innerHeight + this.getHeaderHeight() + 2
-        if (innerHeight && newHeight !== this.props.size[1]) {
+        const newHeight = innerHeight + this.getHeaderHeight()
+        if (innerHeight && Math.abs(newHeight - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
           this.update({
             size: [this.props.size[0], newHeight],
           })
@@ -609,7 +620,10 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
 
     React.useEffect(() => {
       if (!this.initialHeightCalculated) {
-        this.autoResizeHeight(true)
+        setTimeout(() => {
+          this.onResetBounds()
+          app.persist(true)
+        })
       }
     }, [this.initialHeightCalculated])
 
@@ -633,7 +647,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
   ReactComponent = observer((componentProps: TLComponentProps) => {
     const { events, isErasing, isEditing, isBinding } = componentProps
     const {
-      props: { opacity, pageId, stroke, fill },
+      props: { opacity, pageId, stroke, fill, scaleLevel },
     } = this
 
     const app = useApp<Shape>()
@@ -656,6 +670,19 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
       [tlEventsEnabled]
     )
 
+    // There are some other portal sharing the same page id are selected
+    const portalSelected =
+      app.selectedShapesArray.length === 1 &&
+      app.selectedShapesArray.some(
+        shape =>
+          shape.type === 'logseq-portal' &&
+          shape.props.id !== this.props.id &&
+          pageId &&
+          (shape as LogseqPortalShape).props['pageId'] === pageId
+      )
+
+    const scaleRatio = levelToScale[scaleLevel ?? 'md']
+
     // It is a bit weird to update shapes here. Is there a better place?
     React.useEffect(() => {
       if (this.props.collapsed && isEditing) {
@@ -730,12 +757,17 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
             <div
               className="tl-logseq-portal-container"
               data-collapsed={this.props.collapsed}
+              data-page-id={pageId}
+              data-portal-selected={portalSelected}
               style={{
                 background: this.props.compact ? 'transparent' : fill,
                 boxShadow: isBinding
                   ? '0px 0px 0 var(--tl-binding-distance) var(--tl-binding)'
                   : 'none',
                 color: stroke,
+                width: `calc(100% / ${scaleRatio})`,
+                height: `calc(100% / ${scaleRatio})`,
+                transform: `scale(${scaleRatio})`,
                 // @ts-expect-error ???
                 '--ls-primary-background-color': !fill?.startsWith('var') ? fill : undefined,
                 '--ls-primary-text-color': !stroke?.startsWith('var') ? stroke : undefined,
@@ -767,8 +799,9 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
 
   validateProps = (props: Partial<LogseqPortalShapeProps>) => {
     if (props.size !== undefined) {
-      props.size[0] = Math.max(props.size[0], 240)
-      props.size[1] = Math.max(props.size[1], HEADER_HEIGHT)
+      const scale = levelToScale[this.props.scaleLevel ?? 'md']
+      props.size[0] = Math.max(props.size[0], 240 * scale)
+      props.size[1] = Math.max(props.size[1], HEADER_HEIGHT * scale)
     }
     return withClampedStyles(props)
   }

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

@@ -28,6 +28,8 @@ export class PenShape extends TLDrawShape<PenShapeProps> {
     isComplete: false,
     stroke: '#000000',
     fill: '#ffffff',
+    noFill: false,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }

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

@@ -26,7 +26,9 @@ export class PencilShape extends TLDrawShape<PencilShapeProps> {
     points: [],
     isComplete: false,
     stroke: 'var(--tl-foreground, #000)',
-    fill: '#ffffff',
+    fill: 'var(--tl-foreground, #000)',
+    noFill: true,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }
@@ -69,7 +71,7 @@ export class PencilShape extends TLDrawShape<PencilShapeProps> {
   getShapeSVGJsx() {
     const {
       pointsPath,
-      props: { stroke, strokeWidth },
+      props: { stroke, noFill, strokeWidth, strokeType },
     } = this
     return (
       <path
@@ -78,6 +80,7 @@ export class PencilShape extends TLDrawShape<PencilShapeProps> {
         stroke={stroke}
         fill={stroke}
         pointerEvents="all"
+        strokeDasharray={strokeType === 'dashed' ? '12 4' : undefined}
       />
     )
   }

+ 38 - 6
tldraw/apps/tldraw-logseq/src/lib/shapes/PolygonShape.tsx

@@ -23,6 +23,8 @@ export class PolygonShape extends TLPolygonShape<PolygonShapeProps> {
     isFlippedY: false,
     stroke: '#000000',
     fill: '#ffffff',
+    noFill: false,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }
@@ -30,24 +32,25 @@ export class PolygonShape extends TLPolygonShape<PolygonShapeProps> {
   ReactComponent = observer(({ events, isErasing, isSelected }: TLComponentProps) => {
     const {
       offset: [x, y],
-      props: { stroke, fill, strokeWidth, opacity },
+      props: { stroke, fill, noFill, strokeWidth, opacity, strokeType },
     } = this
     const path = this.getVertices(strokeWidth / 2).join()
     return (
       <SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
         <g transform={`translate(${x}, ${y})`}>
           <polygon
-            className={
-              isSelected || fill !== 'transparent' ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'
-            }
+            className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
             points={path}
           />
           <polygon
             points={path}
-            stroke={stroke}
-            fill={fill}
+            stroke={noFill ? fill : stroke}
+            fill={noFill ? 'none' : fill}
             strokeWidth={strokeWidth}
+            rx={2}
+            ry={2}
             strokeLinejoin="round"
+            strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
           />
         </g>
       </SVGContainer>
@@ -71,4 +74,33 @@ export class PolygonShape extends TLPolygonShape<PolygonShapeProps> {
     if (props.sides !== undefined) props.sides = Math.max(props.sides, 3)
     return withClampedStyles(props)
   }
+
+  /**
+   * Get a svg group element that can be used to render the shape with only the props data. In the
+   * base, draw any shape as a box. Can be overridden by subclasses.
+   */
+  getShapeSVGJsx(opts: any) {
+    // Do not need to consider the original point here
+    const {
+      offset: [x, y],
+      props: { stroke, fill, noFill, strokeWidth, opacity, strokeType },
+    } = this
+    const path = this.getVertices(strokeWidth / 2).join()
+
+    return (
+      <g transform={`translate(${x}, ${y})`} opacity={opacity}>
+        <polygon className={!noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'} points={path} />
+        <polygon
+          points={path}
+          stroke={noFill ? fill : stroke}
+          fill={noFill ? 'none' : fill}
+          strokeWidth={strokeWidth}
+          rx={2}
+          ry={2}
+          strokeLinejoin="round"
+          strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
+        />
+      </g>
+    )
+  }
 }

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

@@ -1,8 +1,10 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import * as React from 'react'
-import { HTMLContainer, TLComponentProps, TLTextMeasure } from '@tldraw/react'
 import { TextUtils, TLBounds, TLResizeStartInfo, TLTextShape, TLTextShapeProps } from '@tldraw/core'
+import { HTMLContainer, TLComponentProps, TLTextMeasure } from '@tldraw/react'
+import { action, computed } from 'mobx'
 import { observer } from 'mobx-react-lite'
+import * as React from 'react'
+import type { SizeLevel } from '~lib'
 import { CustomStyleProps, withClampedStyles } from './style-props'
 
 export interface TextShapeProps extends TLTextShapeProps, CustomStyleProps {
@@ -13,6 +15,16 @@ export interface TextShapeProps extends TLTextShapeProps, CustomStyleProps {
   lineHeight: number
   padding: number
   type: 'text'
+  scaleLevel?: SizeLevel
+}
+
+const levelToScale = {
+  xs: 10,
+  sm: 16,
+  md: 20,
+  lg: 32,
+  xl: 48,
+  xxl: 60,
 }
 
 export class TextShape extends TLTextShape<TextShapeProps> {
@@ -34,6 +46,8 @@ export class TextShape extends TLTextShape<TextShapeProps> {
     borderRadius: 0,
     stroke: 'var(--tl-foreground, #000)',
     fill: '#ffffff',
+    noFill: true,
+    strokeType: 'line',
     strokeWidth: 2,
     opacity: 1,
   }
@@ -196,6 +210,18 @@ export class TextShape extends TLTextShape<TextShapeProps> {
     )
   })
 
+  @computed get scaleLevel() {
+    return this.props.scaleLevel ?? 'md'
+  }
+
+  @action setScaleLevel = async (v?: SizeLevel) => {
+    this.update({
+      scaleLevel: v,
+      fontSize: levelToScale[v ?? 'md']
+    })
+    this.onResetBounds()
+  }
+
   ReactIndicator = observer(() => {
     const {
       props: { borderRadius },

+ 97 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/VideoShape.tsx

@@ -0,0 +1,97 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
+import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
+import { observer } from 'mobx-react-lite'
+import * as React from 'react'
+import { useCameraMovingRef } from '~hooks/useCameraMoving'
+import type { Shape } from '~lib'
+import { LogseqContext } from '~lib/logseq-context'
+
+export interface VideoShapeProps extends TLBoxShapeProps {
+  type: 'video'
+  assetId: string
+  opacity: number
+}
+
+export class VideoShape extends TLBoxShape<VideoShapeProps> {
+  static id = 'video'
+
+  static defaultProps: VideoShapeProps = {
+    id: 'video1',
+    parentId: 'page',
+    type: 'video',
+    point: [0, 0],
+    size: [100, 100],
+    opacity: 1,
+    assetId: '',
+    clipping: 0,
+    isAspectRatioLocked: true,
+  }
+
+  canFlip = false
+  canEdit = true
+  canChangeAspectRatio = false
+
+  ReactComponent = observer(({ events, isErasing, asset, isEditing }: TLComponentProps) => {
+    const {
+      props: {
+        opacity,
+        size: [w, h],
+      },
+    } = this
+
+    const isMoving = useCameraMovingRef()
+    const app = useApp<Shape>()
+
+    const isSelected = app.selectedIds.has(this.id)
+
+    const tlEventsEnabled =
+      isMoving || (isSelected && !isEditing) || app.selectedTool.id !== 'select'
+    const stop = React.useCallback(
+      e => {
+        if (!tlEventsEnabled) {
+          // TODO: pinching inside Logseq Shape issue
+          e.stopPropagation()
+        }
+      },
+      [tlEventsEnabled]
+    )
+
+    const { handlers } = React.useContext(LogseqContext)
+
+    return (
+      <HTMLContainer
+        style={{
+          overflow: 'hidden',
+          pointerEvents: 'all',
+          opacity: isErasing ? 0.2 : opacity,
+        }}
+        {...events}
+      >
+        <div
+          onWheelCapture={stop}
+          onPointerDown={stop}
+          onPointerUp={stop}
+          className="tl-video-container"
+          style={{
+            pointerEvents: !isMoving && (isEditing || isSelected) ? 'all' : 'none',
+            overflow: isEditing ? 'auto' : 'hidden',
+          }}
+        >
+          {asset && (
+            <video controls src={handlers ? handlers.makeAssetUrl(asset.src) : asset.src} />
+          )}
+        </div>
+      </HTMLContainer>
+    )
+  })
+
+  ReactIndicator = observer(() => {
+    const {
+      props: {
+        size: [w, h],
+      },
+    } = this
+    return <rect width={w} height={h} fill="transparent" />
+  })
+}

+ 24 - 61
tldraw/apps/tldraw-logseq/src/lib/shapes/YouTubeShape.tsx

@@ -1,14 +1,13 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import * as React from 'react'
 import { TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
-import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
+import { HTMLContainer, TLComponentProps } from '@tldraw/react'
+import { action, computed } from 'mobx'
 import { observer } from 'mobx-react-lite'
-import { CustomStyleProps, withClampedStyles } from './style-props'
-import { TextInput } from '~components/inputs/TextInput'
+import { withClampedStyles } from './style-props'
 
-export interface YouTubeShapeProps extends TLBoxShapeProps, CustomStyleProps {
+export interface YouTubeShapeProps extends TLBoxShapeProps {
   type: 'youtube'
-  embedId: string
+  url: string
 }
 
 export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
@@ -20,11 +19,7 @@ export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
     parentId: 'page',
     point: [0, 0],
     size: [600, 320],
-    stroke: '#000000',
-    fill: '#ffffff',
-    strokeWidth: 2,
-    opacity: 1,
-    embedId: '',
+    url: '',
   }
 
   aspectRatio = 480 / 853
@@ -35,55 +30,37 @@ export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
 
   canEdit = true
 
-  ReactContextBar = observer(() => {
-    const { embedId } = this.props
-    const rInput = React.useRef<HTMLInputElement>(null)
-    const app = useApp()
-    const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-      const url = e.currentTarget.value
-      const match = url.match(
-        /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
-      )
-      const embedId = match?.[1] ?? url ?? ''
-      this.update({ embedId, size: YouTubeShape.defaultProps.size })
-      app.persist()
-    }, [])
-    return (
-      <>
-        <TextInput
-          ref={rInput}
-          label="Youtube Video ID"
-          type="text"
-          value={embedId}
-          onChange={handleChange}
-        />
-      </>
+  @computed get embedId() {
+    const url = this.props.url
+    const match = url.match(
+      /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
     )
-  })
+    const embedId = match?.[1] ?? url ?? ''
+    return embedId
+  }
+
+  @action onYoutubeLinkChange = (url: string) => {
+    this.update({ url, size: YouTubeShape.defaultProps.size })
+  }
 
   ReactComponent = observer(({ events, isErasing, isEditing }: TLComponentProps) => {
-    const {
-      props: { opacity, embedId },
-    } = this
     return (
       <HTMLContainer
         style={{
           overflow: 'hidden',
           pointerEvents: 'all',
-          opacity: isErasing ? 0.2 : opacity,
+          opacity: isErasing ? 0.2 : 1,
         }}
         {...events}
       >
         <div
+          className="rounded-lg w-full h-full relative overflow-hidden shadow-xl"
           style={{
-            width: '100%',
-            height: '100%',
             pointerEvents: isEditing ? 'all' : 'none',
             userSelect: 'none',
-            position: 'relative',
           }}
         >
-          {embedId ? (
+          {this.embedId ? (
             <div
               style={{
                 overflow: 'hidden',
@@ -93,17 +70,10 @@ export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
               }}
             >
               <iframe
-                style={{
-                  left: 0,
-                  top: 0,
-                  height: '100%',
-                  width: '100%',
-                  position: 'absolute',
-                  margin: 0,
-                }}
+                className="absolute inset-0 w-full h-full m-0"
                 width="853"
                 height="480"
-                src={`https://www.youtube.com/embed/${embedId}`}
+                src={`https://www.youtube.com/embed/${this.embedId}`}
                 frameBorder="0"
                 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
                 allowFullScreen
@@ -112,16 +82,9 @@ export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
             </div>
           ) : (
             <div
+              className="w-full h-full flex items-center justify-center p-4"
               style={{
-                width: '100%',
-                height: '100%',
-                display: 'flex',
-                alignItems: 'center',
-                overflow: 'hidden',
-                justifyContent: 'center',
-                backgroundColor: '#ffffff',
-                border: '1px solid rgb(52, 52, 52)',
-                padding: 16,
+                backgroundColor: 'var(--ls-primary-background-color)',
               }}
             >
               <svg

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

@@ -7,6 +7,7 @@ import { getStraightArrowHeadPoints } from './arrowHelpers'
 interface ShapeStyles {
   stroke: string
   strokeWidth: number
+  strokeType: 'line' | 'dashed'
   fill: string
 }
 
@@ -44,11 +45,11 @@ export const Arrow = React.memo(function StraightArrow({
       <path className="tl-stroke-hitarea" d={path} />
       <path
         d={path}
-        fill={style.stroke}
         strokeWidth={sw}
         stroke={style.stroke}
         strokeLinecap="round"
         strokeLinejoin="round"
+        strokeDasharray={style.strokeType === 'dashed' ? '8 4' : undefined}
         pointerEvents="stroke"
       />
       {startArrowHead && (

+ 5 - 12
tldraw/apps/tldraw-logseq/src/lib/shapes/index.ts

@@ -5,6 +5,7 @@ import { EllipseShape } from './EllipseShape'
 import { HighlighterShape } from './HighlighterShape'
 import { HTMLShape } from './HTMLShape'
 import { ImageShape } from './ImageShape'
+import { VideoShape } from './VideoShape'
 import { LineShape } from './LineShape'
 import { LogseqPortalShape } from './LogseqPortalShape'
 import { PencilShape } from './PencilShape'
@@ -18,7 +19,7 @@ export type Shape =
   | EllipseShape
   | HighlighterShape
   | ImageShape
-  | LineShape
+  | VideoShape
   | LineShape
   | PencilShape
   | PolygonShape
@@ -33,6 +34,7 @@ export * from './EllipseShape'
 export * from './HighlighterShape'
 export * from './HTMLShape'
 export * from './ImageShape'
+export * from './VideoShape'
 export * from './LineShape'
 export * from './LogseqPortalShape'
 export * from './PencilShape'
@@ -46,6 +48,7 @@ export const shapes: TLReactShapeConstructor<Shape>[] = [
   EllipseShape,
   HighlighterShape,
   ImageShape,
+  VideoShape,
   LineShape,
   PencilShape,
   PolygonShape,
@@ -55,14 +58,4 @@ export const shapes: TLReactShapeConstructor<Shape>[] = [
   LogseqPortalShape,
 ]
 
-declare global {
-  interface Window {
-    logseq?: {
-      api?: {
-        make_asset_url?: (url: string) => string
-        edit_block?: (uuid: string) => void
-        set_blocks_id?: (uuids: string[]) => void
-      }
-    }
-  }
-}
+export type SizeLevel = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'

+ 2 - 12
tldraw/apps/tldraw-logseq/src/lib/shapes/style-props.tsx

@@ -1,22 +1,12 @@
 export interface CustomStyleProps {
   stroke: string
   fill: string
+  noFill: boolean
   strokeWidth: number
+  strokeType: 'dashed' | 'line'
   opacity: number
 }
 
-export function withDefaultStyles<P>(props: P & Partial<CustomStyleProps>): P & CustomStyleProps {
-  return Object.assign(
-    {
-      stroke: '#000000',
-      fill: '#ffffff',
-      strokeWidth: 2,
-      opacity: 1,
-    },
-    props
-  )
-}
-
 export function withClampedStyles<P>(props: P & Partial<CustomStyleProps>) {
   if (props.strokeWidth !== undefined) props.strokeWidth = Math.max(props.strokeWidth, 1)
   if (props.opacity !== undefined) props.opacity = Math.min(1, Math.max(props.opacity, 0))

+ 172 - 21
tldraw/apps/tldraw-logseq/src/styles.css

@@ -28,7 +28,7 @@
   z-index: 100000;
   user-select: none;
   background: white;
-  border-bottom: 1px solid black;
+  border-bottom: 1px solid var(--ls-secondary-border-color);
   font-size: inherit;
 }
 
@@ -100,7 +100,7 @@
 
   pointer-events: all;
   position: relative;
-  background-color: #fff;
+  background-color: var(--ls-secondary-background-color);
   color: #a4b5b6;
   padding: 8px 12px;
   border-radius: 8px;
@@ -116,8 +116,7 @@
   }
 
   .input {
-    display: flex;
-    flex-direction: column;
+    @apply flex items-center;
     gap: 4px;
   }
 
@@ -129,15 +128,15 @@
 
   .color-input-wrapper {
     overflow: hidden;
-    height: 18px;
-    width: 46px;
+    height: 20px;
+    width: 20px;
     border-radius: 2px;
     margin: 2px;
     box-shadow: 0 0 0 2px var(--ls-tertiary-background-color);
   }
 
   .color-input {
-    transform: translate(-4px, -4px) scale(1.5);
+    transform: translate(-50%, -50%) scale(4);
   }
 
   .switch-input-root {
@@ -163,7 +162,7 @@
       transform: translateX(3px);
       will-change: transform;
     }
-  
+
     &[data-state='checked'] .switch-input-thumb {
       transform: translateX(17px);
     }
@@ -192,7 +191,7 @@
   z-index: 100000;
   user-select: none;
   background: white;
-  border-top: 1px solid black;
+  border-top: 1px solid var(--ls-secondary-border-color);
   flex-shrink: 0;
   height: 32px;
 }
@@ -241,6 +240,49 @@
   pointer-events: all;
 }
 
+button.tl-select-input-trigger {
+  @apply flex items-center py-1 px-3;
+  box-shadow: 0 0 0 1px var(--ls-secondary-border-color);
+  background-color: var(--ls-quaternary-background-color);
+  min-width: 160px;
+  border-radius: 8px;
+  font-size: 16px;
+  height: 100%;
+  color: var(--ls-secondary-text-color);
+}
+
+.tl-select-input-trigger-value {
+  @apply flex items-center justify-start flex-1;
+  line-height: 24px;
+}
+
+.tl-select-input-viewport {
+  border-radius: 8px;
+  background-color: var(--ls-secondary-background-color);
+}
+
+.tl-select-input-select-item {
+  cursor: default;
+  padding: 4px 12px;
+
+  color: var(--ls-secondary-text-color);
+
+  &[data-state='unchecked']:hover {
+    background-color: var(--ls-tertiary-background-color);
+    color: var(--ls-primary-text-color);
+  }
+
+  &[data-state='checked'] {
+    background-color: #4285f4;
+    color: #fff;
+  }
+}
+
+.tl-select-input-content {
+  z-index: 100;
+  font-family: var(--ls-font-family);
+}
+
 .tl-geometry-tools-pane-anchor {
   @apply relative;
 }
@@ -287,9 +329,6 @@
   backface-visibility: hidden;
   pointer-events: all;
   vertical-align: baseline;
-  -webkit-user-drag: none;
-  -webkit-user-select: none;
-  -webkit-touch-callout: none;
 }
 
 .tl-text-shape-input {
@@ -313,7 +352,6 @@
   backface-visibility: hidden;
   pointer-events: all;
   user-select: text;
-  -webkit-user-select: text;
 }
 
 .tl-stroke-hitarea {
@@ -468,20 +506,29 @@
   }
 }
 
-.tl-quick-search-input-sizer {
+.tl-input {
   position: relative;
 }
 
-.tl-quick-search-input {
+.tl-input-sizer {
+  position: relative;
+}
+
+.tl-text-input {
   @apply absolute inset-0;
 
   outline: none;
 }
 
-.tl-quick-search-input-hidden {
+.tl-input-hidden {
+  white-space: pre;
   opacity: 0;
-  min-width: 240px;
   min-height: 24px;
+  min-width: 60px;
+}
+
+.tl-quick-search-input {
+  min-width: 240px;
 }
 
 .tl-quick-search-options {
@@ -530,17 +577,23 @@
 }
 
 .tl-logseq-portal-container {
-  @apply flex flex-col rounded-lg;
+  @apply flex flex-col rounded-lg absolute;
 
-  width: calc(100% - 2px);
-  height: calc(100% - 2px);
+  top: 0;
+  left: 0;
   transform: translate(1px, 1px);
   overscroll-behavior: none;
   opacity: 1;
+  user-select: text;
+  transform-origin: top left;
 
   &[data-collapsed='true'] {
     @apply overflow-hidden;
   }
+
+  &[data-portal-selected='true'] {
+    filter: brightness(0.9);
+  }
 }
 
 .tl-logseq-portal-header {
@@ -576,11 +629,29 @@
   flex-grow: 0;
 }
 
-.tl-html-container > iframe {
+.tl-html-container {
+  @apply h-full w-full m-0 relative;
+  user-select: text;
+  transform-origin: top left;
+}
+
+.tl-html-anchor {
+  width: fit-content;
+}
+
+.tl-html-anchor > iframe {
   @apply h-full w-full !important;
   margin: 0;
 }
 
+.tl-video-container {
+  @apply h-full w-full m-0 relative;
+
+  > video {
+    @apply h-full w-full m-0 relative;
+  }
+}
+
 .tl-logseq-cp-container {
   @apply h-full w-full rounded-lg;
 
@@ -631,3 +702,83 @@ html[data-theme='dark'] {
     backdrop-filter: brightness(1.2);
   }
 }
+
+.tl-toggle-group-input {
+  @apply rounded overflow-hidden;
+  box-shadow: 0 0 0 1px var(--ls-secondary-border-color);
+}
+
+.tl-toggle-group-input-button {
+  @apply inline-flex items-center justify-center;
+  border-right: 1px solid var(--ls-secondary-border-color);
+  height: 32px;
+  width: 32px;
+  color: var(--ls-secondary-text-color);
+  opacity: 0.3;
+
+  &:last-of-type {
+    border-right: none;
+  }
+
+  &:hover {
+    background-color: var(--ls-tertiary-background-color);
+  }
+
+  &[data-state='on'] {
+    background-color: var(--ls-tertiary-background-color);
+    color: var(--ls-primary-text-color);
+    opacity: 1;
+  }
+}
+
+.tl-toggle-input {
+  @apply inline-flex items-center justify-center;
+  height: 32px;
+  width: 32px;
+  color: var(--ls-secondary-text-color);
+  opacity: 0.3;
+  &:hover {
+    background-color: var(--ls-tertiary-background-color);
+  }
+  &[data-state='on'] {
+    background-color: var(--ls-tertiary-background-color);
+    color: var(--ls-primary-text-color);
+    opacity: 1;
+  }
+}
+
+.tl-contextbar-button {
+  @apply rounded inline-flex items-center justify-center;
+  height: 32px;
+  width: 32px;
+
+  &:hover {
+    background-color: var(--ls-tertiary-background-color);
+  }
+}
+
+.tl-contextbar-separator {
+  background-color: var(--ls-border-color);
+  width: 1px;
+}
+
+.tl-youtube-link {
+  border-radius: 8px;
+  color: var(--ls-primary-text-color);
+  box-shadow: 0 0 0 1px var(--ls-secondary-border-color);
+  padding: 4px 14px;
+}
+
+.tl-hitarea-stroke {
+  fill: none;
+  stroke: transparent;
+  pointer-events: stroke;
+  stroke-width: min(100px, calc(24px * var(--tl-scale)));
+}
+
+.tl-hitarea-fill {
+  fill: transparent;
+  stroke: transparent;
+  pointer-events: all;
+  stroke-width: min(100px, calc(24px * var(--tl-scale)));
+}

+ 1 - 1
tldraw/apps/tldraw-logseq/tsconfig.json

@@ -1,6 +1,6 @@
 {
   "extends": "../../tsconfig.base.json",
-  "exclude": ["node_modules", "dist", "docs"],
+  "exclude": ["node_modules", "dist", "docs", "tsup.config.ts"],
   "compilerOptions": {
     "outDir": "./dist/types",
     "rootDir": "src",

+ 3 - 3
tldraw/demo/package.json

@@ -1,19 +1,19 @@
 {
-  "name": "demo",
+  "name": "@tldraw/demo",
   "private": true,
   "version": "0.0.0-dev",
   "devDependencies": {
     "autoprefixer": "^10.4.7",
     "postcss": "^8.4.13",
     "tailwindcss": "^3.0.24",
-    "vite": "^2.9.8",
+    "vite": "^3.0.0",
     "@babel/plugin-proposal-decorators": "^7.18.2"
   },
   "scripts": {
     "dev": "vite"
   },
   "dependencies": {
-    "@vitejs/plugin-react": "^1.3.2",
+    "@vitejs/plugin-react": "^2.0.0",
     "react": "^17",
     "react-dom": "^17"
   }

+ 2 - 6
tldraw/demo/src/App.jsx

@@ -1,7 +1,7 @@
 import { uniqueId, fileToBase64 } from '@tldraw/core'
 import React from 'react'
 import ReactDOM from 'react-dom'
-import { App as TldrawApp } from 'tldraw-logseq'
+import { App as TldrawApp } from '@tldraw/logseq'
 
 const storingKey = 'playground.index'
 
@@ -136,10 +136,6 @@ const searchHandler = q => {
   })
 }
 
-const saveAssets = async files => {
-  return Promise.all(files.map(fileToBase64))
-}
-
 export default function App() {
   const [theme, setTheme] = React.useState('light')
 
@@ -158,7 +154,7 @@ export default function App() {
           addNewBlock: () => uniqueId(),
           queryBlockByUUID: uuid => ({ uuid, content: 'some random content' }),
           isWhiteboardPage: () => false,
-          saveAssets,
+          saveAsset: fileToBase64,
           makeAssetUrl: a => a,
         }}
         model={documentModel}

+ 0 - 215
tldraw/demo/yarn.lock

@@ -1,215 +0,0 @@
-# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-# yarn lockfile v1
-
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz#5b94a1306df31d55055f64a62ff6b763a47b7f64"
-  integrity sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz#78acc80773d16007de5219ccce544c036abd50b8"
-  integrity sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz#e02b1291f629ebdc2aa46fabfacc9aa28ff6aa46"
-  integrity sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz#01eb6650ec010b18c990e443a6abcca1d71290a9"
-  integrity sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz#790b8786729d4aac7be17648f9ea8e0e16475b5e"
-  integrity sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz#b66340ab28c09c1098e6d9d8ff656db47d7211e6"
-  integrity sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz#7927f950986fd39f0ff319e92839455912b67f70"
-  integrity sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz#4893d07b229d9cfe34a2b3ce586399e73c3ac519"
-  integrity sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz#8442402e37d0b8ae946ac616784d9c1a2041056a"
-  integrity sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz#d5dbf32d38b7f79be0ec6b5fb2f9251fd9066986"
-  integrity sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz#95081e42f698bbe35d8ccee0e3a237594b337eb5"
-  integrity sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz#dceb0a1b186f5df679618882a7990bd422089b47"
-  integrity sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz#61fb8edb75f475f9208c4a93ab2bfab63821afd2"
-  integrity sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz#34c7126a4937406bf6a5e69100185fd702d12fe0"
-  integrity sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz#322ea9937d9e529183ee281c7996b93eb38a5d95"
-  integrity sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz#1ca29bb7a2bf09592dcc26afdb45108f08a2cdbd"
-  integrity sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz#c9446f7d8ebf45093e7bb0e7045506a88540019b"
-  integrity sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz#f8e9b4602fd0ccbd48e5c8d117ec0ba4040f2ad1"
-  integrity sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz#280f58e69f78535f470905ce3e43db1746518107"
-  integrity sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==
-
[email protected]:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz#d97e9ac0f95a4c236d9173fa9f86c983d6a53f54"
-  integrity sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==
-
-esbuild@^0.14.27:
-  version "0.14.38"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.38.tgz#99526b778cd9f35532955e26e1709a16cca2fb30"
-  integrity sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==
-  optionalDependencies:
-    esbuild-android-64 "0.14.38"
-    esbuild-android-arm64 "0.14.38"
-    esbuild-darwin-64 "0.14.38"
-    esbuild-darwin-arm64 "0.14.38"
-    esbuild-freebsd-64 "0.14.38"
-    esbuild-freebsd-arm64 "0.14.38"
-    esbuild-linux-32 "0.14.38"
-    esbuild-linux-64 "0.14.38"
-    esbuild-linux-arm "0.14.38"
-    esbuild-linux-arm64 "0.14.38"
-    esbuild-linux-mips64le "0.14.38"
-    esbuild-linux-ppc64le "0.14.38"
-    esbuild-linux-riscv64 "0.14.38"
-    esbuild-linux-s390x "0.14.38"
-    esbuild-netbsd-64 "0.14.38"
-    esbuild-openbsd-64 "0.14.38"
-    esbuild-sunos-64 "0.14.38"
-    esbuild-windows-32 "0.14.38"
-    esbuild-windows-64 "0.14.38"
-    esbuild-windows-arm64 "0.14.38"
-
-fsevents@~2.3.2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
-  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
-
-function-bind@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
-  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
-
-has@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
-  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
-  dependencies:
-    function-bind "^1.1.1"
-
-is-core-module@^2.8.1:
-  version "2.9.0"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
-  integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
-  dependencies:
-    has "^1.0.3"
-
-nanoid@^3.3.3:
-  version "3.3.4"
-  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
-  integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
-
-path-parse@^1.0.7:
-  version "1.0.7"
-  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
-  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
-
-picocolors@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
-  integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
-
-postcss@^8.4.13:
-  version "8.4.13"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.13.tgz#7c87bc268e79f7f86524235821dfdf9f73e5d575"
-  integrity sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==
-  dependencies:
-    nanoid "^3.3.3"
-    picocolors "^1.0.0"
-    source-map-js "^1.0.2"
-
-resolve@^1.22.0:
-  version "1.22.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
-  integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
-  dependencies:
-    is-core-module "^2.8.1"
-    path-parse "^1.0.7"
-    supports-preserve-symlinks-flag "^1.0.0"
-
-rollup@^2.59.0:
-  version "2.72.1"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.72.1.tgz#861c94790537b10008f0ca0fbc60e631aabdd045"
-  integrity sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==
-  optionalDependencies:
-    fsevents "~2.3.2"
-
-source-map-js@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
-  integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
-
-supports-preserve-symlinks-flag@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
-  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
-
-vite@^2.9.8:
-  version "2.9.8"
-  resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.8.tgz#2c2cb0790beb0fbe4b8c0995b80fe691a91c2545"
-  integrity sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==
-  dependencies:
-    esbuild "^0.14.27"
-    postcss "^8.4.13"
-    resolve "^1.22.0"
-    rollup "^2.59.0"
-  optionalDependencies:
-    fsevents "~2.3.2"

+ 6 - 4
tldraw/packages/core/src/lib/TLApi/TLApi.ts

@@ -20,6 +20,11 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
     return this
   }
 
+  editShape = (shape: string | S | undefined): this => {
+    this.app.transition('select').selectedTool.transition('editingShape', { shape })
+    return this
+  }
+
   /**
    * Set the hovered shape.
    *
@@ -159,10 +164,7 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
     const viewport = this.app.viewport
     viewport.update({
       zoom: 1,
-      point: Vec.sub(
-        Vec.sub(this.app.inputs.originScreenPoint, Vec.mul(this.app.inputs.containerOffset, 2)),
-        this.app.inputs.originPoint
-      ),
+      point: Vec.sub(this.app.inputs.originScreenPoint, this.app.inputs.originPoint),
     })
     return this
   }

+ 5 - 1
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -177,7 +177,7 @@ export class TLApp<
         },
       },
       {
-        keys: ['delete', 'backspace'],
+        keys: ['del', 'backspace'],
         fn: () => {
           const { selectedTool } = this
           if (
@@ -422,6 +422,10 @@ export class TLApp<
       const tldrawString = JSON.stringify({
         type: 'logseq/whiteboard-shapes',
         shapes: this.selectedShapesArray.map(shape => shape.serialized),
+        // pasting into other whiteboard may require this if any shape uses asset
+        assets: this.getCleanUpAssets().filter(asset => {
+          return this.selectedShapesArray.some(shape => shape.props.assetId === asset.id)
+        }),
       })
       navigator.clipboard.write([
         new ClipboardItem({

+ 1 - 0
tldraw/packages/core/src/lib/TLHistory.ts

@@ -112,6 +112,7 @@ export class TLHistory<S extends TLShape = TLShape, K extends TLEventMap = TLEve
               if (shape.nonce !== serializedShape.nonce) {
                 shape.update(serializedShape, true)
                 shape.nonce = serializedShape.nonce!
+                shape.setLastSerialized(serializedShape)
               }
               shapesMap.delete(serializedShape.id)
             } else {

+ 1 - 1
tldraw/packages/core/src/lib/TLInputs.ts

@@ -38,7 +38,7 @@ export class TLInputs<K extends TLEventMap> {
   ) {
     if ('clientX' in event) {
       this.previousScreenPoint = this.currentScreenPoint
-      this.currentScreenPoint = Vec.add([event.clientX, event.clientY], this.containerOffset)
+      this.currentScreenPoint = Vec.sub([event.clientX, event.clientY], this.containerOffset)
     }
     if ('shiftKey' in event) {
       this.shiftKey = event.shiftKey

+ 2 - 21
tldraw/packages/core/src/lib/TLViewport.ts

@@ -10,7 +10,6 @@ export class TLViewport {
 
   static readonly minZoom = 0.1
   static readonly maxZoom = 4
-  static readonly zooms = [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4]
 
   /* ------------------- Properties ------------------- */
 
@@ -84,17 +83,8 @@ export class TLViewport {
   }
 
   zoomIn = (): this => {
-    const zooms = TLViewport.zooms
     const { camera, bounds } = this
-    let zoom: number | undefined
-    for (let i = 1; i < zooms.length; i++) {
-      const z1 = zooms[i - 1]
-      const z2 = zooms[i]
-      if (z2 - camera.zoom <= (z2 - z1) / 2) continue
-      zoom = z2
-      break
-    }
-    if (zoom === undefined) zoom = zooms[zooms.length - 1]
+    const zoom: number = Math.min(TLViewport.maxZoom, Math.ceil((camera.zoom * 100 + 1) / 25) / 4)
     const center = [bounds.width / 2, bounds.height / 2]
     const p0 = Vec.sub(Vec.div(center, camera.zoom), center)
     const p1 = Vec.sub(Vec.div(center, zoom), center)
@@ -102,17 +92,8 @@ export class TLViewport {
   }
 
   zoomOut = (): this => {
-    const zooms = TLViewport.zooms
     const { camera, bounds } = this
-    let zoom: number | undefined
-    for (let i = zooms.length - 1; i > 0; i--) {
-      const z1 = zooms[i - 1]
-      const z2 = zooms[i]
-      if (z2 - camera.zoom >= (z2 - z1) / 2) continue
-      zoom = z1
-      break
-    }
-    if (zoom === undefined) zoom = zooms[0]
+    const zoom: number = Math.max(TLViewport.minZoom, Math.floor((camera.zoom * 100 - 1) / 25) / 4)
     const center = [bounds.width / 2, bounds.height / 2]
     const p0 = Vec.sub(Vec.div(center, camera.zoom), center)
     const p1 = Vec.sub(Vec.div(center, zoom), center)

+ 1 - 1
tldraw/packages/core/src/lib/shapes/TLImageShape/TLImageShape.ts

@@ -38,7 +38,7 @@ export class TLImageShape<
     assetId: '',
   }
 
-  onResetBounds = (info: TLResetBoundsInfo<TLImageAsset>) => {
+  onResetBounds: (info?: TLResetBoundsInfo | undefined) => this = (info: any) => {
     const { clipping, size, point } = this.props
     if (clipping) {
       const [t, r, b, l] = Array.isArray(clipping)

+ 9 - 7
tldraw/packages/core/src/lib/shapes/TLShape/TLShape.tsx

@@ -41,7 +41,6 @@ export interface TLShapeProps {
   isGenerated?: boolean
   isSizeLocked?: boolean
   isAspectRatioLocked?: boolean
-  logseqLink?: string
 }
 
 export interface TLResizeStartInfo {
@@ -58,8 +57,9 @@ export interface TLResizeInfo {
   transformOrigin: number[]
 }
 
-export interface TLResetBoundsInfo<T extends TLAsset> {
-  asset?: T
+export interface TLResetBoundsInfo {
+  zoom: number
+  asset?: TLAsset
 }
 
 export interface TLHandleChangeInfo {
@@ -307,7 +307,7 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
     return new this.constructor(this.serialized)
   }
 
-  onResetBounds = (info: TLResetBoundsInfo<any>) => {
+  onResetBounds = (info?: TLResetBoundsInfo) => {
     return this
   }
 
@@ -355,12 +355,14 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
   getShapeSVGJsx(opts: any) {
     // Do not need to consider the original point here
     const bounds = this.getBounds()
-    const { stroke, strokeWidth, opacity, fill, borderRadius } = this.props as any
+    const { stroke, strokeWidth, strokeType, opacity, fill, noFill, borderRadius } = this
+      .props as any
     return (
       <rect
-        fill={fill}
-        stroke={stroke}
+        fill={noFill ? 'none' : fill}
+        stroke={noFill ? fill : stroke}
         strokeWidth={strokeWidth ?? 2}
+        strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
         fillOpacity={opacity ?? 0.2}
         width={bounds.width}
         height={bounds.height}

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

@@ -77,7 +77,9 @@ export class HoveringSelectionHandleState<
           break
         }
         case TLTargetType.Selection: {
-          selectedShape.onResetBounds?.({})
+          selectedShape.onResetBounds?.({
+            zoom: this.app.viewport.camera.zoom,
+          })
           if (this.app.selectedShapesArray.length === 1) {
             this.tool.transition('editingShape', {
               type: TLTargetType.Shape,
@@ -91,7 +93,7 @@ export class HoveringSelectionHandleState<
       const asset = selectedShape.props.assetId
         ? this.app.assets[selectedShape.props.assetId]
         : undefined
-      selectedShape.onResetBounds({ asset })
+      selectedShape.onResetBounds({ asset, zoom: this.app.viewport.camera.zoom })
       this.tool.transition('idle')
     }
   }

+ 24 - 22
tldraw/packages/core/src/lib/tools/TLSelectTool/states/ResizingState.ts

@@ -184,28 +184,30 @@ export class ResizingState<
         rotation *= -1
       }
       // If the shape is aspect ratio locked or size locked...
-      if (isAspectRatioLocked || !canResizeAny || shape.props.isSizeLocked) {
-        relativeBounds.width = initialShapeBounds.width
-        relativeBounds.height = initialShapeBounds.height
-        if (isAspectRatioLocked) {
-          // Scale the width and height to the longer dimension
-          relativeBounds.width *= resizeDimension
-          relativeBounds.height *= resizeDimension
-        }
-        // Find the center using the inner transform origin
-        center = [
-          nextBounds.minX +
-            (scaleX < 0 ? 1 - innerTransformOrigin[0] : innerTransformOrigin[0]) *
-              (nextBounds.width - relativeBounds.width) +
-            relativeBounds.width / 2,
-          nextBounds.minY +
-            (scaleY < 0 ? 1 - innerTransformOrigin[1] : innerTransformOrigin[1]) *
-              (nextBounds.height - relativeBounds.height) +
-            relativeBounds.height / 2,
-        ]
-        // Position the bounds at the center
-        relativeBounds = BoundsUtils.centerBounds(relativeBounds, center)
-      }
+      // FIXME: the following is buggy
+      // if (isAspectRatioLocked || !canResizeAny || shape.props.isSizeLocked) {
+      //   // console.log('aspect ratio locked', isAspectRatioLocked, canResizeAny, shape.props.isSizeLocked)
+      //   relativeBounds.width = initialShapeBounds.width
+      //   relativeBounds.height = initialShapeBounds.height
+      //   if (isAspectRatioLocked) {
+      //     // Scale the width and height to the longer dimension
+      //     relativeBounds.width *= resizeDimension
+      //     relativeBounds.height *= resizeDimension
+      //   }
+      //   // Find the center using the inner transform origin
+      //   center = [
+      //     nextBounds.minX +
+      //       (scaleX < 0 ? 1 - innerTransformOrigin[0] : innerTransformOrigin[0]) *
+      //         (nextBounds.width - relativeBounds.width) +
+      //       relativeBounds.width / 2,
+      //     nextBounds.minY +
+      //       (scaleY < 0 ? 1 - innerTransformOrigin[1] : innerTransformOrigin[1]) *
+      //         (nextBounds.height - relativeBounds.height) +
+      //       relativeBounds.height / 2,
+      //   ]
+      //   // Position the bounds at the center
+      //   relativeBounds = BoundsUtils.centerBounds(relativeBounds, center)
+      // }
       shape.onResize(initialShapeProps, {
         center,
         rotation,

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

@@ -107,6 +107,9 @@ export class TranslatingState<
     // Blur all inputs when moving shapes
     document.querySelectorAll<HTMLElement>('input,textarea').forEach(el => el.blur())
 
+    // Clear selection
+    document.getSelection()?.empty();
+
     if (inputs.altKey) {
       this.startCloning()
     } else {

+ 1 - 1
tldraw/packages/core/src/types/types.ts

@@ -105,7 +105,7 @@ export interface TLOffset {
 
 export interface TLAsset {
   id: string
-  type: any
+  type: string
   src: string
 }
 

+ 36 - 8
tldraw/packages/core/src/utils/BoundsUtils.ts

@@ -323,15 +323,16 @@ export class BoundsUtils {
     handle: TLResizeCorner | TLResizeEdge | 'center',
     delta: number[],
     rotation = 0,
-    isAspectRatioLocked = false,
-    [canResizeX, canResizeY] = [true, true]
+    isAspectRatioLocked = false
   ): TLBounds & { scaleX: number; scaleY: number } {
     // Create top left and bottom right corners.
     const [ax0, ay0] = [bounds.minX, bounds.minY]
     const [ax1, ay1] = [bounds.maxX, bounds.maxY]
+
     // Create a second set of corners for the new box.
     let [bx0, by0] = [bounds.minX, bounds.minY]
     let [bx1, by1] = [bounds.maxX, bounds.maxY]
+
     // If the drag is on the center, just translate the bounds.
     if (handle === 'center') {
       return {
@@ -345,11 +346,14 @@ export class BoundsUtils {
         scaleY: 1,
       }
     }
+
     // Counter rotate the delta. This lets us make changes as if
     // the (possibly rotated) boxes were axis aligned.
     const [dx, dy] = Vec.rot(delta, -rotation)
+
     /*
 1. Delta
+
 Use the delta to adjust the new box by changing its corners.
 The dragging handle (corner or edge) will determine which 
 corners should change.
@@ -368,6 +372,7 @@ corners should change.
         break
       }
     }
+
     switch (handle) {
       case TLResizeEdge.Left:
       case TLResizeCorner.TopLeft:
@@ -382,24 +387,32 @@ corners should change.
         break
       }
     }
+
     const aw = ax1 - ax0
     const ah = ay1 - ay0
+
     const scaleX = (bx1 - bx0) / aw
     const scaleY = (by1 - by0) / ah
+
     const flipX = scaleX < 0
     const flipY = scaleY < 0
+
     const bw = Math.abs(bx1 - bx0)
     const bh = Math.abs(by1 - by0)
+
     /*
-    2. Aspect ratio
-    If the aspect ratio is locked, adjust the corners so that the
-    new box's aspect ratio matches the original aspect ratio.
-    */
+2. Aspect ratio
+
+If the aspect ratio is locked, adjust the corners so that the
+new box's aspect ratio matches the original aspect ratio.
+*/
+
     if (isAspectRatioLocked) {
       const ar = aw / ah
       const isTall = ar < bw / bh
       const tw = bw * (scaleY < 0 ? 1 : -1) * (1 / ar)
       const th = bh * (scaleX < 0 ? 1 : -1) * ar
+
       switch (handle) {
         case TLResizeCorner.TopLeft: {
           if (isTall) by0 = by1 + tw
@@ -439,17 +452,22 @@ corners should change.
         }
       }
     }
+
     /*
 3. Rotation
+
 If the bounds are rotated, get a Vector from the rotated anchor
 corner in the inital bounds to the rotated anchor corner in the
 result's bounds. Subtract this Vector from the result's corners,
 so that the two anchor points (initial and result) will be equal.
 */
+
     if (rotation % (Math.PI * 2) !== 0) {
       let cv = [0, 0]
+
       const c0 = Vec.med([ax0, ay0], [ax1, ay1])
       const c1 = Vec.med([bx0, by0], [bx1, by1])
+
       switch (handle) {
         case TLResizeCorner.TopLeft: {
           cv = Vec.sub(Vec.rotWith([bx1, by1], c1, rotation), Vec.rotWith([ax1, ay1], c0, rotation))
@@ -496,16 +514,26 @@ so that the two anchor points (initial and result) will be equal.
           break
         }
       }
+
       ;[bx0, by0] = Vec.sub([bx0, by0], cv)
       ;[bx1, by1] = Vec.sub([bx1, by1], cv)
     }
+
     /*
 4. Flips
+
 If the axes are flipped (e.g. if the right edge has been dragged
 left past the initial left edge) then swap points on that axis.
 */
-    if (bx1 < bx0) [bx1, bx0] = [bx0, bx1]
-    if (by1 < by0) [by1, by0] = [by0, by1]
+
+    if (bx1 < bx0) {
+      ;[bx1, bx0] = [bx0, bx1]
+    }
+
+    if (by1 < by0) {
+      ;[by1, by0] = [by0, by1]
+    }
+
     return {
       minX: bx0,
       minY: by0,

+ 24 - 4
tldraw/packages/core/src/utils/DataUtils.ts

@@ -76,11 +76,31 @@ export function fileToBase64(file: Blob): Promise<string | ArrayBuffer | null> {
   })
 }
 
-export function getSizeFromSrc(dataURL: string): Promise<number[]> {
+export function getSizeFromSrc(dataURL: string, isVideo: boolean): Promise<number[]> {
   return new Promise(resolve => {
-    const img = new Image()
-    img.onload = () => resolve([img.width, img.height])
-    img.src = dataURL
+    if (isVideo) {
+      const video = document.createElement('video')
+
+      // place a listener on it
+      video.addEventListener(
+        'loadedmetadata',
+        function () {
+          // retrieve dimensions
+          const height = this.videoHeight
+          const width = this.videoWidth
+
+          // send back result
+          resolve([width, height])
+        },
+        false
+      )
+      // start download meta-datas
+      video.src = dataURL
+    } else {
+      const img = new Image()
+      img.onload = () => resolve([img.width, img.height])
+      img.src = dataURL
+    }
   })
 }
 

+ 10 - 1
tldraw/packages/core/src/utils/index.ts

@@ -44,10 +44,15 @@ export function throttle<T extends (...args: any) => any>(
   }
 }
 
-export function debounce<T extends (...args: any[]) => void>(fn: T, ms = 0) {
+export function debounce<T extends (...args: any[]) => void>(
+  fn: T,
+  ms = 0,
+  immediateFn: T | undefined = undefined
+) {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   let timeoutId: number | any
   return function (...args: Parameters<T>) {
+    immediateFn?.(...args)
     clearTimeout(timeoutId)
     timeoutId = setTimeout(() => fn.apply(args), ms)
   }
@@ -75,3 +80,7 @@ export function modKey(e: any): boolean {
 export function isNonNullable<TValue>(value: TValue): value is NonNullable<TValue> {
   return Boolean(value)
 }
+
+export function delay(ms: number = 0) {
+  return new Promise(resolve => setTimeout(resolve, ms))
+}

+ 4 - 10
tldraw/packages/react/src/components/App.tsx

@@ -1,16 +1,10 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import * as React from 'react'
-import type { TLReactApp, TLReactShape, TLReactShapeConstructor } from '~lib'
+import type { AnyObject, TLDocumentModel, TLTheme, TLToolConstructor } from '@tldraw/core'
+import type * as React from 'react'
 import { AppProvider } from '~components'
-import type {
-  AnyObject,
-  TLDocumentModel,
-  TLCallback,
-  TLTheme,
-  TLToolConstructor,
-} from '@tldraw/core'
-import type { TLReactComponents } from '~types/component-props'
+import type { TLReactApp, TLReactShape, TLReactShapeConstructor } from '~lib'
 import type { TLReactCallbacks, TLReactEventMap } from '~types'
+import type { TLReactComponents } from '~types/component-props'
 import { AppCanvas } from './AppCanvas'
 
 export interface TLCommonAppProps<

+ 1 - 4
tldraw/packages/react/src/components/Canvas/Canvas.tsx

@@ -90,7 +90,6 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
 }: Partial<TLCanvasProps<S>>) {
   const rContainer = React.useRef<HTMLDivElement>(null)
   const { viewport, components, meta } = useRendererContext()
-  const { zoom } = viewport.camera
   const app = useApp()
   const onBoundsChange = React.useCallback((bounds: TLBounds) => {
     app.inputs.updateContainerOffset([bounds.minX, bounds.minY])
@@ -117,7 +116,6 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
           {components.SelectionBackground && selectedShapes && selectionBounds && showSelection && (
             <Container data-type="SelectionBackground" bounds={selectionBounds} zIndex={2}>
               <components.SelectionBackground
-                zoom={zoom}
                 shapes={selectedShapes}
                 bounds={selectionBounds}
                 showResizeHandles={showResizeHandles}
@@ -164,7 +162,6 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
                   zIndex={editingShape && selectedShapes.includes(editingShape) ? 1002 : 10002}
                 >
                   <components.SelectionForeground
-                    zoom={zoom}
                     shapes={selectedShapes}
                     bounds={selectionBounds}
                     showResizeHandles={showResizeHandles}
@@ -179,7 +176,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
                   zIndex={10003}
                 >
                   <SVGContainer>
-                    {Object.entries(onlySelectedShapeWithHandles.props.handles!).map(
+                    {Object.entries(onlySelectedShapeWithHandles.props.handles ?? {}).map(
                       ([id, handle]) =>
                         React.createElement(components.Handle!, {
                           key: `${handle.id}_handle_${handle.id}`,

+ 1 - 0
tldraw/packages/react/src/components/ContextBarContainer/ContextBarContainer.tsx

@@ -75,6 +75,7 @@ export const ContextBarContainer = observer(function ContextBarContainer<S exten
       onPointerDown={stopEventPropagation}
     >
       <ContextBar
+        hidden={hidden}
         shapes={shapes}
         bounds={bounds}
         offsets={offsets}

+ 0 - 1
tldraw/packages/react/src/components/ui/SelectionBackground/SelectionBackground.tsx

@@ -1,4 +1,3 @@
-import * as React from 'react'
 import { observer } from 'mobx-react-lite'
 import { useBoundsEvents } from '~hooks/useBoundsEvents'
 import { SVGContainer } from '~components'

+ 4 - 5
tldraw/packages/react/src/components/ui/SelectionForeground/SelectionForeground.tsx

@@ -1,19 +1,21 @@
 import { TLResizeCorner, TLResizeEdge, TLRotateCorner } from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
 import { SVGContainer } from '~components'
+import { useApp } from '~hooks'
 import type { TLReactShape } from '~lib'
 import type { TLSelectionComponentProps } from '~types'
-import { CornerHandle, EdgeHandle, RotateHandle } from './handles'
+import { CornerHandle, EdgeHandle } from './handles'
 import { RotateCornerHandle } from './handles/RotateCornerHandle'
 
 export const SelectionForeground = observer(function SelectionForeground<S extends TLReactShape>({
   bounds,
-  zoom,
   showResizeHandles,
   showRotateHandles,
   shapes,
 }: TLSelectionComponentProps<S>) {
+  const app = useApp()
   const { width, height } = bounds
+  const zoom = app.viewport.camera.zoom
 
   const size = 8 / zoom
   const targetSize = 6 / zoom
@@ -132,9 +134,6 @@ export const SelectionForeground = observer(function SelectionForeground<S exten
           />
         </>
       )}
-      {/* {showRotateHandles && (
-        <RotateHandle cx={width / 2} cy={0 - targetSize * 2} size={size} targetSize={targetSize} />
-      )} */}
     </SVGContainer>
   )
 })

+ 3 - 3
tldraw/packages/react/src/hooks/useGestureEvents.ts

@@ -41,7 +41,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
           order: 0,
           delta: gesture.delta,
           offset: gesture.offset,
-          point: gesture.origin,
+          point: Vec.sub(gesture.origin, inputs.containerOffset),
         },
         event
       )
@@ -58,7 +58,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
           order: 0,
           delta: gesture.delta,
           offset: gesture.offset,
-          point: gesture.origin,
+          point: Vec.sub(gesture.origin, inputs.containerOffset),
         },
         event
       )
@@ -75,7 +75,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
           order: 0,
           delta: gesture.delta,
           offset: gesture.offset,
-          point: gesture.origin,
+          point: Vec.sub(gesture.origin, inputs.containerOffset),
         },
         event
       )

+ 7 - 2
tldraw/packages/react/src/hooks/useKeyboardEvents.ts

@@ -24,7 +24,11 @@ export function useKeyboardEvents(ref: React.RefObject<HTMLDivElement>) {
     }
 
     const onPaste = (e: ClipboardEvent) => {
-      if (!app.editingShape && ref.current?.contains(document.activeElement)) {
+      if (
+        !app.editingShape &&
+        ref.current?.contains(document.activeElement) &&
+        !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName ?? '')
+      ) {
         e.preventDefault()
         app.paste(e, shiftKeyDownRef.current)
       }
@@ -34,7 +38,8 @@ export function useKeyboardEvents(ref: React.RefObject<HTMLDivElement>) {
       if (
         !app.editingShape &&
         app.selectedShapes.size > 0 &&
-        ref.current?.contains(document.activeElement)
+        ref.current?.contains(document.activeElement) &&
+        !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName ?? '')
       ) {
         e.preventDefault()
         app.copy()

+ 0 - 14
tldraw/packages/react/src/hooks/useStylesheet.ts

@@ -418,20 +418,6 @@ const tlcss = css`
     color: var(--tl-background);
   }
 
-  .tl-hitarea-stroke {
-    fill: none;
-    stroke: transparent;
-    pointer-events: stroke;
-    stroke-width: min(100px, calc(24px * var(--tl-scale)));
-  }
-
-  .tl-hitarea-fill {
-    fill: transparent;
-    stroke: transparent;
-    pointer-events: all;
-    stroke-width: min(100px, calc(24px * var(--tl-scale)));
-  }
-
   .tl-grid {
     position: absolute;
     width: 100%;

+ 1 - 1
tldraw/packages/react/src/types/component-props.ts

@@ -6,7 +6,6 @@ import type { TLReactShape } from '~lib'
 /* ------------------- Components ------------------- */
 
 export type TLSelectionComponentProps<S extends TLReactShape = TLReactShape> = {
-  zoom: number
   shapes: S[]
   bounds: TLBounds
   showResizeHandles?: boolean
@@ -23,6 +22,7 @@ export type TLContextBarProps<S extends TLReactShape = TLReactShape> = {
   scaledBounds: TLBounds
   rotation: number
   offsets: TLOffset
+  hidden: boolean
 }
 
 export type TLContextBarComponent<S extends TLReactShape = TLReactShape> = (

+ 1 - 1
tldraw/tsconfig.base.json

@@ -14,7 +14,7 @@
     "experimentalDecorators": true,
     "useDefineForClassFields": true,
     "incremental": true,
-    "jsx": "preserve",
+    "jsx": "react-jsx",
     "lib": ["dom", "esnext"],
     "module": "esnext",
     "moduleResolution": "node",

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 828 - 802
tldraw/yarn.lock


Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно