Browse Source

Merge pull request #6802 from logseq/feat/whiteboards-align-shapes

Peng Xiao 3 years ago
parent
commit
a7def49368

+ 3 - 3
src/main/frontend/components/whiteboard.cljs

@@ -176,9 +176,9 @@
           has-checked? (not-empty checked-page-names)]
       [:<>
        [:h1.select-none.flex.items-center.whiteboard-dashboard-title.title
-        "All whiteboards"
-        [:span.opacity-50
-         (str " · " total-whiteboards)]
+        [:div "All whiteboards"
+         [:span.opacity-50
+          (str " · " total-whiteboards)]]
         [:div.flex-1]
         (when has-checked?
           [:button.ui__button.m-0.py-1.inline-flex.items-center.bg-red-800

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

@@ -126,7 +126,7 @@
    (create-new-whiteboard-page! nil))
   ([name]
    (let [name (or name (str (d/squuid)))
-         uuid (parse-uuid name)
+         uuid (uuid name)
          tldr (get-default-tldr (str uuid))]
      (transact-tldr! name (get-default-tldr (str uuid)))
      (let [entity (get-whiteboard-entity name)

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

@@ -104,7 +104,7 @@
                                   :fn      editor-handler/keydown-new-line-handler}
 
    :editor/new-whiteboard        {:binding "n w"
-                                  :fn      whiteboard-handler/create-new-whiteboard!}
+                                  :fn      #(whiteboard-handler/create-new-whiteboard!)}
 
    :editor/follow-link           {:binding "mod+o"
                                   :fn      editor-handler/follow-link-under-cursor!}

+ 13 - 10
tldraw/apps/tldraw-logseq/src/components/ActionBar/ActionBar.tsx

@@ -5,7 +5,9 @@ import { observer } from 'mobx-react-lite'
 import * as React from 'react'
 import type { Shape } from '../../lib'
 import { TablerIcon } from '../icons'
+import { Button } from '../Button'
 import { ZoomMenu } from '../ZoomMenu'
+import * as Separator from '@radix-ui/react-separator'
 
 export const ActionBar = observer(function ActionBar(): JSX.Element {
   const app = useApp<Shape>()
@@ -27,22 +29,23 @@ export const ActionBar = observer(function ActionBar(): JSX.Element {
 
   return (
     <div className="tl-action-bar">
-      <div className="tl-history-bar">
-        <button title="Undo" onClick={undo}>
+      <div className="tl-toolbar tl-history-bar">
+        <Button title="Undo" onClick={undo}>
           <TablerIcon name="arrow-back-up" />
-        </button>
-        <button title="Redo" onClick={redo}>
+        </Button>
+        <Button title="Redo" onClick={redo}>
           <TablerIcon name="arrow-forward-up" />
-        </button>
+        </Button>
       </div>
 
-      <div className="tl-zoom-bar">
-        <button title="Zoom in" onClick={zoomIn} id="tl-zoom-in">
+      <div className="tl-toolbar tl-zoom-bar">
+        <Button title="Zoom in" onClick={zoomIn} id="tl-zoom-in">
           <TablerIcon name="plus" />
-        </button>
-        <button title="Zoom out" onClick={zoomOut} id="tl-zoom-out">
+        </Button>
+        <Button title="Zoom out" onClick={zoomOut} id="tl-zoom-out">
           <TablerIcon name="minus" />
-        </button>
+        </Button>
+        <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
         <ZoomMenu />
       </div>
     </div>

+ 2 - 2
tldraw/apps/tldraw-logseq/src/components/ContextBar/ContextBar.tsx

@@ -42,7 +42,7 @@ const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets, hidden })
       {Actions.length > 0 && (
         <div
           ref={rContextBar}
-          className="tl-contextbar"
+          className="tl-toolbar tl-context-bar"
           style={{
             visibility: hidden ? 'hidden' : 'visible',
             pointerEvents: hidden ? 'none' : 'all',
@@ -52,7 +52,7 @@ const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets, hidden })
             <React.Fragment key={idx}>
               <Action />
               {idx < Actions.length - 1 && (
-                <Separator.Root className="tl-contextbar-separator" orientation="vertical" />
+                <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
               )}
             </React.Fragment>
           ))}

+ 18 - 30
tldraw/apps/tldraw-logseq/src/components/ContextBar/contextBarActionFactory.tsx

@@ -26,6 +26,7 @@ import {
   ToggleGroupMultipleInput,
 } from '../inputs/ToggleGroupInput'
 import { ToggleInput } from '../inputs/ToggleInput'
+import { Button } from '../Button'
 
 export const contextBarActionTypes = [
   // Order matters
@@ -84,8 +85,7 @@ const EditAction = observer(() => {
   const shape = filterShapeByAction(app.selectedShapesArray, 'Edit')[0]
 
   return (
-    <button
-      className="tl-contextbar-button"
+    <Button
       type="button"
       title="Edit"
       onClick={() => {
@@ -103,7 +103,7 @@ const EditAction = observer(() => {
       }}
     >
       <TablerIcon name="text" />
-    </button>
+    </Button>
   )
 })
 
@@ -120,7 +120,7 @@ const AutoResizingAction = observer(() => {
     <ToggleInput
       title="Auto Resize"
       toggle={shapes.every(s => s.props.type === 'logseq-portal')}
-      className="tl-contextbar-button"
+      className="tl-button"
       pressed={pressed}
       onPressedChange={v => {
         shapes.forEach(s => {
@@ -227,22 +227,16 @@ const OpenPageAction = observer(() => {
 
   return (
     <span className="flex gap-1">
-      <button
+      <Button
         title="Open Page in Right Sidebar"
-        className="tl-contextbar-button"
         type="button"
         onClick={() => handlers?.sidebarAddBlock(pageId, blockType === 'B' ? 'block' : 'page')}
       >
         <TablerIcon name="layout-sidebar-right" />
-      </button>
-      <button
-        title="Open Page"
-        className="tl-contextbar-button"
-        type="button"
-        onClick={() => handlers?.redirectToPage(pageId)}
-      >
+      </Button>
+      <Button title="Open Page" type="button" onClick={() => handlers?.redirectToPage(pageId)}>
         <TablerIcon name="external-link" />
-      </button>
+      </Button>
     </span>
   )
 })
@@ -262,23 +256,18 @@ const IFrameSourceAction = observer(() => {
 
   return (
     <span className="flex gap-3">
-      <button title="Reload" className="tl-contextbar-button" type="button" onClick={handleReload}>
+      <Button title="Reload" type="button" onClick={handleReload}>
         <TablerIcon name="refresh" />
-      </button>
-      <TextInput
+      </Button>
+      <Button
         title="Website Url"
         className="tl-iframe-src"
         value={`${shape.props.url}`}
         onChange={handleChange}
       />
-      <button
-        title="Open website url"
-        className="tl-contextbar-button"
-        type="button"
-        onClick={() => window.open(shape.props.url)}
-      >
+      <Button title="Open website url" type="button" onClick={() => window.open(shape.props.url)}>
         <TablerIcon name="external-link" />
-      </button>
+      </Button>
     </span>
   )
 })
@@ -299,14 +288,13 @@ const YoutubeLinkAction = observer(() => {
         value={`${shape.props.url}`}
         onChange={handleChange}
       />
-      <button
+      <Button
         title="Open YouTube Link"
-        className="tl-contextbar-button"
         type="button"
         onClick={() => window.logseq?.api?.open_external_link?.(shape.props.url)}
       >
         <TablerIcon name="external-link" />
-      </button>
+      </Button>
     </span>
   )
 })
@@ -327,7 +315,7 @@ const NoFillAction = observer(() => {
   return (
     <ToggleInput
       title="Fill Toggle"
-      className="tl-contextbar-button"
+      className="tl-button"
       pressed={noFill}
       onPressedChange={handleChange}
     >
@@ -454,7 +442,7 @@ const TextStyleAction = observer(() => {
     <span className="flex gap-1">
       <ToggleInput
         title="Bold"
-        className="tl-contextbar-button"
+        className="tl-button"
         pressed={bold}
         onPressedChange={v => {
           shapes.forEach(shape => {
@@ -470,7 +458,7 @@ const TextStyleAction = observer(() => {
       </ToggleInput>
       <ToggleInput
         title="Italic"
-        className="tl-contextbar-button"
+        className="tl-button"
         pressed={italic}
         onPressedChange={v => {
           shapes.forEach(shape => {

+ 81 - 19
tldraw/apps/tldraw-logseq/src/components/ContextMenu/ContextMenu.tsx

@@ -1,9 +1,12 @@
 import { useApp } from '@tldraw/react'
-import { MOD_KEY } from '@tldraw/core'
+import { MOD_KEY, AlignType, DistributeType } from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
+import { TablerIcon } from '../icons'
+import { Button } from '../Button'
 import * as React from 'react'
 
 import * as ReactContextMenu from '@radix-ui/react-context-menu'
+import * as Separator from '@radix-ui/react-separator'
 
 const preventDefault = (e: Event) => e.stopPropagation()
 
@@ -28,7 +31,7 @@ export const ContextMenu = observer(function ContextMenu({
     <ReactContextMenu.Root>
       <ReactContextMenu.Trigger>{children}</ReactContextMenu.Trigger>
       <ReactContextMenu.Content
-        className="tl-context-menu"
+        className="tl-menu tl-context-menu"
         ref={rContent}
         onEscapeKeyDown={() => app.transition('select')}
         collisionBoundary={collisionRef.current}
@@ -36,25 +39,84 @@ export const ContextMenu = observer(function ContextMenu({
         tabIndex={-1}
       >
         <div>
+          {app.selectedShapes?.size > 1 && (
+            <ReactContextMenu.Item>
+              <div className="tl-menu-button-row pb-0">
+                <Button
+                  title="Align left"
+                  onClick={() => runAndTransition(() => app.align(AlignType.Left))}
+                >
+                  <TablerIcon name="layout-align-left" />
+                </Button>
+                <Button
+                  title="Align center horizontally"
+                  onClick={() => runAndTransition(() => app.align(AlignType.CenterHorizontal))}
+                >
+                  <TablerIcon name="layout-align-center" />
+                </Button>
+                <Button
+                  title="Align right"
+                  onClick={() => runAndTransition(() => app.align(AlignType.Right))}
+                >
+                  <TablerIcon name="layout-align-right" />
+                </Button>
+                <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
+                <Button
+                  title="Distribute horizontally"
+                  onClick={() => runAndTransition(() => app.distribute(DistributeType.Horizontal))}
+                >
+                  <TablerIcon name="layout-distribute-vertical" />
+                </Button>
+              </div>
+              <div className="tl-menu-button-row pt-0">
+                <Button
+                  title="Align top"
+                  onClick={() => runAndTransition(() => app.align(AlignType.Top))}
+                >
+                  <TablerIcon name="layout-align-top" />
+                </Button>
+                <Button
+                  title="Align center vertically"
+                  onClick={() => runAndTransition(() => app.align(AlignType.CenterVertical))}
+                >
+                  <TablerIcon name="layout-align-middle" />
+                </Button>
+                <Button
+                  title="Align bottom"
+                  onClick={() => runAndTransition(() => app.align(AlignType.Bottom))}
+                >
+                  <TablerIcon name="layout-align-bottom" />
+                </Button>
+                <Separator.Root className="tl-toolbar-separator" orientation="vertical" />
+                <Button
+                  title="Distribute vertically"
+                  onClick={() => runAndTransition(() => app.distribute(DistributeType.Vertical))}
+                >
+                  <TablerIcon name="layout-distribute-horizontal" />
+                </Button>
+              </div>
+              <ReactContextMenu.Separator className="menu-separator" />
+            </ReactContextMenu.Item>
+          )}
           {app.selectedShapes?.size > 0 && (
             <>
               <ReactContextMenu.Item
-                className="tl-context-menu-button"
+                className="tl-menu-item"
                 onClick={() => runAndTransition(app.cut)}
               >
                 Cut
-                <div className="tl-context-menu-right-slot">
+                <div className="tl-menu-right-slot">
                   <span className="keyboard-shortcut">
                     <code>{MOD_KEY}</code> <code>X</code>
                   </span>
                 </div>
               </ReactContextMenu.Item>
               <ReactContextMenu.Item
-                className="tl-context-menu-button"
+                className="tl-menu-item"
                 onClick={() => runAndTransition(app.copy)}
               >
                 Copy
-                <div className="tl-context-menu-right-slot">
+                <div className="tl-menu-right-slot">
                   <span className="keyboard-shortcut">
                     <code>{MOD_KEY}</code> <code>C</code>
                   </span>
@@ -63,11 +125,11 @@ export const ContextMenu = observer(function ContextMenu({
             </>
           )}
           <ReactContextMenu.Item
-            className="tl-context-menu-button"
+            className="tl-menu-item"
             onClick={() => runAndTransition(app.paste)}
           >
             Paste
-            <div className="tl-context-menu-right-slot">
+            <div className="tl-menu-right-slot">
               <span className="keyboard-shortcut">
                 <code>{MOD_KEY}</code> <code>V</code>
               </span>
@@ -75,11 +137,11 @@ export const ContextMenu = observer(function ContextMenu({
           </ReactContextMenu.Item>
           <ReactContextMenu.Separator className="menu-separator" />
           <ReactContextMenu.Item
-            className="tl-context-menu-button"
+            className="tl-menu-item"
             onClick={() => runAndTransition(app.api.selectAll)}
           >
             Select all
-            <div className="tl-context-menu-right-slot">
+            <div className="tl-menu-right-slot">
               <span className="keyboard-shortcut">
                 <code>{MOD_KEY}</code> <code>A</code>
               </span>
@@ -87,7 +149,7 @@ export const ContextMenu = observer(function ContextMenu({
           </ReactContextMenu.Item>
           {app.selectedShapes?.size > 1 && (
             <ReactContextMenu.Item
-              className="tl-context-menu-button"
+              className="tl-menu-item"
               onClick={() => runAndTransition(app.api.deselectAll)}
             >
               Deselect all
@@ -96,11 +158,11 @@ export const ContextMenu = observer(function ContextMenu({
           {app.selectedShapes?.size > 0 && (
             <>
               <ReactContextMenu.Item
-                className="tl-context-menu-button"
+                className="tl-menu-item"
                 onClick={() => runAndTransition(app.api.deleteShapes)}
               >
                 Delete
-                <div className="tl-context-menu-right-slot">
+                <div className="tl-menu-right-slot">
                   <span className="keyboard-shortcut">
                     <code>Del</code>
                   </span>
@@ -110,13 +172,13 @@ export const ContextMenu = observer(function ContextMenu({
                 <>
                   <ReactContextMenu.Separator className="menu-separator" />
                   <ReactContextMenu.Item
-                    className="tl-context-menu-button"
+                    className="tl-menu-item"
                     onClick={() => runAndTransition(app.flipHorizontal)}
                   >
                     Flip horizontally
                   </ReactContextMenu.Item>
                   <ReactContextMenu.Item
-                    className="tl-context-menu-button"
+                    className="tl-menu-item"
                     onClick={() => runAndTransition(app.flipVertical)}
                   >
                     Flip vertically
@@ -125,22 +187,22 @@ export const ContextMenu = observer(function ContextMenu({
               )}
               <ReactContextMenu.Separator className="menu-separator" />
               <ReactContextMenu.Item
-                className="tl-context-menu-button"
+                className="tl-menu-item"
                 onClick={() => runAndTransition(app.bringToFront)}
               >
                 Move to front
-                <div className="tl-context-menu-right-slot">
+                <div className="tl-menu-right-slot">
                   <span className="keyboard-shortcut">
                     <code>⇧</code> <code>]</code>
                   </span>
                 </div>
               </ReactContextMenu.Item>
               <ReactContextMenu.Item
-                className="tl-context-menu-button"
+                className="tl-menu-item"
                 onClick={() => runAndTransition(app.sendToBack)}
               >
                 Move to back
-                <div className="tl-context-menu-right-slot">
+                <div className="tl-menu-right-slot">
                   <span className="keyboard-shortcut">
                     <code>⇧</code> <code>[</code>
                   </span>

+ 4 - 1
tldraw/apps/tldraw-logseq/src/components/PrimaryTools/PrimaryTools.tsx

@@ -95,7 +95,10 @@ export const PrimaryTools = observer(function PrimaryTools() {
 
   return (
     <div className="tl-primary-tools">
-      <div className="tl-tools-floating-panel" data-tool-locked={app.settings.isToolLocked}>
+      <div
+        className="tl-toolbar tl-tools-floating-panel"
+        data-tool-locked={app.settings.isToolLocked}
+      >
         <ToolButton title="Select" id="select" icon="select-cursor" />
         <ToolButton
           title="Move"

+ 0 - 67
tldraw/apps/tldraw-logseq/src/components/Toolbar/ToolBar.tsx

@@ -1,67 +0,0 @@
-/* eslint-disable @typescript-eslint/no-non-null-assertion */
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import * as React from 'react'
-import { observer } from 'mobx-react-lite'
-import { useApp } from '@tldraw/react'
-import type { Shape } from '../../lib'
-
-export const ToolBar = observer(function ToolBar(): JSX.Element {
-  const app = useApp<Shape>()
-
-  const zoomIn = React.useCallback(() => {
-    app.api.zoomIn()
-  }, [app])
-
-  const zoomOut = React.useCallback(() => {
-    app.api.zoomOut()
-  }, [app])
-
-  const resetZoom = React.useCallback(() => {
-    app.api.resetZoom()
-  }, [app])
-
-  const zoomToFit = React.useCallback(() => {
-    app.api.zoomToFit()
-  }, [app])
-
-  const zoomToSelection = React.useCallback(() => {
-    app.api.zoomToSelection()
-  }, [app])
-
-  const sendToBack = React.useCallback(() => {
-    app.sendToBack()
-  }, [app])
-
-  const sendBackward = React.useCallback(() => {
-    app.sendBackward()
-  }, [app])
-
-  const bringToFront = React.useCallback(() => {
-    app.bringToFront()
-  }, [app])
-
-  const bringForward = React.useCallback(() => {
-    app.bringForward()
-  }, [app])
-
-  const flipHorizontal = React.useCallback(() => {
-    app.flipHorizontal()
-  }, [app])
-
-  const flipVertical = React.useCallback(() => {
-    app.flipVertical()
-  }, [app])
-
-  return (
-    <div className="tl-toolbar">
-      <button onClick={sendToBack}>Send to Back</button>
-      <button onClick={sendBackward}>Send Backward</button>
-      <button onClick={bringForward}>Bring Forward</button>
-      <button onClick={bringToFront}>Bring To Front</button>|<button onClick={zoomOut}>-</button>
-      <button onClick={zoomIn}>+</button>
-      <button onClick={resetZoom}>reset</button>
-      <button onClick={zoomToFit}>zoom to fit</button>
-      <button onClick={zoomToSelection}>zoom to selection</button>
-    </div>
-  )
-})

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

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

+ 12 - 16
tldraw/apps/tldraw-logseq/src/components/ZoomMenu/ZoomMenu.tsx

@@ -11,69 +11,65 @@ export const ZoomMenu = observer(function ZoomMenu(): JSX.Element {
 
   return (
     <DropdownMenuPrimitive.Root>
-      <DropdownMenuPrimitive.Trigger id="tl-zoom">
+      <DropdownMenuPrimitive.Trigger className="tl-button text-sm w-auto px-2" id="tl-zoom">
         {(app.viewport.camera.zoom * 100).toFixed(0) + '%'}
       </DropdownMenuPrimitive.Trigger>
-      <DropdownMenuPrimitive.Content
-        className="tl-zoom-menu-dropdown-menu-button"
-        id="zoomPopup"
-        sideOffset={12}
-      >
+      <DropdownMenuPrimitive.Content className="tl-menu" id="zoomPopup" sideOffset={12}>
         <DropdownMenuPrimitive.Item
-          className="menu-link tl-zoom-menu-dropdown-item"
+          className="tl-menu-item"
           onSelect={preventEvent}
           onClick={app.api.zoomToFit}
         >
           Zoom to fit
-          <div className="tl-zoom-menu-right-slot">
+          <div className="tl-menu-right-slot">
             <span className="keyboard-shortcut">
               <code>1</code>
             </span>
           </div>
         </DropdownMenuPrimitive.Item>
         <DropdownMenuPrimitive.Item
-          className="menu-link tl-zoom-menu-dropdown-item"
+          className="tl-menu-item"
           onSelect={preventEvent}
           onClick={app.api.zoomToSelection}
         >
           Zoom to selection
-          <div className="tl-zoom-menu-right-slot">
+          <div className="tl-menu-right-slot">
             <span className="keyboard-shortcut">
               <code>⇧</code> <code>1</code>
             </span>
           </div>
         </DropdownMenuPrimitive.Item>
         <DropdownMenuPrimitive.Item
-          className="menu-link tl-zoom-menu-dropdown-item"
+          className="tl-menu-item"
           onSelect={preventEvent}
           onClick={app.api.zoomIn}
         >
           Zoom in
-          <div className="tl-zoom-menu-right-slot">
+          <div className="tl-menu-right-slot">
             <span className="keyboard-shortcut">
               <code>{MOD_KEY}</code> <code>+</code>
             </span>
           </div>
         </DropdownMenuPrimitive.Item>
         <DropdownMenuPrimitive.Item
-          className="menu-link tl-zoom-menu-dropdown-item"
+          className="tl-menu-item"
           onSelect={preventEvent}
           onClick={app.api.zoomOut}
         >
           Zoom out
-          <div className="tl-zoom-menu-right-slot">
+          <div className="tl-menu-right-slot">
             <span className="keyboard-shortcut">
               <code>{MOD_KEY}</code> <code>-</code>
             </span>
           </div>
         </DropdownMenuPrimitive.Item>
         <DropdownMenuPrimitive.Item
-          className="menu-link tl-zoom-menu-dropdown-item"
+          className="tl-menu-item"
           onSelect={preventEvent}
           onClick={app.api.resetZoom}
         >
           Reset zoom
-          <div className="tl-zoom-menu-right-slot">
+          <div className="tl-menu-right-slot">
             <span className="keyboard-shortcut">
               <code>⇧</code> <code>0</code>
             </span>

+ 45 - 117
tldraw/apps/tldraw-logseq/src/styles.css

@@ -22,21 +22,7 @@
   }
 }
 
-.tl-toolbar {
-  @apply absolute top-0 w-full flex;
-
-  grid-row: 1;
-  align-items: center;
-  padding: 8px;
-  color: black;
-  z-index: 100000;
-  user-select: none;
-  background: white;
-  border-bottom: 1px solid var(--ls-secondary-border-color);
-  font-size: inherit;
-}
-
-.tl-context-menu-button {
+.tl-menu-item {
   @apply flex items-center px-4 py-1 text-sm !important;
 
   min-width: 220px;
@@ -50,11 +36,16 @@
 
   &:hover,
   &:focus {
-    background-color: var(--ls-secondary-background-color) !important;
+    cursor: pointer;
+    background-color: var(--ls-primary-background-color) !important;
   }
 }
 
-.tl-context-menu {
+.tl-menu-button-row {
+  @apply flex justify-between px-4 py-1;
+}
+
+.tl-menu {
   @apply relative py-2 flex bottom-0 flex border-0 rounded shadow-lg;
 
   opacity: 100%;
@@ -63,26 +54,18 @@
   z-index: 180;
   min-width: 220px;
   pointer-events: 'all';
-  background: var(--ls-primary-background-color);
+  background: var(--ls-secondary-background-color);
 }
 
-.tl-context-menu-right-slot {
+.tl-menu-right-slot {
   margin-left: auto;
   padding-left: 20px;
 }
 
-.tl-context-menu-right-slot:focus {
+.tl-menu-right-slot:focus {
   color: whites;
 }
 
-.tl-zoom-bar,
-.tl-history-bar {
-  @apply flex items-center border-0 rounded gap-1 p-1;
-
-  background: var(--ls-secondary-background-color);
-  box-shadow: var(--shadow-medium);
-}
-
 .tl-zoom-bar {
   @apply ml-4;
 }
@@ -92,63 +75,27 @@
 
   z-index: 100000;
   user-select: none;
-
-  button {
-    @apply text-sm rounded border-0 inline-flex items-center justify-center p-2;
-
-    height: 30px;
-    background: transparent;
-    color: var(--ls-primary-text-color);
-    font-family: var(--ls-font-family);
-    cursor: pointer;
-    opacity: 1;
-    white-space: nowrap;
-
-    &:hover {
-      background-color: var(--ls-primary-background-color);
-    }
-  }
-}
-
-.tl-zoom-menu-dropdown-menu-button {
-  @apply py-2 rounded shadow-lg;
-  opacity: 100%;
-  background-color: var(--ls-primary-background-color);
-}
-
-.tl-zoom-menu-dropdown-item {
-  @apply flex items-center px-4 py-1 text-sm !important;
-  min-width: 220px;
-  all: unset;
-  height: 25px;
-  user-select: none;
-  color: var(--color-text);
-
-  &:hover {
-    cursor: pointer;
-    background-color: var(--color-hover);
-  }
-}
-
-.tl-zoom-menu-right-slot {
-  margin-left: auto;
-  padding-left: 20px;
 }
 
-.tl-contextbar {
-  @apply relative flex text-sm;
+.tl-toolbar {
+  @apply relative flex text-sm p-1 rounded-lg;
 
   pointer-events: all;
   position: relative;
   background-color: var(--ls-secondary-background-color);
   color: #a4b5b6;
-  padding: 8px 12px;
   border-radius: 8px;
   white-space: nowrap;
-  gap: 12px;
+  gap: 8px;
+
   align-items: stretch;
   box-shadow: var(--shadow-medium);
   z-index: 1000;
+}
+
+.tl-context-bar {
+  gap: 12px;
+  padding: 8px 12px;
 
   label {
     font-family: var(--ls-font-family);
@@ -236,6 +183,26 @@
   height: 32px;
 }
 
+.tl-button {
+  @apply relative flex items-center justify-center text-base rounded border-0;
+
+  height: 32px;
+  width: 32px;
+  font-family: var(--ls-font-family);
+  background: none;
+  cursor: pointer;
+  color: var(--ls-primary-text-color);
+
+  &:hover {
+    background-color: var(--ls-primary-background-color);
+  }
+
+  &[data-selected='true'] {
+    background-color: var(--color-selectedFill);
+    color: var(--color-selectedContrast);
+  }
+}
+
 .tl-primary-tools {
   @apply absolute h-full top-0 flex items-center justify-center;
 
@@ -246,38 +213,10 @@
   flex-flow: column;
   border-radius: 8px;
   padding: 8px;
-
-  .tl-button {
-    @apply relative flex items-center justify-center text-base rounded border-0;
-
-    height: 28px;
-    width: 28px;
-    font-family: var(--ls-font-family);
-    background: none;
-    cursor: pointer;
-    color: var(--ls-primary-text-color);
-
-    &:hover {
-      background-color: var(--color-hover);
-    }
-
-    &[data-selected='true'] {
-      background-color: var(--color-selectedFill);
-      color: var(--color-selectedContrast);
-    }
-  }
 }
 
 .tl-tools-floating-panel {
-  @apply flex;
-
   flex-flow: column;
-  border-radius: 8px;
-  padding: 4px;
-  gap: 8px;
-  background-color: var(--color-panel);
-  box-shadow: var(--shadow-medium);
-  pointer-events: all;
 }
 
 button.tl-select-input-trigger {
@@ -498,7 +437,7 @@ button.tl-select-input-trigger {
 }
 
 .tl-circle-button {
-  @apply absolute flex items-center	justify-center transition-all;
+  @apply absolute flex items-center	justify-center transition-all rounded-full shadow;
 
   color: var(--ls-primary-text-color);
   background-color: var(--ls-secondary-background-color);
@@ -506,13 +445,12 @@ button.tl-select-input-trigger {
   right: calc(100% + 12px);
   height: 34px;
   width: 34px;
-  border-radius: 50%;
   border: 2px solid var(--ls-secondary-background-color);
   top: 2px;
   transition-delay: 0;
 
   .tie {
-    transform: translateY(-100%); 
+    transform: translateY(-100%);
   }
 
   &[data-active='false']:hover {
@@ -557,7 +495,7 @@ button.tl-select-input-trigger {
     transition-delay: 0;
   }
 
-  &[data-recently-changed=true] {
+  &[data-recently-changed='true'] {
     i.tie {
       transition-delay: 0.5s;
     }
@@ -859,20 +797,10 @@ html[data-theme='dark'] {
   }
 }
 
-.tl-contextbar-button {
-  @apply rounded inline-flex items-center justify-center;
-  height: 32px;
-  width: 32px;
-  color: var(--ls-primary-text-color);
-
-  &:hover {
-    background-color: var(--ls-tertiary-background-color);
-  }
-}
-
-.tl-contextbar-separator {
+.tl-toolbar-separator {
   background-color: var(--ls-border-color);
   width: 1px;
+  opacity: 0.5;
 }
 
 .tl-youtube-link,

+ 58 - 0
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -17,6 +17,7 @@ import type {
   TLEvents,
   TLHandle,
 } from '../../types'
+import { AlignType, DistributeType } from '../../types'
 import { KeyUtils, BoundsUtils, isNonNullable, createNewLineBinding } from '../../utils'
 import type { TLShape, TLShapeConstructor, TLShapeModel } from '../shapes'
 import { TLApi } from '../TLApi'
@@ -377,6 +378,63 @@ export class TLApp<
     return this
   }
 
+  align = (type: AlignType, shapes: S[] = this.selectedShapesArray): this => {
+    if (shapes.length < 2) return this
+
+    const boundsForShapes = shapes.map(shape => {
+      const bounds = shape.getBounds()
+      return {
+        id: shape.id,
+        point: [bounds.minX, bounds.minY],
+        bounds: bounds,
+      }
+    })
+
+    const commonBounds = BoundsUtils.getCommonBounds(boundsForShapes.map(({ bounds }) => bounds))
+
+    const midX = commonBounds.minX + commonBounds.width / 2
+    const midY = commonBounds.minY + commonBounds.height / 2
+
+    const deltaMap = Object.fromEntries(
+      boundsForShapes.map(({ id, point, bounds }) => {
+        return [
+          id,
+          {
+            prev: point,
+            next: {
+              [AlignType.Top]: [point[0], commonBounds.minY],
+              [AlignType.CenterVertical]: [point[0], midY - bounds.height / 2],
+              [AlignType.Bottom]: [point[0], commonBounds.maxY - bounds.height],
+              [AlignType.Left]: [commonBounds.minX, point[1]],
+              [AlignType.CenterHorizontal]: [midX - bounds.width / 2, point[1]],
+              [AlignType.Right]: [commonBounds.maxX - bounds.width, point[1]],
+            }[type],
+          },
+        ]
+      })
+    )
+
+    shapes.forEach(shape => {
+      if (deltaMap[shape.id]) shape.update({ point: deltaMap[shape.id].next })
+    })
+
+    this.persist()
+    return this
+  }
+
+  distribute = (type: DistributeType, shapes: S[] = this.selectedShapesArray): this => {
+    if (shapes.length < 2) return this
+
+    const deltaMap = Object.fromEntries(BoundsUtils.getDistributions(shapes, type).map(d => [d.id, d]))
+
+    shapes.forEach(shape => {
+      if (deltaMap[shape.id]) shape.update({ point: deltaMap[shape.id].next })
+    })
+
+    this.persist()
+    return this
+  }
+
   /* --------------------- Assets --------------------- */
 
   @observable assets: Record<string, TLAsset> = {}

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

@@ -3,6 +3,20 @@ import type { TLShape, TLApp } from '../lib'
 import type { TLEventMap } from './TLEventMap'
 import type { TLHandle } from './TLHandle'
 
+export enum AlignType {
+  Top = 'top',
+  CenterVertical = 'centerVertical',
+  Bottom = 'bottom',
+  Left = 'left',
+  CenterHorizontal = 'centerHorizontal',
+  Right = 'right',
+}
+
+export enum DistributeType {
+  Horizontal = 'horizontal',
+  Vertical = 'vertical',
+}
+
 export enum TLResizeEdge {
   Top = 'top_edge',
   Right = 'right_edge',

+ 98 - 1
tldraw/packages/core/src/utils/BoundsUtils.ts

@@ -1,5 +1,6 @@
 /* eslint-disable @typescript-eslint/no-extra-semi */
 import { Vec } from '@tldraw/vec'
+import type { TLShape } from '../lib'
 import {
   type TLBounds,
   TLResizeCorner,
@@ -7,6 +8,7 @@ import {
   type TLBoundsWithCenter,
   TLSnapPoints,
   type TLSnap,
+  DistributeType,
 } from '../types'
 
 export class BoundsUtils {
@@ -354,7 +356,7 @@ export class BoundsUtils {
 1. Delta
 
 Use the delta to adjust the new box by changing its corners.
-The dragging handle (corner or edge) will determine which 
+The dragging handle (corner or edge) will determine which
 corners should change.
 */
     switch (handle) {
@@ -895,4 +897,99 @@ left past the initial left edge) then swap points on that axis.
     }
     return newBounds
   }
+
+  static getDistributions(shapes: TLShape[], type: DistributeType) {
+    const entries = shapes.map(shape => {
+      const bounds = shape.getBounds()
+      return {
+        id: shape.id,
+        point: [bounds.minX, bounds.minY],
+        bounds: bounds,
+        center: shape.getCenter(),
+      }
+    })
+
+    const len = entries.length
+    const commonBounds = BoundsUtils.getCommonBounds(entries.map(({ bounds }) => bounds))
+
+    const results: { id: string; prev: number[]; next: number[] }[] = []
+
+    switch (type) {
+      case DistributeType.Horizontal: {
+        const span = entries.reduce((a, c) => a + c.bounds.width, 0)
+
+        if (span > commonBounds.width) {
+          const left = entries.sort((a, b) => a.bounds.minX - b.bounds.minX)[0]
+
+          const right = entries.sort((a, b) => b.bounds.maxX - a.bounds.maxX)[0]
+
+          const entriesToMove = entries
+            .filter(a => a !== left && a !== right)
+            .sort((a, b) => a.center[0] - b.center[0])
+
+          const step = (right.center[0] - left.center[0]) / (len - 1)
+
+          const x = left.center[0] + step
+
+          entriesToMove.forEach(({ id, point, bounds }, i) => {
+            results.push({
+              id,
+              prev: point,
+              next: [x + step * i - bounds.width / 2, bounds.minY],
+            })
+          })
+        } else {
+          const entriesToMove = entries.sort((a, b) => a.center[0] - b.center[0])
+
+          let x = commonBounds.minX
+          const step = (commonBounds.width - span) / (len - 1)
+
+          entriesToMove.forEach(({ id, point, bounds }) => {
+            results.push({ id, prev: point, next: [x, bounds.minY] })
+            x += bounds.width + step
+          })
+        }
+        break
+      }
+      case DistributeType.Vertical: {
+        const span = entries.reduce((a, c) => a + c.bounds.height, 0)
+
+        if (span > commonBounds.height) {
+          const top = entries.sort((a, b) => a.bounds.minY - b.bounds.minY)[0]
+
+          const bottom = entries.sort((a, b) => b.bounds.maxY - a.bounds.maxY)[0]
+
+          const entriesToMove = entries
+            .filter(a => a !== top && a !== bottom)
+            .sort((a, b) => a.center[1] - b.center[1])
+
+          const step = (bottom.center[1] - top.center[1]) / (len - 1)
+
+          const y = top.center[1] + step
+
+          entriesToMove.forEach(({ id, point, bounds }, i) => {
+            results.push({
+              id,
+              prev: point,
+              next: [bounds.minX, y + step * i - bounds.height / 2],
+            })
+          })
+        } else {
+          const entriesToMove = entries.sort((a, b) => a.center[1] - b.center[1])
+
+          let y = commonBounds.minY
+          const step = (commonBounds.height - span) / (len - 1)
+
+          entriesToMove.forEach(({ id, point, bounds }) => {
+            results.push({ id, prev: point, next: [bounds.minX, y] })
+            y += bounds.height + step
+          })
+        }
+
+        break
+      }
+    }
+
+    return results
+  }
 }

+ 0 - 1
tldraw/packages/core/src/utils/SvgPathUtils.ts

@@ -1,4 +1,3 @@
-
 export class SvgPathUtils {
   static getCurvedPathForPolygon(points: number[][]) {
     if (points.length < 3) {