[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:
Steve Ruiz 2021-10-13 17:03:33 +01:00 committed by GitHub
parent 1408ac2cbe
commit e1c307fd71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 918 additions and 568 deletions

View file

@ -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()

View file

@ -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} />}
</>
)

View file

@ -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>

View file

@ -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,

View file

@ -22,6 +22,8 @@ export const ShapeUtil = function <T extends TLShape, E extends Element, M = any
canBind: false,
showBounds: true,
isStateful: false,
isAspectRatioLocked: false,

View file

@ -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

View file

@ -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[]) {

View file

@ -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 (

View file

@ -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}

View file

@ -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'

View file

@ -0,0 +1 @@
export * from './back-to-content'

View file

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

View file

@ -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>
)
})

View file

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

View file

@ -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' } })}

View file

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

View file

@ -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()

View file

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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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']) {

View file

@ -27,6 +27,8 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
canStyleFill: false,
showBounds: false,
pathCache: new WeakMap<ArrowShape, string>(),
defaultProps: {

View file

@ -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,

View file

@ -4,4 +4,4 @@ export * from './rectangle'
export * from './ellipse'
export * from './text'
export * from './group'
export * from './post-it'
export * from './sticky'

View file

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

View file

@ -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')
})
})

View file

@ -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,
}))

View file

@ -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 cursors 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 fields 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 fields 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)
)
}
}

View file

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

View 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')
})
})

View 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}&#8203;
</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',
})

View file

@ -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 cursors 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 fields 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 fields 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)
)
}
}

View file

@ -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

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -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) => {

View file

@ -19,6 +19,10 @@ export abstract class BaseTool {
this.state = state
}
abstract onEnter: () => void
abstract onExit: () => void
getNextChildIndex = () => {
const {
shapes,

View file

@ -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) => {

View file

@ -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()

View file

@ -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()

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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),
}
}

View file

@ -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>