[feature] sticky notes (#148)
* Refactor toolbar * Adds containerRef prop * Fix arrows for groups * Adds auto focus * Notes on blurring * Upgrades to match main * Loose ends around sticky notes * Fixes user select * Passing tests * Update sticky.tsx
This commit is contained in:
parent
1408ac2cbe
commit
e1c307fd71
47 changed files with 918 additions and 568 deletions
|
@ -30,6 +30,7 @@ interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
|
|||
hideBounds?: boolean
|
||||
hideHandles?: boolean
|
||||
hideIndicators?: boolean
|
||||
externalContainerRef?: React.RefObject<HTMLElement>
|
||||
meta?: M
|
||||
id?: string
|
||||
}
|
||||
|
@ -41,6 +42,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
|
|||
users,
|
||||
userId,
|
||||
meta,
|
||||
externalContainerRef,
|
||||
hideHandles = false,
|
||||
hideBounds = false,
|
||||
hideIndicators = false,
|
||||
|
@ -53,7 +55,7 @@ export function Canvas<T extends TLShape, M extends Record<string, unknown>>({
|
|||
|
||||
useResizeObserver(rCanvas)
|
||||
|
||||
useZoomEvents(pageState.camera.zoom, rCanvas)
|
||||
useZoomEvents(pageState.camera.zoom, externalContainerRef || rCanvas)
|
||||
|
||||
useSafariFocusOutFix()
|
||||
|
||||
|
|
|
@ -55,16 +55,6 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
|
|||
{shapeTree.map((node) => (
|
||||
<ShapeNode key={node.shape.id} utils={shapeUtils} {...node} />
|
||||
))}
|
||||
{bounds && (
|
||||
<Bounds
|
||||
zoom={zoom}
|
||||
bounds={bounds}
|
||||
viewportWidth={inputs.bounds.width}
|
||||
isLocked={isLocked}
|
||||
rotation={rotation}
|
||||
isHidden={hideBounds}
|
||||
/>
|
||||
)}
|
||||
{!hideIndicators &&
|
||||
selectedIds
|
||||
.filter(Boolean)
|
||||
|
@ -79,6 +69,16 @@ export const Page = React.memo(function Page<T extends TLShape, M extends Record
|
|||
isHovered
|
||||
/>
|
||||
)}
|
||||
{bounds && (
|
||||
<Bounds
|
||||
zoom={zoom}
|
||||
bounds={bounds}
|
||||
viewportWidth={inputs.bounds.width}
|
||||
isLocked={isLocked}
|
||||
rotation={rotation}
|
||||
isHidden={hideBounds}
|
||||
/>
|
||||
)}
|
||||
{!hideHandles && shapeWithHandles && <Handles shape={shapeWithHandles} />}
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -20,6 +20,10 @@ export interface RendererProps<T extends TLShape, E extends Element = any, M = a
|
|||
* (optional) A unique id to be applied to the renderer element, used to scope styles.
|
||||
*/
|
||||
id?: string
|
||||
/**
|
||||
* (optional) A ref for the renderer's container element, used for scoping event handlers.
|
||||
*/
|
||||
containerRef?: React.RefObject<HTMLElement>
|
||||
/**
|
||||
* An object containing instances of your shape classes.
|
||||
*/
|
||||
|
@ -93,6 +97,7 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
|
|||
userId,
|
||||
theme,
|
||||
meta,
|
||||
containerRef,
|
||||
hideHandles = false,
|
||||
hideIndicators = false,
|
||||
hideBounds = false,
|
||||
|
@ -132,6 +137,7 @@ export function Renderer<T extends TLShape, E extends Element, M extends Record<
|
|||
hideBounds={hideBounds}
|
||||
hideIndicators={hideIndicators}
|
||||
hideHandles={hideHandles}
|
||||
externalContainerRef={containerRef}
|
||||
meta={meta}
|
||||
/>
|
||||
</TLContext.Provider>
|
||||
|
|
|
@ -6,7 +6,7 @@ import { useGesture } from '@use-gesture/react'
|
|||
import { Vec } from '@tldraw/vec'
|
||||
|
||||
// Capture zoom gestures (pinches, wheels and pans)
|
||||
export function useZoomEvents<T extends Element>(zoom: number, ref: React.RefObject<T>) {
|
||||
export function useZoomEvents<T extends HTMLElement>(zoom: number, ref: React.RefObject<T>) {
|
||||
const rOriginPoint = React.useRef<number[] | undefined>(undefined)
|
||||
const rPinchPoint = React.useRef<number[] | undefined>(undefined)
|
||||
const rDelta = React.useRef<number[]>([0, 0])
|
||||
|
@ -35,7 +35,9 @@ export function useZoomEvents<T extends Element>(zoom: number, ref: React.RefObj
|
|||
{
|
||||
onWheel: ({ event: e, delta }) => {
|
||||
const elm = ref.current
|
||||
if (!(e.target === elm || elm?.contains(e.target as Node))) return
|
||||
|
||||
if (!elm || !(e.target === elm || elm.contains(e.target as Node))) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (inputs.isPinching) return
|
||||
|
@ -47,7 +49,8 @@ export function useZoomEvents<T extends Element>(zoom: number, ref: React.RefObj
|
|||
},
|
||||
onPinchStart: ({ origin, event }) => {
|
||||
const elm = ref.current
|
||||
if (!(event.target === elm || elm?.contains(event.target as Node))) return
|
||||
|
||||
if (!elm || !(event.target === elm || elm.contains(event.target as Node))) return
|
||||
|
||||
const info = inputs.pinch(origin, origin)
|
||||
inputs.isPinching = true
|
||||
|
@ -93,7 +96,7 @@ export function useZoomEvents<T extends Element>(zoom: number, ref: React.RefObj
|
|||
},
|
||||
},
|
||||
{
|
||||
target: window,
|
||||
target: ref,
|
||||
eventOptions: { passive: false },
|
||||
pinch: {
|
||||
from: zoom,
|
||||
|
|
|
@ -22,6 +22,8 @@ export const ShapeUtil = function <T extends TLShape, E extends Element, M = any
|
|||
|
||||
canBind: false,
|
||||
|
||||
showBounds: true,
|
||||
|
||||
isStateful: false,
|
||||
|
||||
isAspectRatioLocked: false,
|
||||
|
|
|
@ -336,6 +336,14 @@ export type TLShapeUtil<
|
|||
|
||||
isStateful: boolean
|
||||
|
||||
minHeight: number
|
||||
|
||||
minWidth: number
|
||||
|
||||
maxHeight: number
|
||||
|
||||
maxWidth: number
|
||||
|
||||
getRotatedBounds(this: TLShapeUtil<T, E, M>, shape: T): TLBounds
|
||||
|
||||
hitTest(this: TLShapeUtil<T, E, M>, shape: T, point: number[]): boolean
|
||||
|
|
|
@ -79,12 +79,11 @@ export class Utils {
|
|||
*```
|
||||
*/
|
||||
|
||||
static lerpColor(color1: string, color2: string, factor = 0.5): string | undefined {
|
||||
static lerpColor(color1: string, color2: string, factor = 0.5): string {
|
||||
function h2r(hex: string) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result
|
||||
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
|
||||
: null
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!
|
||||
return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
|
||||
}
|
||||
|
||||
function r2h(rgb: number[]) {
|
||||
|
|
|
@ -5,11 +5,11 @@ export default function Editor(props: TLDrawProps): JSX.Element {
|
|||
const rTLDrawState = React.useRef<TLDrawState>()
|
||||
|
||||
const handleMount = React.useCallback((state: TLDrawState) => {
|
||||
rTLDrawState.current = state
|
||||
props.onMount?.(state)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
window.tlstate = state
|
||||
rTLDrawState.current = state
|
||||
props.onMount?.(state)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
|
|
@ -19,14 +19,18 @@ import { PagePanel } from '~components/page-panel'
|
|||
import { Menu } from '~components/menu'
|
||||
import { breakpoints, iconButton } from '~components'
|
||||
import { DotFilledIcon } from '@radix-ui/react-icons'
|
||||
import { TLDR } from '~state/tldr'
|
||||
|
||||
// Selectors
|
||||
const isInSelectSelector = (s: Data) => s.appState.activeTool === 'select'
|
||||
|
||||
const isSelectedShapeWithHandlesSelector = (s: Data) => {
|
||||
const isHideBoundsShapeSelector = (s: Data) => {
|
||||
const { shapes } = s.document.pages[s.appState.currentPageId]
|
||||
const { selectedIds } = s.document.pageStates[s.appState.currentPageId]
|
||||
return selectedIds.length === 1 && selectedIds.every((id) => shapes[id].handles !== undefined)
|
||||
return (
|
||||
selectedIds.length === 1 &&
|
||||
selectedIds.every((id) => !TLDR.getShapeUtils(shapes[id].type).showBounds)
|
||||
)
|
||||
}
|
||||
|
||||
const pageSelector = (s: Data) => s.document.pages[s.appState.currentPageId]
|
||||
|
@ -106,7 +110,6 @@ export function TLDraw({
|
|||
}, [sId, id])
|
||||
|
||||
// Use the `key` to ensure that new selector hooks are made when the id changes
|
||||
|
||||
return (
|
||||
<TLDrawContext.Provider value={context}>
|
||||
<IdProvider>
|
||||
|
@ -157,7 +160,7 @@ function InnerTldraw({
|
|||
|
||||
const isSelecting = useSelector(isInSelectSelector)
|
||||
|
||||
const isSelectedHandlesShape = useSelector(isSelectedShapeWithHandlesSelector)
|
||||
const isHideBoundsShape = useSelector(isHideBoundsShapeSelector)
|
||||
|
||||
const isInSession = tlstate.session !== undefined
|
||||
|
||||
|
@ -165,7 +168,7 @@ function InnerTldraw({
|
|||
const hideBounds =
|
||||
(isInSession && tlstate.session?.constructor.name !== 'BrushSession') ||
|
||||
!isSelecting ||
|
||||
isSelectedHandlesShape ||
|
||||
isHideBoundsShape ||
|
||||
!!pageState.editingId
|
||||
|
||||
// Hide bounds when not using the select tool, or when in session
|
||||
|
@ -215,6 +218,7 @@ function InnerTldraw({
|
|||
<ContextMenu>
|
||||
<Renderer
|
||||
id={id}
|
||||
containerRef={rWrapper}
|
||||
page={page}
|
||||
pageState={pageState}
|
||||
users={users}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react'
|
||||
import { floatingContainer, rowButton } from '../shared'
|
||||
import { floatingContainer, rowButton } from '~components/shared'
|
||||
import css from '~styles'
|
||||
import type { Data } from '~types'
|
||||
import { useTLDrawContext } from '~hooks'
|
|
@ -0,0 +1 @@
|
|||
export * from './back-to-content'
|
|
@ -0,0 +1 @@
|
|||
export * from './primary-tools'
|
|
@ -0,0 +1,98 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
ArrowTopRightIcon,
|
||||
CircleIcon,
|
||||
Pencil1Icon,
|
||||
Pencil2Icon,
|
||||
SquareIcon,
|
||||
TextIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Data, TLDrawShapeType } from '~types'
|
||||
import { useTLDrawContext } from '~hooks'
|
||||
import { floatingContainer } from '~components/shared'
|
||||
import { PrimaryButton } from '~components/tools-panel/styled'
|
||||
|
||||
const activeToolSelector = (s: Data) => s.appState.activeTool
|
||||
|
||||
export const PrimaryTools = React.memo((): JSX.Element => {
|
||||
const { tlstate, useSelector } = useTLDrawContext()
|
||||
|
||||
const activeTool = useSelector(activeToolSelector)
|
||||
|
||||
const selectDrawTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Draw)
|
||||
}, [tlstate])
|
||||
|
||||
const selectRectangleTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Rectangle)
|
||||
}, [tlstate])
|
||||
|
||||
const selectEllipseTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Ellipse)
|
||||
}, [tlstate])
|
||||
|
||||
const selectArrowTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Arrow)
|
||||
}, [tlstate])
|
||||
|
||||
const selectTextTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Text)
|
||||
}, [tlstate])
|
||||
|
||||
const selectStickyTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Sticky)
|
||||
}, [tlstate])
|
||||
|
||||
return (
|
||||
<div className={floatingContainer()}>
|
||||
<PrimaryButton
|
||||
kbd={'2'}
|
||||
label={TLDrawShapeType.Draw}
|
||||
onClick={selectDrawTool}
|
||||
isActive={activeTool === TLDrawShapeType.Draw}
|
||||
>
|
||||
<Pencil1Icon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'3'}
|
||||
label={TLDrawShapeType.Rectangle}
|
||||
onClick={selectRectangleTool}
|
||||
isActive={activeTool === TLDrawShapeType.Rectangle}
|
||||
>
|
||||
<SquareIcon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'4'}
|
||||
label={TLDrawShapeType.Draw}
|
||||
onClick={selectEllipseTool}
|
||||
isActive={activeTool === TLDrawShapeType.Ellipse}
|
||||
>
|
||||
<CircleIcon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'5'}
|
||||
label={TLDrawShapeType.Arrow}
|
||||
onClick={selectArrowTool}
|
||||
isActive={activeTool === TLDrawShapeType.Arrow}
|
||||
>
|
||||
<ArrowTopRightIcon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'6'}
|
||||
label={TLDrawShapeType.Text}
|
||||
onClick={selectTextTool}
|
||||
isActive={activeTool === TLDrawShapeType.Text}
|
||||
>
|
||||
<TextIcon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'7'}
|
||||
label={TLDrawShapeType.Sticky}
|
||||
onClick={selectStickyTool}
|
||||
isActive={activeTool === TLDrawShapeType.Sticky}
|
||||
>
|
||||
<Pencil2Icon />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)
|
||||
})
|
|
@ -0,0 +1 @@
|
|||
export * from './status-bar'
|
|
@ -1,23 +1,15 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
ArrowTopRightIcon,
|
||||
CircleIcon,
|
||||
CursorArrowIcon,
|
||||
LockClosedIcon,
|
||||
LockOpen1Icon,
|
||||
Pencil1Icon,
|
||||
SquareIcon,
|
||||
TextIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { CursorArrowIcon, LockClosedIcon, LockOpen1Icon } from '@radix-ui/react-icons'
|
||||
import css from '~styles'
|
||||
import { Data, TLDrawShapeType } from '~types'
|
||||
import type { Data } from '~types'
|
||||
import { useTLDrawContext } from '~hooks'
|
||||
import { StatusBar } from './status-bar'
|
||||
import { floatingContainer } from '../shared'
|
||||
import { PrimaryButton, SecondaryButton } from './styled'
|
||||
import { UndoRedo } from './undo-redo'
|
||||
import { Zoom } from './zoom'
|
||||
import { BackToContent } from './back-to-content'
|
||||
import { floatingContainer } from '~components/shared'
|
||||
import { StatusBar } from '~components/tools-panel/status-bar'
|
||||
import { SecondaryButton } from '~components/tools-panel/styled'
|
||||
import { UndoRedo } from '~components/tools-panel/undo-redo'
|
||||
import { Zoom } from '~components/tools-panel/zoom'
|
||||
import { BackToContent } from '~components/tools-panel/back-to-content'
|
||||
import { PrimaryTools } from '~components/tools-panel/primary-tools'
|
||||
|
||||
const activeToolSelector = (s: Data) => s.appState.activeTool
|
||||
const isToolLockedSelector = (s: Data) => s.appState.isToolLocked
|
||||
|
@ -36,26 +28,6 @@ export const ToolsPanel = React.memo((): JSX.Element => {
|
|||
tlstate.selectTool('select')
|
||||
}, [tlstate])
|
||||
|
||||
const selectDrawTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Draw)
|
||||
}, [tlstate])
|
||||
|
||||
const selectRectangleTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Rectangle)
|
||||
}, [tlstate])
|
||||
|
||||
const selectEllipseTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Ellipse)
|
||||
}, [tlstate])
|
||||
|
||||
const selectArrowTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Arrow)
|
||||
}, [tlstate])
|
||||
|
||||
const selectTextTool = React.useCallback(() => {
|
||||
tlstate.selectTool(TLDrawShapeType.Text)
|
||||
}, [tlstate])
|
||||
|
||||
return (
|
||||
<div className={toolsPanelContainer()}>
|
||||
<div className={leftWrap({ size: { '@initial': 'mobile', '@sm': 'small' } })}>
|
||||
|
@ -73,48 +45,7 @@ export const ToolsPanel = React.memo((): JSX.Element => {
|
|||
</div>
|
||||
<div className={centerWrap()}>
|
||||
<BackToContent />
|
||||
<div className={floatingContainer()}>
|
||||
<PrimaryButton
|
||||
kbd={'2'}
|
||||
label={TLDrawShapeType.Draw}
|
||||
onClick={selectDrawTool}
|
||||
isActive={activeTool === TLDrawShapeType.Draw}
|
||||
>
|
||||
<Pencil1Icon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'3'}
|
||||
label={TLDrawShapeType.Rectangle}
|
||||
onClick={selectRectangleTool}
|
||||
isActive={activeTool === TLDrawShapeType.Rectangle}
|
||||
>
|
||||
<SquareIcon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'4'}
|
||||
label={TLDrawShapeType.Draw}
|
||||
onClick={selectEllipseTool}
|
||||
isActive={activeTool === TLDrawShapeType.Ellipse}
|
||||
>
|
||||
<CircleIcon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'5'}
|
||||
label={TLDrawShapeType.Arrow}
|
||||
onClick={selectArrowTool}
|
||||
isActive={activeTool === TLDrawShapeType.Arrow}
|
||||
>
|
||||
<ArrowTopRightIcon />
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
kbd={'6'}
|
||||
label={TLDrawShapeType.Text}
|
||||
onClick={selectTextTool}
|
||||
isActive={activeTool === TLDrawShapeType.Text}
|
||||
>
|
||||
<TextIcon />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
<PrimaryTools />
|
||||
</div>
|
||||
<div
|
||||
className={rightWrap({ size: { '@initial': 'mobile', '@micro': 'micro', '@sm': 'small' } })}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './undo-redo'
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react'
|
||||
import { useTLDrawContext } from '~hooks'
|
||||
import { TertiaryButton, tertiaryButtonsContainer } from './styled'
|
||||
import { Undo, Redo, Trash } from '../icons'
|
||||
import { TertiaryButton, tertiaryButtonsContainer } from '~components/tools-panel/styled'
|
||||
import { Undo, Redo, Trash } from '~components/icons'
|
||||
|
||||
export const UndoRedo = React.memo((): JSX.Element => {
|
||||
const { tlstate } = useTLDrawContext()
|
1
packages/tldraw/src/components/tools-panel/zoom/index.ts
Normal file
1
packages/tldraw/src/components/tools-panel/zoom/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './zoom'
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { ZoomInIcon, ZoomOutIcon } from '@radix-ui/react-icons'
|
||||
import { TertiaryButton, tertiaryButtonsContainer } from './styled'
|
||||
import { TertiaryButton, tertiaryButtonsContainer } from '~components/tools-panel/styled'
|
||||
import { useTLDrawContext } from '~hooks'
|
||||
import type { Data } from '~types'
|
||||
|
|
@ -66,6 +66,15 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
|
|||
[tlstate]
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'n,7',
|
||||
() => {
|
||||
if (canHandleEvent()) tlstate.selectTool(TLDrawShapeType.Sticky)
|
||||
},
|
||||
undefined,
|
||||
[tlstate]
|
||||
)
|
||||
|
||||
/* ---------------------- Misc ---------------------- */
|
||||
|
||||
// Dark Mode
|
||||
|
|
|
@ -20,6 +20,26 @@ const colors = {
|
|||
[ColorStyle.Yellow]: '#ffc936',
|
||||
}
|
||||
|
||||
export const stickyFills: Record<Theme, Record<ColorStyle, string>> = {
|
||||
light: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(colors).map(([k, v]) => [k, Utils.lerpColor(v, canvasLight, 0.45)])
|
||||
) as Record<ColorStyle, string>),
|
||||
[ColorStyle.White]: '#ffffff',
|
||||
[ColorStyle.Black]: '#3d3d3d',
|
||||
},
|
||||
dark: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(colors).map(([k, v]) => [
|
||||
k,
|
||||
Utils.lerpColor(Utils.lerpColor(v, '#999999', 0.3), canvasDark, 0.4),
|
||||
])
|
||||
) as Record<ColorStyle, string>),
|
||||
[ColorStyle.White]: '#bbbbbb',
|
||||
[ColorStyle.Black]: '#1d1d1d',
|
||||
},
|
||||
}
|
||||
|
||||
export const strokes: Record<Theme, Record<ColorStyle, string>> = {
|
||||
light: colors,
|
||||
dark: {
|
||||
|
@ -57,6 +77,13 @@ const fontSizes = {
|
|||
auto: 'auto',
|
||||
}
|
||||
|
||||
const stickyFontSizes = {
|
||||
[SizeStyle.Small]: 24,
|
||||
[SizeStyle.Medium]: 36,
|
||||
[SizeStyle.Large]: 48,
|
||||
auto: 'auto',
|
||||
}
|
||||
|
||||
export function getStrokeWidth(size: SizeStyle): number {
|
||||
return strokeWidths[size]
|
||||
}
|
||||
|
@ -65,6 +92,10 @@ export function getFontSize(size: SizeStyle): number {
|
|||
return fontSizes[size]
|
||||
}
|
||||
|
||||
export function getStickyFontSize(size: SizeStyle): number {
|
||||
return stickyFontSizes[size]
|
||||
}
|
||||
|
||||
export function getFontStyle(style: ShapeStyles): string {
|
||||
const fontSize = getFontSize(style.size)
|
||||
const { scale = 1 } = style
|
||||
|
@ -72,6 +103,26 @@ export function getFontStyle(style: ShapeStyles): string {
|
|||
return `${fontSize * scale}px/1.3 "Caveat Brush"`
|
||||
}
|
||||
|
||||
export function getStickyFontStyle(style: ShapeStyles): string {
|
||||
const fontSize = getStickyFontSize(style.size)
|
||||
const { scale = 1 } = style
|
||||
|
||||
return `${fontSize * scale}px/1.3 "Caveat Brush"`
|
||||
}
|
||||
|
||||
export function getStickyShapeStyle(style: ShapeStyles, isDarkMode = false) {
|
||||
const { color } = style
|
||||
|
||||
const theme: Theme = isDarkMode ? 'dark' : 'light'
|
||||
const adjustedColor = color === ColorStyle.Black ? ColorStyle.Yellow : color
|
||||
|
||||
return {
|
||||
fill: stickyFills[theme][adjustedColor],
|
||||
stroke: strokes[theme][adjustedColor],
|
||||
color: isDarkMode ? '#1d1d1d' : '#0d0d0d',
|
||||
}
|
||||
}
|
||||
|
||||
export function getShapeStyle(
|
||||
style: ShapeStyles,
|
||||
isDarkMode = false
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Rectangle, Ellipse, Arrow, Draw, Text, Group, PostIt } from './shapes'
|
||||
import { Rectangle, Ellipse, Arrow, Draw, Text, Group, Sticky } from './shapes'
|
||||
import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil } from '~types'
|
||||
|
||||
// This is a bad "any", but the "this" context stuff we're doing doesn't allow us to union the types
|
||||
|
@ -10,7 +10,7 @@ export const tldrawShapeUtils: Record<TLDrawShapeType, any> = {
|
|||
[TLDrawShapeType.Arrow]: Arrow,
|
||||
[TLDrawShapeType.Text]: Text,
|
||||
[TLDrawShapeType.Group]: Group,
|
||||
[TLDrawShapeType.PostIt]: PostIt,
|
||||
[TLDrawShapeType.Sticky]: Sticky,
|
||||
}
|
||||
|
||||
export function getShapeUtils<T extends TLDrawShape>(type: T['type']) {
|
||||
|
|
|
@ -27,6 +27,8 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
|
|||
|
||||
canStyleFill: false,
|
||||
|
||||
showBounds: false,
|
||||
|
||||
pathCache: new WeakMap<ArrowShape, string>(),
|
||||
|
||||
defaultProps: {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { SVGContainer, Utils, ShapeUtil, TLTransformInfo, TLBounds } from '@tldr
|
|||
import { Vec } from '@tldraw/vec'
|
||||
import { DashStyle, EllipseShape, TLDrawShapeType, TLDrawMeta } from '~types'
|
||||
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles'
|
||||
import getStroke, { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
|
||||
import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
|
||||
import {
|
||||
intersectBoundsEllipse,
|
||||
intersectLineSegmentEllipse,
|
||||
|
|
|
@ -4,4 +4,4 @@ export * from './rectangle'
|
|||
export * from './ellipse'
|
||||
export * from './text'
|
||||
export * from './group'
|
||||
export * from './post-it'
|
||||
export * from './sticky'
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export * from './post-it'
|
|
@ -1,8 +0,0 @@
|
|||
import { PostIt } from './post-it'
|
||||
|
||||
describe('Post-It shape', () => {
|
||||
it('Creates a shape', () => {
|
||||
expect(PostIt.create).toBeDefined()
|
||||
// expect(PostIt.create({ id: 'postit' })).toMatchSnapshot('postit')
|
||||
})
|
||||
})
|
|
@ -1,89 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import { HTMLContainer, ShapeUtil } from '@tldraw/core'
|
||||
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||
import { PostItShape, TLDrawMeta, TLDrawShapeType } from '~types'
|
||||
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
|
||||
|
||||
export const PostIt = new ShapeUtil<PostItShape, HTMLDivElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.PostIt,
|
||||
|
||||
canBind: true,
|
||||
|
||||
pathCache: new WeakMap<number[], string>([]),
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.PostIt,
|
||||
name: 'PostIt',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
text: '',
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
},
|
||||
|
||||
shouldRender(prev, next) {
|
||||
return next.size !== prev.size || next.style !== prev.style
|
||||
},
|
||||
|
||||
Component({ events }, ref) {
|
||||
const [count, setCount] = React.useState(0)
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: 'all',
|
||||
backgroundColor: 'rgba(255, 220, 100)',
|
||||
border: '1px solid black',
|
||||
fontFamily: 'sans-serif',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div onPointerDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
type="textarea"
|
||||
style={{ width: '100%', height: '50%', background: 'none' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button onPointerDown={() => setCount((count) => count + 1)}>{count}</button>
|
||||
</div>
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
},
|
||||
|
||||
Indicator({ shape }) {
|
||||
const {
|
||||
style,
|
||||
size: [width, height],
|
||||
} = shape
|
||||
|
||||
const styles = getShapeStyle(style, false)
|
||||
const strokeWidth = +styles.strokeWidth
|
||||
|
||||
const sw = strokeWidth
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
rx={1}
|
||||
ry={1}
|
||||
width={Math.max(1, width - sw)}
|
||||
height={Math.max(1, height - sw)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
getBounds(shape) {
|
||||
return getBoundsRectangle(shape, this.boundsCache)
|
||||
},
|
||||
|
||||
transform: transformRectangle,
|
||||
|
||||
transformSingle: transformSingleRectangle,
|
||||
}))
|
|
@ -81,3 +81,175 @@ export function getBoundsRectangle<T extends TLShape & { size: number[] }>(
|
|||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
|
||||
// Adapted (mostly copied) the work of https://github.com/fregante
|
||||
// Copyright (c) Federico Brigante <opensource@bfred.it> (bfred.it)
|
||||
|
||||
type ReplacerCallback = (substring: string, ...args: any[]) => string
|
||||
|
||||
const INDENT = ' '
|
||||
|
||||
export class TextAreaUtils {
|
||||
static insertTextFirefox(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
|
||||
// Found on https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html 🎈
|
||||
field.setRangeText(
|
||||
text,
|
||||
field.selectionStart || 0,
|
||||
field.selectionEnd || 0,
|
||||
'end' // Without this, the cursor is either at the beginning or `text` remains selected
|
||||
)
|
||||
|
||||
field.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
data: text,
|
||||
inputType: 'insertText',
|
||||
isComposing: false, // TODO: fix @types/jsdom, this shouldn't be required
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */
|
||||
static insert(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
|
||||
const document = field.ownerDocument
|
||||
const initialFocus = document.activeElement
|
||||
if (initialFocus !== field) {
|
||||
field.focus()
|
||||
}
|
||||
|
||||
if (!document.execCommand('insertText', false, text)) {
|
||||
TextAreaUtils.insertTextFirefox(field, text)
|
||||
}
|
||||
|
||||
if (initialFocus === document.body) {
|
||||
field.blur()
|
||||
} else if (initialFocus instanceof HTMLElement && initialFocus !== field) {
|
||||
initialFocus.focus()
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */
|
||||
static set(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
|
||||
field.select()
|
||||
TextAreaUtils.insert(field, text)
|
||||
}
|
||||
|
||||
/** Get the selected text in a field or an empty string if nothing is selected. */
|
||||
static getSelection(field: HTMLTextAreaElement | HTMLInputElement): string {
|
||||
const { selectionStart, selectionEnd } = field
|
||||
return field.value.slice(
|
||||
selectionStart ? selectionStart : undefined,
|
||||
selectionEnd ? selectionEnd : undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Adds the `wrappingText` before and after field’s selection (or cursor). If `endWrappingText` is provided, it will be used instead of `wrappingText` at on the right. */
|
||||
static wrapSelection(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
wrap: string,
|
||||
wrapEnd?: string
|
||||
): void {
|
||||
const { selectionStart, selectionEnd } = field
|
||||
const selection = TextAreaUtils.getSelection(field)
|
||||
TextAreaUtils.insert(field, wrap + selection + (wrapEnd ?? wrap))
|
||||
|
||||
// Restore the selection around the previously-selected text
|
||||
field.selectionStart = (selectionStart || 0) + wrap.length
|
||||
field.selectionEnd = (selectionEnd || 0) + wrap.length
|
||||
}
|
||||
|
||||
/** Finds and replaces strings and regex in the field’s value, like `field.value = field.value.replace()` but better */
|
||||
static replace(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
searchValue: string | RegExp,
|
||||
replacer: string | ReplacerCallback
|
||||
): void {
|
||||
/** Remembers how much each match offset should be adjusted */
|
||||
let drift = 0
|
||||
|
||||
field.value.replace(searchValue, (...args): string => {
|
||||
// Select current match to replace it later
|
||||
const matchStart = drift + (args[args.length - 2] as number)
|
||||
const matchLength = args[0].length
|
||||
field.selectionStart = matchStart
|
||||
field.selectionEnd = matchStart + matchLength
|
||||
|
||||
const replacement = typeof replacer === 'string' ? replacer : replacer(...args)
|
||||
TextAreaUtils.insert(field, replacement)
|
||||
|
||||
// Select replacement. Without this, the cursor would be after the replacement
|
||||
field.selectionStart = matchStart
|
||||
drift += replacement.length - matchLength
|
||||
return replacement
|
||||
})
|
||||
}
|
||||
|
||||
static findLineEnd(value: string, currentEnd: number): number {
|
||||
// Go to the beginning of the last line
|
||||
const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1
|
||||
|
||||
// There's nothing to unindent after the last cursor, so leave it as is
|
||||
if (value.charAt(lastLineStart) !== '\t') {
|
||||
return currentEnd
|
||||
}
|
||||
|
||||
return lastLineStart + 1 // Include the first character, which will be a tab
|
||||
}
|
||||
|
||||
static indent(element: HTMLTextAreaElement): void {
|
||||
const { selectionStart, selectionEnd, value } = element
|
||||
const selectedText = value.slice(selectionStart, selectionEnd)
|
||||
// The first line should be indented, even if it starts with `\n`
|
||||
// The last line should only be indented if includes any character after `\n`
|
||||
const lineBreakCount = /\n/g.exec(selectedText)?.length
|
||||
|
||||
if (lineBreakCount && lineBreakCount > 0) {
|
||||
// Select full first line to replace everything at once
|
||||
const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
|
||||
|
||||
const newSelection = element.value.slice(firstLineStart, selectionEnd - 1)
|
||||
const indentedText = newSelection.replace(
|
||||
/^|\n/g, // Match all line starts
|
||||
`$&${INDENT}`
|
||||
)
|
||||
const replacementsCount = indentedText.length - newSelection.length
|
||||
|
||||
// Replace newSelection with indentedText
|
||||
element.setSelectionRange(firstLineStart, selectionEnd - 1)
|
||||
TextAreaUtils.insert(element, indentedText)
|
||||
|
||||
// Restore selection position, including the indentation
|
||||
element.setSelectionRange(selectionStart + 1, selectionEnd + replacementsCount)
|
||||
} else {
|
||||
TextAreaUtils.insert(element, INDENT)
|
||||
}
|
||||
}
|
||||
|
||||
// The first line should always be unindented
|
||||
// The last line should only be unindented if the selection includes any characters after `\n`
|
||||
static unindent(element: HTMLTextAreaElement): void {
|
||||
const { selectionStart, selectionEnd, value } = element
|
||||
|
||||
// Select the whole first line because it might contain \t
|
||||
const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
|
||||
const minimumSelectionEnd = TextAreaUtils.findLineEnd(value, selectionEnd)
|
||||
|
||||
const newSelection = element.value.slice(firstLineStart, minimumSelectionEnd)
|
||||
const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, '$1')
|
||||
const replacementsCount = newSelection.length - indentedText.length
|
||||
|
||||
// Replace newSelection with indentedText
|
||||
element.setSelectionRange(firstLineStart, minimumSelectionEnd)
|
||||
TextAreaUtils.insert(element, indentedText)
|
||||
|
||||
// Restore selection position, including the indentation
|
||||
const firstLineIndentation = /\t| {1,2}/.exec(value.slice(firstLineStart, selectionStart))
|
||||
|
||||
const difference = firstLineIndentation ? firstLineIndentation[0].length : 0
|
||||
|
||||
const newSelectionStart = selectionStart - difference
|
||||
element.setSelectionRange(
|
||||
selectionStart - difference,
|
||||
Math.max(newSelectionStart, selectionEnd - replacementsCount)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
1
packages/tldraw/src/shape/shapes/sticky/index.ts
Normal file
1
packages/tldraw/src/shape/shapes/sticky/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './sticky'
|
8
packages/tldraw/src/shape/shapes/sticky/sticky.spec.tsx
Normal file
8
packages/tldraw/src/shape/shapes/sticky/sticky.spec.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Sticky } from './sticky'
|
||||
|
||||
describe('Post-It shape', () => {
|
||||
it('Creates a shape', () => {
|
||||
expect(Sticky.create).toBeDefined()
|
||||
// expect(Sticky.create({ id: 'sticky' })).toMatchSnapshot('sticky')
|
||||
})
|
||||
})
|
293
packages/tldraw/src/shape/shapes/sticky/sticky.tsx
Normal file
293
packages/tldraw/src/shape/shapes/sticky/sticky.tsx
Normal file
|
@ -0,0 +1,293 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as React from 'react'
|
||||
import { css } from '@stitches/core'
|
||||
import { HTMLContainer, ShapeUtil } from '@tldraw/core'
|
||||
import { defaultStyle } from '~shape/shape-styles'
|
||||
import { StickyShape, TLDrawMeta, TLDrawShapeType } from '~types'
|
||||
import { getBoundsRectangle } from '../shared'
|
||||
import { getStickyFontStyle, getStickyShapeStyle } from '~shape'
|
||||
import { TextAreaUtils } from '../shared'
|
||||
import Vec from '@tldraw/vec'
|
||||
|
||||
const PADDING = 16
|
||||
const MIN_CONTAINER_HEIGHT = 200
|
||||
|
||||
function normalizeText(text: string) {
|
||||
return text.replace(/\r?\n|\r/g, '\n')
|
||||
}
|
||||
|
||||
export const Sticky = new ShapeUtil<StickyShape, HTMLDivElement, TLDrawMeta>(() => ({
|
||||
type: TLDrawShapeType.Sticky,
|
||||
|
||||
showBounds: false,
|
||||
|
||||
isStateful: true,
|
||||
|
||||
canBind: true,
|
||||
|
||||
canEdit: true,
|
||||
|
||||
pathCache: new WeakMap<number[], string>([]),
|
||||
|
||||
defaultProps: {
|
||||
id: 'id',
|
||||
type: TLDrawShapeType.Sticky,
|
||||
name: 'Sticky',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [200, 200],
|
||||
text: '',
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
},
|
||||
|
||||
shouldRender(prev, next) {
|
||||
return next.size !== prev.size || next.style !== prev.style || next.text !== prev.text
|
||||
},
|
||||
|
||||
Component({ events, shape, isEditing, onShapeBlur, onShapeChange, meta }, ref) {
|
||||
const font = getStickyFontStyle(shape.style)
|
||||
|
||||
const { color, fill } = getStickyShapeStyle(shape.style, meta.isDarkMode)
|
||||
|
||||
const rContainer = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const rTextArea = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const rText = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const rIsMounted = React.useRef(false)
|
||||
|
||||
const handlePointerDown = React.useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleTextChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onShapeChange?.({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
text: normalizeText(e.currentTarget.value),
|
||||
})
|
||||
},
|
||||
[onShapeChange]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') return
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
|
||||
}
|
||||
},
|
||||
[shape, onShapeChange]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return
|
||||
if (rIsMounted.current) {
|
||||
e.currentTarget.setSelectionRange(0, 0)
|
||||
onShapeBlur?.()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
const handleFocus = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return
|
||||
if (!rIsMounted.current) return
|
||||
|
||||
if (document.activeElement === e.currentTarget) {
|
||||
e.currentTarget.select()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
// Focus when editing changes to true
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
if (document.activeElement !== rText.current) {
|
||||
requestAnimationFrame(() => {
|
||||
rIsMounted.current = true
|
||||
const elm = rTextArea.current!
|
||||
elm.focus()
|
||||
elm.select()
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
// Resize to fit text
|
||||
React.useEffect(() => {
|
||||
const text = rText.current!
|
||||
|
||||
const { size } = shape
|
||||
const { offsetHeight: currTextHeight } = text
|
||||
const minTextHeight = MIN_CONTAINER_HEIGHT - PADDING * 2
|
||||
const prevTextHeight = size[1] - PADDING * 2
|
||||
|
||||
// Same size? We can quit here
|
||||
if (currTextHeight === prevTextHeight) return
|
||||
|
||||
if (currTextHeight > minTextHeight) {
|
||||
// Snap the size to the text content if the text only when the
|
||||
// text is larger than the minimum text height.
|
||||
onShapeChange?.({ id: shape.id, size: [size[0], currTextHeight + PADDING * 2] })
|
||||
return
|
||||
}
|
||||
|
||||
if (currTextHeight < minTextHeight && size[1] > MIN_CONTAINER_HEIGHT) {
|
||||
// If we're smaller than the minimum height and the container
|
||||
// is too tall, snap it down to the minimum container height
|
||||
onShapeChange?.({ id: shape.id, size: [size[0], MIN_CONTAINER_HEIGHT] })
|
||||
return
|
||||
}
|
||||
}, [shape.text, shape.size[1]])
|
||||
|
||||
const style = {
|
||||
font,
|
||||
color,
|
||||
textShadow: meta.isDarkMode
|
||||
? `0.5px 0.5px 2px rgba(255, 255, 255,.25)`
|
||||
: `0.5px 0.5px 2px rgba(255, 255, 255,.5)`,
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
<div
|
||||
ref={rContainer}
|
||||
className={styledStickyContainer({ isDarkMode: meta.isDarkMode })}
|
||||
style={{ backgroundColor: fill, ...style }}
|
||||
>
|
||||
<div ref={rText} className={styledText({ isEditing })}>
|
||||
{shape.text}​
|
||||
</div>
|
||||
{isEditing && (
|
||||
<textarea
|
||||
ref={rTextArea}
|
||||
className={styledTextArea({ isEditing })}
|
||||
onPointerDown={handlePointerDown}
|
||||
value={shape.text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
},
|
||||
|
||||
Indicator({ shape }) {
|
||||
const {
|
||||
size: [width, height],
|
||||
} = shape
|
||||
|
||||
return (
|
||||
<rect x={0} y={0} rx={3} ry={3} width={Math.max(1, width)} height={Math.max(1, height)} />
|
||||
)
|
||||
},
|
||||
|
||||
getBounds(shape) {
|
||||
return getBoundsRectangle(shape, this.boundsCache)
|
||||
},
|
||||
|
||||
transform(shape, bounds, { transformOrigin, scaleX, scaleY }) {
|
||||
const point = Vec.round([
|
||||
bounds.minX +
|
||||
(bounds.width - shape.size[0]) * (scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
|
||||
bounds.minY +
|
||||
(bounds.height - shape.size[1]) *
|
||||
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
|
||||
])
|
||||
|
||||
return {
|
||||
point,
|
||||
}
|
||||
},
|
||||
|
||||
transformSingle(shape) {
|
||||
return shape
|
||||
},
|
||||
}))
|
||||
|
||||
const styledStickyContainer = css({
|
||||
pointerEvents: 'all',
|
||||
position: 'relative',
|
||||
backgroundColor: 'rgba(255, 220, 100)',
|
||||
fontFamily: 'sans-serif',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
padding: PADDING + 'px',
|
||||
borderRadius: '3px',
|
||||
perspective: '800px',
|
||||
variants: {
|
||||
isDarkMode: {
|
||||
true: {
|
||||
boxShadow:
|
||||
'2px 3px 12px -2px rgba(0,0,0,.3), 1px 1px 4px rgba(0,0,0,.3), 1px 1px 2px rgba(0,0,0,.3)',
|
||||
},
|
||||
false: {
|
||||
boxShadow:
|
||||
'2px 3px 12px -2px rgba(0,0,0,.2), 1px 1px 4px rgba(0,0,0,.16), 1px 1px 2px rgba(0,0,0,.16)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const styledText = css({
|
||||
position: 'absolute',
|
||||
top: PADDING,
|
||||
left: PADDING,
|
||||
width: `calc(100% - ${PADDING * 2}px)`,
|
||||
height: 'fit-content',
|
||||
font: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
userSelect: 'none',
|
||||
variants: {
|
||||
isEditing: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
},
|
||||
false: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const styledTextArea = css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
background: 'none',
|
||||
outline: 'none',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
padding: 0,
|
||||
color: 'transparent',
|
||||
verticalAlign: 'top',
|
||||
resize: 'none',
|
||||
caretColor: 'black',
|
||||
})
|
|
@ -1,171 +0,0 @@
|
|||
// Adapted (mostly copied) the work of https://github.com/fregante
|
||||
// Copyright (c) Federico Brigante <opensource@bfred.it> (bfred.it)
|
||||
|
||||
type ReplacerCallback = (substring: string, ...args: any[]) => string
|
||||
|
||||
const INDENT = ' '
|
||||
|
||||
export default class TextAreaUtils {
|
||||
static insertTextFirefox(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
|
||||
// Found on https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html 🎈
|
||||
field.setRangeText(
|
||||
text,
|
||||
field.selectionStart || 0,
|
||||
field.selectionEnd || 0,
|
||||
'end' // Without this, the cursor is either at the beginning or `text` remains selected
|
||||
)
|
||||
|
||||
field.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
data: text,
|
||||
inputType: 'insertText',
|
||||
isComposing: false, // TODO: fix @types/jsdom, this shouldn't be required
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */
|
||||
static insert(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
|
||||
const document = field.ownerDocument
|
||||
const initialFocus = document.activeElement
|
||||
if (initialFocus !== field) {
|
||||
field.focus()
|
||||
}
|
||||
|
||||
if (!document.execCommand('insertText', false, text)) {
|
||||
TextAreaUtils.insertTextFirefox(field, text)
|
||||
}
|
||||
|
||||
if (initialFocus === document.body) {
|
||||
field.blur()
|
||||
} else if (initialFocus instanceof HTMLElement && initialFocus !== field) {
|
||||
initialFocus.focus()
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */
|
||||
static set(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
|
||||
field.select()
|
||||
TextAreaUtils.insert(field, text)
|
||||
}
|
||||
|
||||
/** Get the selected text in a field or an empty string if nothing is selected. */
|
||||
static getSelection(field: HTMLTextAreaElement | HTMLInputElement): string {
|
||||
const { selectionStart, selectionEnd } = field
|
||||
return field.value.slice(
|
||||
selectionStart ? selectionStart : undefined,
|
||||
selectionEnd ? selectionEnd : undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Adds the `wrappingText` before and after field’s selection (or cursor). If `endWrappingText` is provided, it will be used instead of `wrappingText` at on the right. */
|
||||
static wrapSelection(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
wrap: string,
|
||||
wrapEnd?: string
|
||||
): void {
|
||||
const { selectionStart, selectionEnd } = field
|
||||
const selection = TextAreaUtils.getSelection(field)
|
||||
TextAreaUtils.insert(field, wrap + selection + (wrapEnd ?? wrap))
|
||||
|
||||
// Restore the selection around the previously-selected text
|
||||
field.selectionStart = (selectionStart || 0) + wrap.length
|
||||
field.selectionEnd = (selectionEnd || 0) + wrap.length
|
||||
}
|
||||
|
||||
/** Finds and replaces strings and regex in the field’s value, like `field.value = field.value.replace()` but better */
|
||||
static replace(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
searchValue: string | RegExp,
|
||||
replacer: string | ReplacerCallback
|
||||
): void {
|
||||
/** Remembers how much each match offset should be adjusted */
|
||||
let drift = 0
|
||||
|
||||
field.value.replace(searchValue, (...args): string => {
|
||||
// Select current match to replace it later
|
||||
const matchStart = drift + (args[args.length - 2] as number)
|
||||
const matchLength = args[0].length
|
||||
field.selectionStart = matchStart
|
||||
field.selectionEnd = matchStart + matchLength
|
||||
|
||||
const replacement = typeof replacer === 'string' ? replacer : replacer(...args)
|
||||
TextAreaUtils.insert(field, replacement)
|
||||
|
||||
// Select replacement. Without this, the cursor would be after the replacement
|
||||
field.selectionStart = matchStart
|
||||
drift += replacement.length - matchLength
|
||||
return replacement
|
||||
})
|
||||
}
|
||||
|
||||
static findLineEnd(value: string, currentEnd: number): number {
|
||||
// Go to the beginning of the last line
|
||||
const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1
|
||||
|
||||
// There's nothing to unindent after the last cursor, so leave it as is
|
||||
if (value.charAt(lastLineStart) !== '\t') {
|
||||
return currentEnd
|
||||
}
|
||||
|
||||
return lastLineStart + 1 // Include the first character, which will be a tab
|
||||
}
|
||||
|
||||
static indent(element: HTMLTextAreaElement): void {
|
||||
const { selectionStart, selectionEnd, value } = element
|
||||
const selectedText = value.slice(selectionStart, selectionEnd)
|
||||
// The first line should be indented, even if it starts with `\n`
|
||||
// The last line should only be indented if includes any character after `\n`
|
||||
const lineBreakCount = /\n/g.exec(selectedText)?.length
|
||||
|
||||
if (lineBreakCount && lineBreakCount > 0) {
|
||||
// Select full first line to replace everything at once
|
||||
const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
|
||||
|
||||
const newSelection = element.value.slice(firstLineStart, selectionEnd - 1)
|
||||
const indentedText = newSelection.replace(
|
||||
/^|\n/g, // Match all line starts
|
||||
`$&${INDENT}`
|
||||
)
|
||||
const replacementsCount = indentedText.length - newSelection.length
|
||||
|
||||
// Replace newSelection with indentedText
|
||||
element.setSelectionRange(firstLineStart, selectionEnd - 1)
|
||||
TextAreaUtils.insert(element, indentedText)
|
||||
|
||||
// Restore selection position, including the indentation
|
||||
element.setSelectionRange(selectionStart + 1, selectionEnd + replacementsCount)
|
||||
} else {
|
||||
TextAreaUtils.insert(element, INDENT)
|
||||
}
|
||||
}
|
||||
|
||||
// The first line should always be unindented
|
||||
// The last line should only be unindented if the selection includes any characters after `\n`
|
||||
static unindent(element: HTMLTextAreaElement): void {
|
||||
const { selectionStart, selectionEnd, value } = element
|
||||
|
||||
// Select the whole first line because it might contain \t
|
||||
const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
|
||||
const minimumSelectionEnd = TextAreaUtils.findLineEnd(value, selectionEnd)
|
||||
|
||||
const newSelection = element.value.slice(firstLineStart, minimumSelectionEnd)
|
||||
const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, '$1')
|
||||
const replacementsCount = newSelection.length - indentedText.length
|
||||
|
||||
// Replace newSelection with indentedText
|
||||
element.setSelectionRange(firstLineStart, minimumSelectionEnd)
|
||||
TextAreaUtils.insert(element, indentedText)
|
||||
|
||||
// Restore selection position, including the indentation
|
||||
const firstLineIndentation = /\t| {1,2}/.exec(value.slice(firstLineStart, selectionStart))
|
||||
|
||||
const difference = firstLineIndentation ? firstLineIndentation[0].length : 0
|
||||
|
||||
const newSelectionStart = selectionStart - difference
|
||||
element.setSelectionRange(
|
||||
selectionStart - difference,
|
||||
Math.max(newSelectionStart, selectionEnd - replacementsCount)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import { Vec } from '@tldraw/vec'
|
|||
import { getShapeStyle, getFontStyle, defaultStyle } from '~shape/shape-styles'
|
||||
import { TextShape, TLDrawShapeType, TLDrawMeta } from '~types'
|
||||
import css from '~styles'
|
||||
import TextAreaUtils from './text-utils'
|
||||
import { TextAreaUtils } from '../shared'
|
||||
|
||||
const LETTER_SPACING = -1.5
|
||||
|
||||
|
|
|
@ -2,9 +2,7 @@
|
|||
import { TLDrawState } from './tlstate'
|
||||
import { mockDocument, TLStateUtils } from '~test'
|
||||
import { ArrowShape, ColorStyle, SessionType, TLDrawShapeType } from '~types'
|
||||
import type { TextTool } from './tool/TextTool'
|
||||
import type { SelectTool } from './tool/SelectTool'
|
||||
import { Shape } from '~../../core/src/components/shape/shape'
|
||||
|
||||
describe('TLDrawState', () => {
|
||||
const tlstate = new TLDrawState()
|
||||
|
|
|
@ -1740,7 +1740,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
appState: {
|
||||
status: {
|
||||
current: TLDrawStatus.Idle,
|
||||
previous: this.appState.status.previous,
|
||||
previous: TLDrawStatus.Idle,
|
||||
},
|
||||
},
|
||||
document: {
|
||||
|
@ -1833,7 +1833,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
|
||||
const { isToolLocked, activeTool } = this.appState
|
||||
|
||||
if (!isToolLocked && activeTool !== 'draw') {
|
||||
if (!isToolLocked && activeTool !== TLDrawShapeType.Draw) {
|
||||
this.selectTool('select')
|
||||
}
|
||||
|
||||
|
@ -1853,13 +1853,14 @@ export class TLDrawState extends StateManager<Data> {
|
|||
...shapes: ({ id: string; type: TLDrawShapeType } & Partial<TLDrawShape>)[]
|
||||
): this => {
|
||||
if (shapes.length === 0) return this
|
||||
|
||||
return this.create(
|
||||
shapes.map((shape) =>
|
||||
TLDR.getShapeUtils(shape.type).create({
|
||||
shapes.map((shape) => {
|
||||
return TLDR.getShapeUtils(shape.type).create({
|
||||
parentId: this.currentPageId,
|
||||
...shape,
|
||||
parentId: shape.parentId || this.currentPageId,
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -2162,130 +2163,21 @@ export class TLDrawState extends StateManager<Data> {
|
|||
|
||||
/* ----------------- Keyboard Events ---------------- */
|
||||
|
||||
onKeyDown: TLKeyboardEventHandler = (key, info) => {
|
||||
onKeyDown: TLKeyboardEventHandler = (key, info, e) => {
|
||||
if (key === 'Escape') {
|
||||
this.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
if (!info) return
|
||||
this.currentTool.onKeyDown?.(key, info, e)
|
||||
|
||||
switch (this.appState.status.current) {
|
||||
case TLDrawStatus.Idle: {
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.Brushing: {
|
||||
if (key === 'Meta' || key === 'Control') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.Translating: {
|
||||
if (key === 'Escape') {
|
||||
this.cancelSession()
|
||||
}
|
||||
|
||||
if (key === 'Shift' || key === 'Alt') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.Transforming: {
|
||||
if (key === 'Escape') {
|
||||
this.cancelSession()
|
||||
} else if (key === 'Shift' || key === 'Alt') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.TranslatingHandle: {
|
||||
if (key === 'Escape') {
|
||||
this.cancelSession()
|
||||
}
|
||||
|
||||
if (key === 'Meta' || key === 'Control') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
onKeyUp: TLKeyboardEventHandler = (key, info) => {
|
||||
onKeyUp: TLKeyboardEventHandler = (key, info, e) => {
|
||||
if (!info) return
|
||||
|
||||
switch (this.appState.status.current) {
|
||||
case TLDrawStatus.Brushing: {
|
||||
if (key === 'Meta' || key === 'Control') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.Transforming: {
|
||||
if (key === 'Shift' || key === 'Alt') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.Translating: {
|
||||
if (key === 'Shift' || key === 'Alt') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case TLDrawStatus.TranslatingHandle: {
|
||||
if (key === 'Escape') {
|
||||
this.cancelSession()
|
||||
}
|
||||
|
||||
if (key === 'Meta' || key === 'Control') {
|
||||
this.updateSession(
|
||||
this.getPagePoint(info.point),
|
||||
info.shiftKey,
|
||||
info.altKey,
|
||||
info.metaKey
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
this.currentTool.onKeyUp?.(key, info, e)
|
||||
}
|
||||
|
||||
/* ------------- Renderer Event Handlers ------------ */
|
||||
|
@ -2298,7 +2190,9 @@ export class TLDrawState extends StateManager<Data> {
|
|||
}
|
||||
|
||||
onPinchEnd: TLPinchEventHandler = () => {
|
||||
this.undoSelect()
|
||||
if (Utils.isMobileSafari()) {
|
||||
this.undoSelect()
|
||||
}
|
||||
this.setStatus(TLDrawStatus.Idle)
|
||||
}
|
||||
|
||||
|
@ -2350,41 +2244,7 @@ export class TLDrawState extends StateManager<Data> {
|
|||
this.currentTool.onPointerMove?.(info, e)
|
||||
}
|
||||
|
||||
onPointerDown: TLPointerEventHandler = (...args) => {
|
||||
this.currentTool.onPointerDown?.(...args)
|
||||
|
||||
// switch (this.appState.status.current) {
|
||||
// case TLDrawStatus.Idle: {
|
||||
// switch (this.appState.activeTool) {
|
||||
// case TLDrawShapeType.Draw: {
|
||||
// this.createActiveToolShape(info.point)
|
||||
// break
|
||||
// }
|
||||
// case TLDrawShapeType.Rectangle: {
|
||||
// this.createActiveToolShape(info.point)
|
||||
// break
|
||||
// }
|
||||
// case TLDrawShapeType.Ellipse: {
|
||||
// this.createActiveToolShape(info.point)
|
||||
// break
|
||||
// }
|
||||
// case TLDrawShapeType.Arrow: {
|
||||
// this.createActiveToolShape(info.point)
|
||||
// break
|
||||
// }
|
||||
// case TLDrawShapeType.Text: {
|
||||
// this.createActiveToolShape(info.point)
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// break
|
||||
// }
|
||||
// case TLDrawStatus.EditingText: {
|
||||
// this.completeSession()
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
}
|
||||
onPointerDown: TLPointerEventHandler = (...args) => this.currentTool.onPointerDown?.(...args)
|
||||
|
||||
onPointerUp: TLPointerEventHandler = (...args) => this.currentTool.onPointerUp?.(...args)
|
||||
|
||||
|
@ -2487,10 +2347,12 @@ export class TLDrawState extends StateManager<Data> {
|
|||
|
||||
this.setEditingId()
|
||||
|
||||
if (shape.type === TLDrawShapeType.Text && shape.text.trim().length <= 0) {
|
||||
this.setState(Commands.deleteShapes(this.state, [editingId]), 'delete_empty_text')
|
||||
} else {
|
||||
this.select(editingId)
|
||||
if (shape.type === TLDrawShapeType.Text) {
|
||||
if (shape.text.trim().length <= 0) {
|
||||
this.setState(Commands.deleteShapes(this.state, [editingId]), 'delete_empty_text')
|
||||
} else {
|
||||
this.select(editingId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,13 @@ export class ArrowTool extends BaseTool {
|
|||
this.status = status
|
||||
}
|
||||
|
||||
onEnter = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onExit = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
/* ----------------- Event Handlers ----------------- */
|
||||
|
||||
onPointerDown: TLPointerEventHandler = (info) => {
|
||||
|
|
|
@ -19,6 +19,10 @@ export abstract class BaseTool {
|
|||
this.state = state
|
||||
}
|
||||
|
||||
abstract onEnter: () => void
|
||||
|
||||
abstract onExit: () => void
|
||||
|
||||
getNextChildIndex = () => {
|
||||
const {
|
||||
shapes,
|
||||
|
|
|
@ -21,6 +21,14 @@ export class DrawTool extends BaseTool {
|
|||
this.status = status
|
||||
}
|
||||
|
||||
onEnter = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onExit = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
/* ----------------- Event Handlers ----------------- */
|
||||
|
||||
onPointerDown: TLPointerEventHandler = (info) => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Vec from '@tldraw/vec'
|
||||
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
|
||||
import { Utils, TLPointerEventHandler, TLKeyboardEventHandler, TLBoundsCorner } from '@tldraw/core'
|
||||
import { Ellipse } from '~shape/shapes'
|
||||
import { SessionType, TLDrawShapeType } from '~types'
|
||||
import { BaseTool } from '../BaseTool'
|
||||
|
@ -19,6 +19,14 @@ export class EllipseTool extends BaseTool {
|
|||
this.status = status
|
||||
}
|
||||
|
||||
onEnter = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onExit = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
/* ----------------- Event Handlers ----------------- */
|
||||
|
||||
onPointerDown: TLPointerEventHandler = (info) => {
|
||||
|
@ -54,6 +62,18 @@ export class EllipseTool extends BaseTool {
|
|||
}
|
||||
}
|
||||
|
||||
onKeyDown: TLKeyboardEventHandler = (key, info) => {
|
||||
if (
|
||||
(this.status === Status.Creating && key === 'Shift') ||
|
||||
key === 'Meta' ||
|
||||
key === 'Alt' ||
|
||||
key === 'Ctrl'
|
||||
) {
|
||||
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
|
||||
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp: TLPointerEventHandler = () => {
|
||||
if (this.status === Status.Creating) {
|
||||
this.state.completeSession()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Vec from '@tldraw/vec'
|
||||
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
|
||||
import { Utils, TLPointerEventHandler, TLKeyboardEventHandler, TLBoundsCorner } from '@tldraw/core'
|
||||
import { Rectangle } from '~shape/shapes'
|
||||
import { SessionType, TLDrawShapeType } from '~types'
|
||||
import { BaseTool } from '../BaseTool'
|
||||
|
@ -20,6 +20,14 @@ export class RectangleTool extends BaseTool {
|
|||
this.status = status
|
||||
}
|
||||
|
||||
onEnter = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onExit = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
/* ----------------- Event Handlers ----------------- */
|
||||
|
||||
onPointerDown: TLPointerEventHandler = (info) => {
|
||||
|
@ -55,6 +63,18 @@ export class RectangleTool extends BaseTool {
|
|||
}
|
||||
}
|
||||
|
||||
onKeyDown: TLKeyboardEventHandler = (key, info) => {
|
||||
if (
|
||||
(this.status === Status.Creating && key === 'Shift') ||
|
||||
key === 'Meta' ||
|
||||
key === 'Alt' ||
|
||||
key === 'Ctrl'
|
||||
) {
|
||||
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
|
||||
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp: TLPointerEventHandler = () => {
|
||||
if (this.status === Status.Creating) {
|
||||
this.state.completeSession()
|
||||
|
|
|
@ -24,8 +24,6 @@ enum Status {
|
|||
Rotating = 'rotating',
|
||||
Pinching = 'pinching',
|
||||
Brushing = 'brushing',
|
||||
Creating = 'creating',
|
||||
EditingText = 'editing-text',
|
||||
}
|
||||
|
||||
export class SelectTool extends BaseTool {
|
||||
|
@ -64,6 +62,14 @@ export class SelectTool extends BaseTool {
|
|||
this.state.deselectAll()
|
||||
}
|
||||
|
||||
onEnter = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onExit = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
/* ----------------- Event Handlers ----------------- */
|
||||
|
||||
onCancel = () => {
|
||||
|
@ -179,13 +185,8 @@ export class SelectTool extends BaseTool {
|
|||
}
|
||||
|
||||
onPointerDown: TLPointerEventHandler = () => {
|
||||
if (this.status === Status.Idle) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.status === Status.EditingText) {
|
||||
this.state.completeSession()
|
||||
return
|
||||
if (this.state.appState.isStyleOpen) {
|
||||
this.state.toggleStylePanel()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -234,6 +235,9 @@ export class SelectTool extends BaseTool {
|
|||
// Unless the user is holding shift or meta, clear the current selection
|
||||
if (!info.shiftKey) {
|
||||
this.deselectAll()
|
||||
if (this.state.pageState.editingId) {
|
||||
this.state.setEditingId()
|
||||
}
|
||||
}
|
||||
|
||||
this.setStatus(Status.PointingCanvas)
|
||||
|
|
|
@ -1,6 +1,100 @@
|
|||
import { TLDrawShapeType } from '~types'
|
||||
import Vec from '@tldraw/vec'
|
||||
import type { TLPointerEventHandler } from '@tldraw/core'
|
||||
import { Utils } from '@tldraw/core'
|
||||
import { Sticky } from '~shape/shapes'
|
||||
import { SessionType, TLDrawShapeType } from '~types'
|
||||
import { BaseTool } from '../BaseTool'
|
||||
|
||||
export class StickyTool extends BaseTool {
|
||||
type = TLDrawShapeType.PostIt
|
||||
enum Status {
|
||||
Idle = 'idle',
|
||||
Creating = 'creating',
|
||||
}
|
||||
|
||||
export class StickyTool extends BaseTool {
|
||||
type = TLDrawShapeType.Sticky
|
||||
|
||||
status = Status.Idle
|
||||
|
||||
shapeId?: string
|
||||
|
||||
/* --------------------- Methods -------------------- */
|
||||
|
||||
private setStatus(status: Status) {
|
||||
this.status = status
|
||||
}
|
||||
|
||||
onEnter = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onExit = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
/* ----------------- Event Handlers ----------------- */
|
||||
|
||||
onPointerDown: TLPointerEventHandler = (info) => {
|
||||
if (this.status === Status.Creating) {
|
||||
this.setStatus(Status.Idle)
|
||||
|
||||
if (!this.state.appState.isToolLocked) {
|
||||
this.state.selectTool('select')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.status === Status.Idle) {
|
||||
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
|
||||
|
||||
const {
|
||||
shapes,
|
||||
appState: { currentPageId, currentStyle },
|
||||
} = this.state
|
||||
|
||||
const childIndex =
|
||||
shapes.length === 0
|
||||
? 1
|
||||
: shapes
|
||||
.filter((shape) => shape.parentId === currentPageId)
|
||||
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
|
||||
|
||||
const id = Utils.uniqueId()
|
||||
|
||||
this.shapeId = id
|
||||
|
||||
const newShape = Sticky.create({
|
||||
id,
|
||||
parentId: currentPageId,
|
||||
childIndex,
|
||||
point: pagePoint,
|
||||
style: { ...currentStyle },
|
||||
})
|
||||
|
||||
const bounds = Sticky.getBounds(newShape)
|
||||
|
||||
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
|
||||
|
||||
this.state.createShapes(newShape)
|
||||
|
||||
this.state.startSession(SessionType.Translate, pagePoint)
|
||||
|
||||
this.setStatus(Status.Creating)
|
||||
}
|
||||
}
|
||||
|
||||
onPointerMove: TLPointerEventHandler = (info) => {
|
||||
if (this.status === Status.Creating) {
|
||||
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
|
||||
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp: TLPointerEventHandler = () => {
|
||||
if (this.status === Status.Creating) {
|
||||
this.state.completeSession()
|
||||
this.setStatus(Status.Idle)
|
||||
this.state.setEditingId(this.shapeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,14 @@ export class TextTool extends BaseTool {
|
|||
this.status = status
|
||||
}
|
||||
|
||||
onEnter = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onExit = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
stopEditingShape = () => {
|
||||
this.setStatus(Status.Idle)
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ export type ToolType =
|
|||
| TLDrawShapeType.Ellipse
|
||||
| TLDrawShapeType.Rectangle
|
||||
| TLDrawShapeType.Arrow
|
||||
| TLDrawShapeType.PostIt
|
||||
| TLDrawShapeType.Sticky
|
||||
|
||||
export interface ToolsMap {
|
||||
select: typeof SelectTool
|
||||
|
@ -24,7 +24,7 @@ export interface ToolsMap {
|
|||
[TLDrawShapeType.Ellipse]: typeof EllipseTool
|
||||
[TLDrawShapeType.Rectangle]: typeof RectangleTool
|
||||
[TLDrawShapeType.Arrow]: typeof ArrowTool
|
||||
[TLDrawShapeType.PostIt]: typeof StickyTool
|
||||
[TLDrawShapeType.Sticky]: typeof StickyTool
|
||||
}
|
||||
|
||||
export type ToolOfType<K extends ToolType> = ToolsMap[K]
|
||||
|
@ -38,7 +38,7 @@ export const tools: { [K in ToolType]: ToolsMap[K] } = {
|
|||
[TLDrawShapeType.Ellipse]: EllipseTool,
|
||||
[TLDrawShapeType.Rectangle]: RectangleTool,
|
||||
[TLDrawShapeType.Arrow]: ArrowTool,
|
||||
[TLDrawShapeType.PostIt]: StickyTool,
|
||||
[TLDrawShapeType.Sticky]: StickyTool,
|
||||
}
|
||||
|
||||
export const getTool = <K extends ToolType>(type: K): ToolOfType<K> => {
|
||||
|
@ -53,6 +53,6 @@ export function createTools(state: TLDrawState) {
|
|||
[TLDrawShapeType.Ellipse]: new EllipseTool(state),
|
||||
[TLDrawShapeType.Rectangle]: new RectangleTool(state),
|
||||
[TLDrawShapeType.Arrow]: new ArrowTool(state),
|
||||
[TLDrawShapeType.PostIt]: new StickyTool(state),
|
||||
[TLDrawShapeType.Sticky]: new StickyTool(state),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -164,7 +164,7 @@ export enum FlipType {
|
|||
}
|
||||
|
||||
export enum TLDrawShapeType {
|
||||
PostIt = 'post-it',
|
||||
Sticky = 'sticky',
|
||||
Ellipse = 'ellipse',
|
||||
Rectangle = 'rectangle',
|
||||
Draw = 'draw',
|
||||
|
@ -223,8 +223,8 @@ export interface GroupShape extends TLDrawBaseShape {
|
|||
children: string[]
|
||||
}
|
||||
|
||||
export interface PostItShape extends TLDrawBaseShape {
|
||||
type: TLDrawShapeType.PostIt
|
||||
export interface StickyShape extends TLDrawBaseShape {
|
||||
type: TLDrawShapeType.Sticky
|
||||
size: number[]
|
||||
text: string
|
||||
}
|
||||
|
@ -236,7 +236,7 @@ export type TLDrawShape =
|
|||
| ArrowShape
|
||||
| TextShape
|
||||
| GroupShape
|
||||
| PostItShape
|
||||
| StickyShape
|
||||
|
||||
export type TLDrawShapeUtil<T extends TLDrawShape> = TLShapeUtil<T, any, TLDrawMeta>
|
||||
|
||||
|
|
Loading…
Reference in a new issue