فهرست منبع

Feat (Whiteboards): Shape conversion (#9192)

* feat: shape shift

* fix: input border radius

* fix: maintain shape id for references

---------

Co-authored-by: Tienson Qin <[email protected]>
Konstantinos 2 سال پیش
والد
کامیت
fd375dde5d

+ 22 - 3
tldraw/apps/tldraw-logseq/src/components/ContextBar/contextBarActionFactory.tsx

@@ -28,11 +28,13 @@ import {
   type ToggleGroupInputOption,
 } from '../inputs/ToggleGroupInput'
 import { ToggleInput } from '../inputs/ToggleInput'
+import { GeometryTools } from '../GeometryTools'
 import { LogseqContext } from '../../lib/logseq-context'
 
 export const contextBarActionTypes = [
   // Order matters
   'Edit',
+  'Geometry',
   'AutoResizing',
   'Swatch',
   'NoFill',
@@ -72,9 +74,9 @@ export const shapeMapping: Record<ShapeType, ContextBarActionType[]> = {
   youtube: ['YoutubeLink', 'Links'],
   tweet: ['TwitterLink', 'Links'],
   iframe: ['IFrameSource', 'Links'],
-  box: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
-  ellipse: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
-  polygon: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
+  box: ['Edit', 'Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
+  ellipse: ['Edit', 'Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
+  polygon: ['Edit', 'Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
   line: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'ArrowMode', 'Links'],
   pencil: ['Swatch', 'Links', 'ScaleLevel'],
   highlighter: ['Swatch', 'Links', 'ScaleLevel'],
@@ -357,6 +359,22 @@ const SwatchAction = observer(() => {
   )
 })
 
+const GeometryAction = observer(() => {
+  const app = useApp<Shape>()
+
+  const handleSetGeometry = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
+    const type = e.currentTarget.dataset.tool
+    app.api.convertShapes(type)
+  }, [])
+
+  return (
+    <GeometryTools
+      popoverSide="top"
+      chevron={false}
+      setGeometry={handleSetGeometry}/>
+  )
+})
+
 const StrokeTypeAction = observer(() => {
   const app = useApp<Shape>()
   const shapes = filterShapeByAction<
@@ -510,6 +528,7 @@ const LinksAction = observer(() => {
 })
 
 contextBarActionMapping.set('Edit', EditAction)
+contextBarActionMapping.set('Geometry', GeometryAction)
 contextBarActionMapping.set('AutoResizing', AutoResizingAction)
 contextBarActionMapping.set('LogseqPortalViewMode', LogseqPortalViewModeAction)
 contextBarActionMapping.set('ScaleLevel', ScaleLevelAction)

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

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

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

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

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

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

+ 5 - 2
tldraw/apps/tldraw-logseq/src/styles.css

@@ -246,6 +246,10 @@ html[data-theme='light'] {
       outline: none;
     }
   }
+
+  .tl-geometry-tools-pane-anchor > .tl-button {
+    border: 1px solid var(--ls-secondary-border-color);
+  }
 }
 
 .tl-statusbar {
@@ -318,7 +322,7 @@ button.tl-select-input-trigger {
   @apply flex items-center px-3;
   box-shadow: 0 0 0 1px var(--ls-secondary-border-color);
   background-color: var(--ls-secondary-background-color);
-  border-radius: 8px;
+  border-radius: 0.25rem;
   font-size: 16px;
   height: 100%;
   color: var(--ls-secondary-text-color);
@@ -957,7 +961,6 @@ html[data-theme='dark'] {
 
 .tl-geometry-toolbar {
   box-shadow: none;
-  flex-flow: column;
 }
 
 .tl-popover-arrow {

+ 16 - 1
tldraw/packages/core/src/lib/TLApi/TLApi.ts

@@ -43,7 +43,7 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
    *
    * @param shapes The serialized shape changes to apply.
    */
-  updateShapes = <T extends S>(...shapes: ({ id: string } & Partial<T['props']>)[]): this => {
+  updateShapes = <T extends S>(...shapes: ({ id: string, type: string } & Partial<T['props']>)[]): this => {
     this.app.updateShapes(shapes)
     return this
   }
@@ -427,4 +427,19 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
       this.app.setSelectedShapes(shapesInGroups)
     }
   }
+
+  convertShapes = (type: string, shapes: S[] = this.app.allSelectedShapesArray) => {
+    const ShapeClass = this.app.getShapeClass(type)
+
+    this.app.currentPage.removeShapes(...shapes)
+    const clones = shapes.map(s => {
+      return new ShapeClass({
+        ...s.serialized,
+        type: type,
+      })
+    })
+    this.app.currentPage.addShapes(...clones)
+    this.app.persist()
+    this.app.setSelectedShapes(clones)
+  }
 }

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

@@ -347,12 +347,16 @@ export class TLApp<
     return this
   }
 
+  @action updateShapes = <T extends S>(shapes: ({ id: string, type: string } & Partial<T['props']>)[]): this => {
+    if (this.readOnly) return this
 
-
-  @action updateShapes = <T extends S>(shapes: ({ id: string } & Partial<T['props']>)[]): this => {
-    if (this.readOnly ) return this
-
-    shapes.forEach(shape => this.getShapeById(shape.id)?.update(shape))
+    shapes.forEach(shape => {
+      const oldShape = this.getShapeById(shape.id)
+      oldShape?.update(shape)
+      if (shape.type !== oldShape?.type) {
+        this.api.convertShapes(shape.type , [oldShape])
+      }
+    })
     this.persist()
     return this
   }

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

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