ShapeUtil refactor, Editor cleanup (#1611)

This PR improves the ergonomics of `ShapeUtil` classes.

### Cached methods

First, I've remove the cached methods (such as `bounds`) from the
`ShapeUtil` class and lifted this to the `Editor` class.

Previously, calling `ShapeUtil.getBounds` would return the un-cached
bounds of a shape, while calling `ShapeUtil.bounds` would return the
cached bounds of a shape. We also had `Editor.getBounds`, which would
call `ShapeUtil.bounds`. It was confusing. The cached methods like
`outline` were also marked with "please don't override", which suggested
the architecture was just wrong.

The only weirdness from this is that utils sometimes reach out to the
editor for cached versions of data rather than calling their own cached
methods. It's still an easier story to tell than what we had before.

### More defaults

We now have three and only three `abstract` methods for a `ShapeUtil`:
- `getDefaultProps` (renamed from `defaultProps`)
- `getBounds`,
- `component`
-  `indicator`

Previously, we also had `getCenter` as an abstract method, though this
was usually just the middle of the bounds anyway.

### Editing bounds

This PR removes the concept of editingBounds. The viewport will no
longer animate to editing shapes.

### Active area manager

This PR also removes the active area manager, which was not being used
in the way we expected it to be.

### Dpr manager

This PR removes the dpr manager and uses a hook instead to update it
from React. This is one less runtime browser dependency in the app, one
less thing to document.

### Moving things around

This PR also continues to try to organize related methods and properties
in the editor.

### Change Type

- [x] `major` — Breaking change

### Release Notes

- [editor] renames `defaultProps` to `getDefaultProps`
- [editor] removes `outline`, `outlineSegments`, `handles`, `bounds`
- [editor] renames `renderBackground` to `backgroundComponent`
This commit is contained in:
Steve Ruiz 2023-06-19 15:01:18 +01:00 committed by GitHub
parent 38d74a9ff0
commit 57bb341593
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 6422 additions and 6370 deletions

View file

@ -11,6 +11,126 @@ keywords:
- utils
---
Coming soon.
In tldraw, **shapes** are the things that are on the canvas. This article is about shapes: what they are, how they work, and how to create your own shapes. If you'd prefer to see an example, see the tldraw repository's [examples app](https://github.com/tldraw/tldraw/tree/main/apps/examples) for examples of how to create custom shapes in tldraw.
See the [tldraw repository](https://github.com/tldraw/tldraw/tree/main/apps/examples) for an example of how to create custom shapes in tldraw.
## Custom shapes
Let's create a custom "card" shape.
### Shape type
In tldraw's data model, each shape is represented by a JSON object. Let's first create a type that describes what this object will look like.
```ts
import { TLBaseShape } from '@tldraw/tldraw'
type CardShape = TLBaseShape<
'card',
{ w: number, h: number }
>
```
With the `TLBaseShape` helper, we define the shape's `type` property (`card`) and the shape's `props` property (`{ w: number, h: number }`). The type can be any string but the props must be a regular [JSON-serializable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) JavaScript object.
The `TLBaseShape` helper adds the other default properties of a shape, such as `parentId`, `x`, `y`, and `rotation`.
### Shape Util
While tldraw's shapes themselves are simple JSON objects, we use `ShapeUtil` classes to answer questions about shapes. For example, when the editor needs to know the bounding box of our card shape, it will find a `ShapeUtil` for the `card` type and call that util's `bounds` method, passing in the `CardShape` object as an argument.
Let's create a `ShapeUtil` class for the shape.
```tsx
import { ShapeUtil, HTMLContainer } from '@tldraw/tldraw'
class CardShapeUtil extends ShapeUtil<CardShape> {
static type = 'card' as const
getDefaultProps(): CardShape['props'] {
return {
w: 100,
h: 100,
}
}
getBounds(shape: Shape) {
return new Box2d(0, 0, shape.props.w, shape.props.h)
}
component(shape: Shape) {
return (
<HTMLContainer>Hello</HTMLContainer>
)
}
indicator(shape: Shape) {
return (
<rect width={shape.props.w} height={shape.props.h}/>
)
}
}
```
This is a minimal `ShapeUtil`. We've given it a static property `type` that matches the type of our shape, we've provided implementations for the abstract methods `getDefaultProps`, `getBounds`, `component`, and `indicator`.
We still have work to do on the `CardShapeUtil` class, but we'll come back to it later. For now, let's put the shape onto the canvas by passing it to the `<Tldraw>` component.
### Defining the shape
Before we pass the shape down, we need to package it up in a way using the `defineShape` function. We can then create an array of our defined shapes and pass them into the `<Tldraw>` component's `shapes` prop.
```tsx
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
const MyCardShape = defineShape('card', { util: CardShapeUtil })
const MyCustomShapes = [MyCardShape]
export default function () {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw shapes={MyCustomShapes}/>
</div>
)
}
```
The `defineShape` function can also be used to include a tool that we can use to create this type of shape. For now, let's create it using the `Editor` API.
```tsx
export default function () {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw shapes={MyCustomShapes} onMount={editor => {
editor.createShapes([{ type: "card" }])
}}/>
</div>
)
}
```
Once the page refreshes, we should now have our custom shape on the canvas.
## Using starter shapes
You can use "starter" shape utils like `BaseBoxShapeUtil` to get regular rectangular shape behavior.
> todo
## Flags
You can use flags like `hideRotateHandle` to hide different parts of the UI when the shape is selected, or else to control different behaviors of the shape.
> todo
## Interaction
You can turn on `pointer-events` to allow users to interact inside of the shape.
> todo
## Editing
You can make shapes "editable" to help decide when they're interactive or not.
> todo

View file

@ -10,8 +10,8 @@ import {
} from '@tldraw/tldraw'
import { T } from '@tldraw/validate'
// Define a style that can be used across multiple shapes. The ID (myApp:filter) must be globally
// unique, so we recommend prefixing it with a namespace.
// Define a style that can be used across multiple shapes.
// The ID (myApp:filter) must be globally unique, so we recommend prefixing it with a namespace.
export const MyFilterStyle = StyleProp.defineEnum('myApp:filter', {
defaultValue: 'none',
values: ['none', 'invert', 'grayscale', 'blur'],
@ -30,16 +30,13 @@ export type CardShape = TLBaseShape<
>
export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
// Id — the shape util's id
static override type = 'card' as const
// Flags — there are a LOT of other flags!
override isAspectRatioLocked = (_shape: CardShape) => false
override canResize = (_shape: CardShape) => true
override canBind = (_shape: CardShape) => true
// Default props — used for shapes created with the tool
override defaultProps(): CardShape['props'] {
override getDefaultProps(): CardShape['props'] {
return {
w: 300,
h: 300,
@ -48,9 +45,8 @@ export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
}
}
// The React component that will be rendered for the shape; can return any HTML elements here
component(shape: CardShape) {
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
return (
<HTMLContainer
@ -71,7 +67,7 @@ export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
)
}
// The indicator shown when hovering over a shape or when it's selected; must return only SVG elements here
// Indicator — used when hovering over a shape or when it's selected; must return only SVG elements here
indicator(shape: CardShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}

View file

@ -1,8 +1,8 @@
import { TLUiMenuGroup, Tldraw, menuItem, toolbarItem, useEditor } from '@tldraw/tldraw'
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { TLUiOverrides } from '@tldraw/ui/src/lib/overrides'
import { track } from 'signia-react'
import { CardShape, MyFilterStyle } from './CardShape'
import { CardShape } from './CardShape'
import { FilterStyleUi } from './FilterStyleUi'
import { uiOverrides } from './ui-overrides'
const shapes = [CardShape]
@ -13,72 +13,10 @@ export default function CustomStylesExample() {
autoFocus
persistenceKey="custom-styles-example"
shapes={shapes}
overrides={cardToolMenuItems}
overrides={uiOverrides}
>
<FilterStyleUi />
</Tldraw>
</div>
)
}
const FilterStyleUi = track(function FilterStyleUi() {
const editor = useEditor()
const filterStyle = editor.sharedStyles.get(MyFilterStyle)
// if the filter style isn't in sharedStyles, it means it's not relevant to the current tool/selection
if (!filterStyle) return null
return (
<div style={{ position: 'absolute', zIndex: 300, top: 64, left: 12 }}>
filter:{' '}
<select
value={filterStyle.type === 'mixed' ? 'mixed' : filterStyle.value}
onChange={(e) => editor.setStyle(MyFilterStyle, e.target.value)}
>
<option value="mixed" disabled>
Mixed
</option>
<option value="none">None</option>
<option value="invert">Invert</option>
<option value="grayscale">Grayscale</option>
<option value="blur">Blur</option>
</select>
</div>
)
})
const cardToolMenuItems: TLUiOverrides = {
// In order for our custom tool to show up in the UI...
// We need to add it to the tools list. This "toolItem"
// has information about its icon, label, keyboard shortcut,
// and what to do when it's selected.
tools(editor, tools) {
tools.card = {
id: 'card',
icon: 'color',
label: 'Card' as any,
kbd: 'c',
readonlyOk: false,
onSelect: () => {
editor.setSelectedTool('card')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
// The toolbar is an array of items. We can add it to the
// end of the array or splice it in, then return the array.
toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar
},
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
// Same for the keyboard shortcuts menu, but this menu contains
// both items and groups. We want to find the "Tools" group and
// add it to that before returning the array.
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.card))
return keyboardShortcutsMenu
},
}

View file

@ -0,0 +1,29 @@
import { useEditor } from '@tldraw/tldraw'
import { track } from 'signia-react'
import { MyFilterStyle } from './CardShape'
export const FilterStyleUi = track(function FilterStyleUi() {
const editor = useEditor()
const filterStyle = editor.sharedStyles.get(MyFilterStyle)
// if the filter style isn't in sharedStyles, it means it's not relevant to the current tool/selection
if (!filterStyle) return null
return (
<div style={{ position: 'absolute', zIndex: 300, top: 64, left: 12 }}>
filter:{' '}
<select
value={filterStyle.type === 'mixed' ? 'mixed' : filterStyle.value}
onChange={(e) => editor.setStyle(MyFilterStyle, e.target.value)}
>
<option value="mixed" disabled>
Mixed
</option>
<option value="none">None</option>
<option value="invert">Invert</option>
<option value="grayscale">Grayscale</option>
<option value="blur">Blur</option>
</select>
</div>
)
})

View file

@ -0,0 +1,28 @@
import { TLUiMenuGroup, TLUiOverrides, menuItem, toolbarItem } from '@tldraw/tldraw'
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
tools.card = {
id: 'card',
icon: 'color',
label: 'Card' as any,
kbd: 'c',
readonlyOk: false,
onSelect: () => {
editor.setSelectedTool('card')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar
},
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.card))
return keyboardShortcutsMenu
},
}

View file

@ -1,71 +0,0 @@
import {
BaseBoxShapeTool,
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
defineShape,
} from '@tldraw/tldraw'
export type CardShape = TLBaseShape<
'card',
{
w: number
h: number
}
>
export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
// Id — the shape util's id
static override type = 'card' as const
// Flags — there are a LOT of other flags!
override isAspectRatioLocked = (_shape: CardShape) => false
override canResize = (_shape: CardShape) => true
override canBind = (_shape: CardShape) => true
// Default props — used for shapes created with the tool
override defaultProps(): CardShape['props'] {
return {
w: 300,
h: 300,
}
}
// The React component that will be rendered for the shape; can return any HTML elements here
component(shape: CardShape) {
const bounds = this.bounds(shape)
return (
<HTMLContainer
id={shape.id}
style={{
border: '1px solid black',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
}}
>
{bounds.w.toFixed()}x{bounds.h.toFixed()}
</HTMLContainer>
)
}
// Indicator — used when hovering over a shape or when it's selected; must return only SVG elements here
indicator(shape: CardShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
// Extending the base box shape tool gives us a lot of functionality for free.
export class CardShapeTool extends BaseBoxShapeTool {
static override id = 'card'
static override initial = 'idle'
override shapeType = CardShapeUtil
}
export const CardShape = defineShape('card', {
util: CardShapeUtil,
tool: CardShapeTool,
})

View file

@ -0,0 +1,17 @@
import { defineShape } from '@tldraw/tldraw'
import { CardShapeTool } from './CardShapeTool'
import { CardShapeUtil } from './CardShapeUtil'
import { cardShapeMigrations } from './card-shape-migrations'
import { cardShapeProps } from './card-shape-props'
// A custom shape is a bundle of a shape util, a tool, and props
export const CardShape = defineShape('card', {
// A utility class
util: CardShapeUtil,
// A tool that is used to create and edit the shape (optional)
tool: CardShapeTool,
// A validation schema for the shape's props (optional)
props: cardShapeProps,
// Migrations for upgrading shapes (optional)
migrations: cardShapeMigrations,
})

View file

@ -0,0 +1,16 @@
import { BaseBoxShapeTool, TLClickEvent } from '@tldraw/tldraw'
import { CardShapeUtil } from './CardShapeUtil'
// A tool used to create our custom card shapes. Extending the base
// box shape tool gives us a lot of functionality for free.
export class CardShapeTool extends BaseBoxShapeTool {
static override id = 'card'
static override initial = 'idle'
override shapeType = CardShapeUtil
override onDoubleClick: TLClickEvent = (_info) => {
// you can handle events in handlers like this one;
// check the BaseBoxShapeTool source as an example
}
}

View file

@ -0,0 +1,61 @@
import { resizeBox } from '@tldraw/editor/src/lib/editor/shapes/shared/resizeBox'
import { Box2d, HTMLContainer, ShapeUtil, TLOnResizeHandler } from '@tldraw/tldraw'
import { ICardShape } from './card-shape-types'
// A utility class for the card shape. This is where you define
// the shape's behavior, how it renders (its component and
// indicator), and how it handles different events.
export class CardShapeUtil extends ShapeUtil<ICardShape> {
static override type = 'card' as const
// Flags
override isAspectRatioLocked = (_shape: ICardShape) => false
override canResize = (_shape: ICardShape) => true
override canBind = (_shape: ICardShape) => true
getDefaultProps(): ICardShape['props'] {
return {
w: 300,
h: 300,
color: 'black',
weight: 'regular',
}
}
getBounds(shape: ICardShape) {
return new Box2d(0, 0, shape.props.w, shape.props.h)
}
// Render method — the React component that will be rendered for the shape
component(shape: ICardShape) {
const bounds = this.editor.getBounds(shape)
return (
<HTMLContainer
id={shape.id}
style={{
border: '1px solid black',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
fontWeight: shape.props.weight,
color: `var(--palette-${shape.props.color})`,
}}
>
{bounds.w.toFixed()}x{bounds.h.toFixed()}
</HTMLContainer>
)
}
// Indicator — used when hovering over a shape or when it's selected; must return only SVG elements here
indicator(shape: ICardShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
// Events
override onResize: TLOnResizeHandler<ICardShape> = (shape, info) => {
return resizeBox(shape, info)
}
}

View file

@ -0,0 +1,21 @@
import { defineMigrations } from '@tldraw/tldraw'
// Migrations for the custom card shape (optional but very helpful)
export const cardShapeMigrations = defineMigrations({
currentVersion: 1,
migrators: {
1: {
// for example, removing a property from the shape
up(shape) {
const migratedUpShape = { ...shape }
delete migratedUpShape._somePropertyToRemove
return migratedUpShape
},
down(shape) {
const migratedDownShape = { ...shape }
migratedDownShape._somePropertyToRemove = 'some value'
return migratedDownShape
},
},
},
})

View file

@ -0,0 +1,17 @@
import { DefaultColorStyle, ShapeProps, StyleProp } from '@tldraw/tldraw'
import { T } from '@tldraw/validate'
import { ICardShape, IWeightStyle } from './card-shape-types'
export const WeightStyle = new StyleProp<IWeightStyle>(
'myApp:weight',
'regular',
T.literalEnum('regular', 'bold')
)
// Validation for our custom card shape's props, using our custom style + one of tldraw's default styles
export const cardShapeProps: ShapeProps<ICardShape> = {
w: T.number,
h: T.number,
color: DefaultColorStyle,
weight: WeightStyle,
}

View file

@ -0,0 +1,15 @@
import { TLBaseShape, TLDefaultColorStyle } from '@tldraw/tldraw'
// We'll have a custom style called weight
export type IWeightStyle = 'regular' | 'bold'
// A type for our custom card shape
export type ICardShape = TLBaseShape<
'card',
{
w: number
h: number
color: TLDefaultColorStyle
weight: IWeightStyle
}
>

View file

@ -1,50 +1,17 @@
import { TLUiMenuGroup, Tldraw, menuItem, toolbarItem } from '@tldraw/tldraw'
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { CardShape } from './CardShape'
const shapes = [CardShape]
import { customShapes } from './custom-shapes'
import { uiOverrides } from './ui-overrides'
export default function CustomConfigExample() {
return (
<div className="tldraw__editor">
<Tldraw
autoFocus
shapes={shapes}
overrides={{
// In order for our custom tool to show up in the UI...
// We need to add it to the tools list. This "toolItem"
// has information about its icon, label, keyboard shortcut,
// and what to do when it's selected.
tools(editor, tools) {
tools.card = {
id: 'card',
icon: 'color',
label: 'Card' as any,
kbd: 'c',
readonlyOk: false,
onSelect: () => {
editor.setSelectedTool('card')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
// The toolbar is an array of items. We can add it to the
// end of the array or splice it in, then return the array.
toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar
},
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
// Same for the keyboard shortcuts menu, but this menu contains
// both items and groups. We want to find the "Tools" group and
// add it to that before returning the array.
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.card))
return keyboardShortcutsMenu
},
}}
// Pass in the array of custom shape definitions
shapes={customShapes}
// Pass in any overrides to the user interface
overrides={uiOverrides}
/>
</div>
)

View file

@ -0,0 +1,3 @@
import { CardShape } from '../16-custom-styles/CardShape'
export const customShapes = [CardShape]

View file

@ -0,0 +1,33 @@
import { TLUiMenuGroup, TLUiOverrides, menuItem, toolbarItem } from '@tldraw/tldraw'
// In order to see select our custom shape tool, we need to add it to the ui.
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
// Create a tool item in the ui's context.
tools.card = {
id: 'card',
icon: 'color',
label: 'Card' as any,
kbd: 'c',
readonlyOk: false,
onSelect: () => {
editor.setSelectedTool('card')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
// Add the tool item from the context to the toolbar.
toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar
},
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
// Add the tool item from the context to the keyboard shortcuts dialog.
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.card))
return keyboardShortcutsMenu
},
}

View file

@ -6,7 +6,7 @@ export class ErrorShapeUtil extends BaseBoxShapeUtil<ErrorShape> {
static override type = 'error' as const
override type = 'error' as const
defaultProps() {
getDefaultProps() {
return { message: 'Error!', w: 100, h: 100 }
}
component(shape: ErrorShape) {

View file

@ -12,6 +12,7 @@ import { Box2dModel } from '@tldraw/tlschema';
import { Computed } from 'signia';
import { ComputedCache } from '@tldraw/store';
import { CubicSpline2d } from '@tldraw/primitives';
import { defineMigrations } from '@tldraw/store';
import { EASINGS } from '@tldraw/primitives';
import { EmbedDefinition } from '@tldraw/tlschema';
import { EventEmitter } from 'eventemitter3';
@ -111,15 +112,13 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// (undocumented)
component(shape: TLArrowShape): JSX.Element | null;
// (undocumented)
defaultProps(): TLArrowShape['props'];
// (undocumented)
getArrowInfo(shape: TLArrowShape): ArrowInfo | undefined;
// (undocumented)
getBounds(shape: TLArrowShape): Box2d;
// (undocumented)
getCenter(shape: TLArrowShape): Vec2d;
// (undocumented)
getEditingBounds: (shape: TLArrowShape) => Box2d;
getDefaultProps(): TLArrowShape['props'];
// (undocumented)
getHandles(shape: TLArrowShape): TLHandle[];
// (undocumented)
@ -205,7 +204,7 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
// (undocumented)
component(shape: TLBookmarkShape): JSX.Element;
// (undocumented)
defaultProps(): TLBookmarkShape['props'];
getDefaultProps(): TLBookmarkShape['props'];
// (undocumented)
hideSelectionBoundsBg: () => boolean;
// (undocumented)
@ -293,6 +292,8 @@ export const defaultShapes: readonly [TLShapeInfo<TLDrawShape>, TLShapeInfo<TLGe
// @public (undocumented)
export const defaultTools: TLStateNodeConstructor[];
export { defineMigrations }
// @public (undocumented)
export function defineShape<T extends TLUnknownShape>(type: T['type'], opts: Omit<TLShapeInfo<T>, 'type'>): TLShapeInfo<T>;
@ -313,14 +314,14 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
// (undocumented)
component(shape: TLDrawShape): JSX.Element;
// (undocumented)
defaultProps(): TLDrawShape['props'];
// (undocumented)
expandSelectionOutlinePx(shape: TLDrawShape): number;
// (undocumented)
getBounds(shape: TLDrawShape): Box2d;
// (undocumented)
getCenter(shape: TLDrawShape): Vec2d;
// (undocumented)
getDefaultProps(): TLDrawShape['props'];
// (undocumented)
getOutline(shape: TLDrawShape): Vec2d[];
// (undocumented)
hideResizeHandles: (shape: TLDrawShape) => boolean;
@ -450,20 +451,24 @@ export class Editor extends EventEmitter<TLEventMap> {
}[];
getAssetById(id: TLAssetId): TLAsset | undefined;
getAssetBySrc(src: string): TLBookmarkAsset | TLImageAsset | TLVideoAsset | undefined;
getBounds(shape: TLShape): Box2d;
getBoundsById(id: TLShapeId): Box2d | undefined;
getBounds<T extends TLShape>(shape: T): Box2d;
getBoundsById<T extends TLShape>(id: T['id']): Box2d | undefined;
getClipPathById(id: TLShapeId): string | undefined;
getContainer: () => HTMLElement;
getContent(ids?: TLShapeId[]): TLContent | undefined;
getDeltaInParentSpace(shape: TLShape, delta: VecLike): Vec2d;
getDeltaInShapeSpace(shape: TLShape, delta: VecLike): Vec2d;
getDroppingShape(point: VecLike, droppingShapes?: TLShape[]): TLUnknownShape | undefined;
getHandles<T extends TLShape>(shape: T): TLHandle[] | undefined;
getHandlesById<T extends TLShape>(id: T['id']): TLHandle[] | undefined;
getHighestIndexForParent(parentId: TLPageId | TLShapeId): string;
getMaskedPageBounds(shape: TLShape): Box2d | undefined;
getMaskedPageBoundsById(id: TLShapeId): Box2d | undefined;
getOutermostSelectableShape(shape: TLShape, filter?: (shape: TLShape) => boolean): TLShape;
getOutline(shape: TLShape): Vec2d[];
getOutline<T extends TLShape>(shape: T): Vec2d[];
getOutlineById(id: TLShapeId): Vec2d[];
getOutlineSegments<T extends TLShape>(shape: T): Vec2d[][];
getOutlineSegmentsById(id: TLShapeId): Vec2d[][];
getPageBounds(shape: TLShape): Box2d | undefined;
getPageBoundsById(id: TLShapeId): Box2d | undefined;
getPageById(id: TLPageId): TLPage | undefined;
@ -644,6 +649,7 @@ export class Editor extends EventEmitter<TLEventMap> {
setCurrentPageId(pageId: TLPageId, { stopFollowing }?: TLViewportOptions): this;
setCursor(cursor: Partial<TLCursor>): this;
setDarkMode(isDarkMode: boolean): this;
setDevicePixelRatio(dpr: number): this;
setEditingId(id: null | TLShapeId): this;
setErasingIds(ids?: TLShapeId[]): this;
setFocusLayer(next: null | TLShapeId): this;
@ -651,9 +657,9 @@ export class Editor extends EventEmitter<TLEventMap> {
setGridMode(isGridMode: boolean): this;
setHintingIds(ids: TLShapeId[]): this;
setHoveredId(id?: null | TLShapeId): this;
setInstancePageState(partial: Partial<TLInstancePageState>, ephemeral?: boolean): void;
setLocale(locale: string): void;
setOpacity(opacity: number, ephemeral?: boolean, squashing?: boolean): this;
setPageState(partial: Partial<TLInstancePageState>, ephemeral?: boolean): void;
setPenMode(isPenMode: boolean): this;
// @internal (undocumented)
setProjectName(name: string): void;
@ -685,7 +691,7 @@ export class Editor extends EventEmitter<TLEventMap> {
stopFollowingUser(): this;
readonly store: TLStore;
stretchShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[]): this;
textMeasure: TextManager;
readonly textMeasure: TextManager;
toggleLock(ids?: TLShapeId[]): this;
undo(): HistoryManager<this>;
ungroupShapes(ids?: TLShapeId[]): this;
@ -727,7 +733,7 @@ export class EmbedShapeUtil extends BaseBoxShapeUtil<TLEmbedShape> {
// (undocumented)
component(shape: TLEmbedShape): JSX.Element;
// (undocumented)
defaultProps(): TLEmbedShape['props'];
getDefaultProps(): TLEmbedShape['props'];
// (undocumented)
hideSelectionBoundsBg: TLShapeUtilFlag<TLEmbedShape>;
// (undocumented)
@ -792,7 +798,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// (undocumented)
component(shape: TLFrameShape): JSX.Element;
// (undocumented)
defaultProps(): TLFrameShape['props'];
getDefaultProps(): TLFrameShape['props'];
// (undocumented)
indicator(shape: TLFrameShape): JSX.Element;
// (undocumented)
@ -821,12 +827,12 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
// (undocumented)
component(shape: TLGeoShape): JSX.Element;
// (undocumented)
defaultProps(): TLGeoShape['props'];
// (undocumented)
getBounds(shape: TLGeoShape): Box2d;
// (undocumented)
getCenter(shape: TLGeoShape): Vec2d;
// (undocumented)
getDefaultProps(): TLGeoShape['props'];
// (undocumented)
getOutline(shape: TLGeoShape): Vec2d[];
// (undocumented)
hitTestLineSegment(shape: TLGeoShape, A: VecLike, B: VecLike): boolean;
@ -1041,12 +1047,12 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
// (undocumented)
component(shape: TLGroupShape): JSX.Element | null;
// (undocumented)
defaultProps(): TLGroupShape['props'];
// (undocumented)
getBounds(shape: TLGroupShape): Box2d;
// (undocumented)
getCenter(shape: TLGroupShape): Vec2d;
// (undocumented)
getDefaultProps(): TLGroupShape['props'];
// (undocumented)
getOutline(shape: TLGroupShape): Vec2d[];
// (undocumented)
hideSelectionBoundsBg: () => boolean;
@ -1082,9 +1088,9 @@ export const HighlightShape: TLShapeInfo<TLHighlightShape>;
// @public (undocumented)
export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
// (undocumented)
component(shape: TLHighlightShape): JSX.Element;
backgroundComponent(shape: TLHighlightShape): JSX.Element;
// (undocumented)
defaultProps(): TLHighlightShape['props'];
component(shape: TLHighlightShape): JSX.Element;
// (undocumented)
expandSelectionOutlinePx(shape: TLHighlightShape): number;
// (undocumented)
@ -1092,6 +1098,8 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
// (undocumented)
getCenter(shape: TLHighlightShape): Vec2d;
// (undocumented)
getDefaultProps(): TLHighlightShape['props'];
// (undocumented)
getOutline(shape: TLHighlightShape): Vec2d[];
// (undocumented)
hideResizeHandles: (shape: TLHighlightShape) => boolean;
@ -1110,8 +1118,6 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
// (undocumented)
onResize: TLOnResizeHandler<TLHighlightShape>;
// (undocumented)
renderBackground(shape: TLHighlightShape): JSX.Element;
// (undocumented)
toBackgroundSvg(shape: TLHighlightShape, font: string | undefined, colors: TLExportColors): SVGPathElement;
// (undocumented)
toSvg(shape: TLHighlightShape, _font: string | undefined, colors: TLExportColors): SVGPathElement;
@ -1135,7 +1141,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
// (undocumented)
component(shape: TLImageShape): JSX.Element;
// (undocumented)
defaultProps(): TLImageShape['props'];
getDefaultProps(): TLImageShape['props'];
// (undocumented)
indicator(shape: TLImageShape): JSX.Element | null;
// (undocumented)
@ -1182,11 +1188,9 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
// (undocumented)
component(shape: TLLineShape): JSX.Element | undefined;
// (undocumented)
defaultProps(): TLLineShape['props'];
// (undocumented)
getBounds(shape: TLLineShape): Box2d;
// (undocumented)
getCenter(shape: TLLineShape): Vec2d;
getDefaultProps(): TLLineShape['props'];
// (undocumented)
getHandles(shape: TLLineShape): TLHandle[];
// (undocumented)
@ -1650,12 +1654,12 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
// (undocumented)
component(shape: TLNoteShape): JSX.Element;
// (undocumented)
defaultProps(): TLNoteShape['props'];
// (undocumented)
getBounds(shape: TLNoteShape): Box2d;
// (undocumented)
getCenter(_shape: TLNoteShape): Vec2d;
// (undocumented)
getDefaultProps(): TLNoteShape['props'];
// (undocumented)
getHeight(shape: TLNoteShape): number;
// (undocumented)
getOutline(shape: TLNoteShape): Vec2d[];
@ -1820,7 +1824,8 @@ export function setUserPreferences(user: TLUserPreferences): void;
// @public (undocumented)
export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
constructor(editor: Editor, type: Shape['type'], styleProps: ReadonlyMap<StyleProp<unknown>, string>);
bounds(shape: Shape): Box2d;
// @internal
backgroundComponent?(shape: Shape): any;
canBind: <K>(_shape: Shape, _otherShape?: K | undefined) => boolean;
canCrop: TLShapeUtilFlag<Shape>;
canDropShapes(shape: Shape, shapes: TLShape[]): boolean;
@ -1832,20 +1837,18 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
canUnmount: TLShapeUtilFlag<Shape>;
center(shape: Shape): Vec2d;
abstract component(shape: Shape): any;
abstract defaultProps(): Shape['props'];
// (undocumented)
editor: Editor;
// @internal (undocumented)
expandSelectionOutlinePx(shape: Shape): number;
protected abstract getBounds(shape: Shape): Box2d;
abstract getCenter(shape: Shape): Vec2d;
getEditingBounds: (shape: Shape) => Box2d;
protected getHandles?(shape: Shape): TLHandle[];
protected abstract getOutline(shape: Shape): Vec2d[];
protected getOutlineSegments(shape: Shape): Vec2d[][];
abstract getBounds(shape: Shape): Box2d;
getCenter(shape: Shape): Vec2d;
abstract getDefaultProps(): Shape['props'];
getHandles?(shape: Shape): TLHandle[];
getOutline(shape: Shape): Vec2d[];
getOutlineSegments(shape: Shape): Vec2d[][];
// (undocumented)
getStyleIfExists<T>(style: StyleProp<T>, shape: Shape | TLShapePartial<Shape>): T | undefined;
handles(shape: Shape): TLHandle[];
// (undocumented)
hasStyle(style: StyleProp<unknown>): boolean;
hideResizeHandles: TLShapeUtilFlag<Shape>;
@ -1884,12 +1887,8 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
onTranslate?: TLOnTranslateHandler<Shape>;
onTranslateEnd?: TLOnTranslateEndHandler<Shape>;
onTranslateStart?: TLOnTranslateStartHandler<Shape>;
outline(shape: Shape): Vec2d[];
outlineSegments(shape: Shape): Vec2d[][];
// @internal
providesBackgroundForChildren(shape: Shape): boolean;
// @internal
renderBackground?(shape: Shape): any;
// (undocumented)
setStyleInPartial<T>(style: StyleProp<T>, shape: TLShapePartial<Shape>, value: T): TLShapePartial<Shape>;
snapPoints(shape: Shape): Vec2d[];
@ -2020,11 +2019,9 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
// (undocumented)
component(shape: TLTextShape): JSX.Element;
// (undocumented)
defaultProps(): TLTextShape['props'];
// (undocumented)
getBounds(shape: TLTextShape): Box2d;
// (undocumented)
getCenter(shape: TLTextShape): Vec2d;
getDefaultProps(): TLTextShape['props'];
// (undocumented)
getMinDimensions(shape: TLTextShape): {
height: number;
@ -2489,7 +2486,7 @@ export type TLOnHandleChangeHandler<T extends TLShape> = (shape: T, info: {
export type TLOnResizeEndHandler<T extends TLShape> = TLEventChangeHandler<T>;
// @public (undocumented)
export type TLOnResizeHandler<T extends TLShape> = (shape: T, info: TLResizeInfo<T>) => Partial<TLShapePartial<T>> | undefined | void;
export type TLOnResizeHandler<T extends TLShape> = (shape: T, info: TLResizeInfo<T>) => Omit<TLShapePartial<T>, 'id' | 'type'> | undefined | void;
// @public (undocumented)
export type TLOnResizeStartHandler<T extends TLShape> = TLEventStartHandler<T>;
@ -2763,7 +2760,7 @@ export class VideoShapeUtil extends BaseBoxShapeUtil<TLVideoShape> {
// (undocumented)
component(shape: TLVideoShape): JSX.Element;
// (undocumented)
defaultProps(): TLVideoShape['props'];
getDefaultProps(): TLVideoShape['props'];
// (undocumented)
indicator(shape: TLVideoShape): JSX.Element;
// (undocumented)

View file

@ -3,6 +3,7 @@
// eslint-disable-next-line local/no-export-star
export * from '@tldraw/indices'
export { defineMigrations } from '@tldraw/store'
// eslint-disable-next-line local/no-export-star
export * from '@tldraw/tlschema'
export { getHashForString } from '@tldraw/utils'

View file

@ -207,8 +207,7 @@ const HandlesWrapper = track(function HandlesWrapper() {
if (!(onlySelectedShape && shouldDisplayHandles)) return null
const util = editor.getShapeUtil(onlySelectedShape)
const handles = util.handles?.(onlySelectedShape)
const handles = editor.getHandles(onlySelectedShape)
if (!handles) return null

View file

@ -83,8 +83,7 @@ export const Shape = track(function Shape({
const shape = editor.getShapeById(id)
if (!shape) return null
const util = editor.getShapeUtil(shape)
const bounds = util.bounds(shape)
const bounds = editor.getBounds(shape)
setProperty('width', Math.ceil(bounds.width) + 'px')
setProperty('height', Math.ceil(bounds.height) + 'px')
},
@ -106,7 +105,7 @@ export const Shape = track(function Shape({
return (
<>
{util.renderBackground && (
{util.backgroundComponent && (
<div
ref={backgroundContainerRef}
className="tl-shape tl-shape-background"
@ -137,7 +136,7 @@ export const Shape = track(function Shape({
onPointerLeave={events.onPointerLeave}
>
{isCulled && util.canUnmount(shape) ? (
<CulledShape shape={shape} util={util} />
<CulledShape shape={shape} />
) : (
<OptionalErrorBoundary
fallback={ShapeErrorFallback ? (error) => <ShapeErrorFallback error={error} /> : null}
@ -168,14 +167,16 @@ const InnerShapeBackground = React.memo(
shape: T
util: ShapeUtil<T>
}) {
return useStateTracking('InnerShape:' + util.type, () => util.renderBackground?.(shape))
return useStateTracking('InnerShape:' + util.type, () => util.backgroundComponent?.(shape))
},
(prev, next) => prev.shape.props === next.shape.props
)
const CulledShape = React.memo(
function CulledShap<T extends TLShape>({ shape, util }: { shape: T; util: ShapeUtil<T> }) {
const bounds = util.bounds(shape)
function CulledShape<T extends TLShape>({ shape }: { shape: T }) {
const editor = useEditor()
const bounds = editor.getBounds(shape)
return (
<div
className="tl-shape__culled"

File diff suppressed because it is too large Load diff

View file

@ -1,68 +0,0 @@
import { atom } from 'signia'
import { Editor } from '../Editor'
type Offsets = {
top: number
left: number
bottom: number
right: number
}
const DEFAULT_OFFSETS = {
top: 10,
left: 10,
bottom: 10,
right: 10,
}
export function getActiveAreaScreenSpace(editor: Editor) {
const containerEl = editor.getContainer()
const el = containerEl.querySelector('*[data-tldraw-area="active-drawing"]')
const out = {
...DEFAULT_OFFSETS,
width: 0,
height: 0,
}
if (el && containerEl) {
const cBbbox = containerEl.getBoundingClientRect()
const bbox = el.getBoundingClientRect()
out.top = bbox.top
out.left = bbox.left
out.bottom = cBbbox.height - bbox.bottom
out.right = cBbbox.width - bbox.right
}
out.width = editor.viewportScreenBounds.width - out.left - out.right
out.height = editor.viewportScreenBounds.height - out.top - out.bottom
return out
}
export function getActiveAreaPageSpace(editor: Editor) {
const out = getActiveAreaScreenSpace(editor)
const z = editor.zoomLevel
out.left /= z
out.right /= z
out.top /= z
out.bottom /= z
out.width /= z
out.height /= z
return out
}
export class ActiveAreaManager {
constructor(public editor: Editor) {
window.addEventListener('resize', this.updateOffsets)
this.editor.disposables.add(this.dispose)
}
offsets = atom<Offsets>('activeAreaOffsets', DEFAULT_OFFSETS)
updateOffsets = () => {
const offsets = getActiveAreaPageSpace(this.editor)
this.offsets.set(offsets)
}
// Clear the listener
dispose = () => {
window.addEventListener('resize', this.updateOffsets)
}
}

View file

@ -1,45 +0,0 @@
import { atom } from 'signia'
import { Editor } from '../Editor'
export class DprManager {
private _currentMM: MediaQueryList | undefined
constructor(public editor: Editor) {
this.rebind()
// Add this class's dispose method (cancel the listener) to the app's disposables
this.editor.disposables.add(this.dispose)
}
// Set a listener to update the dpr when the device pixel ratio changes
rebind() {
this.dispose()
this._currentMM = this.getMedia()
this._currentMM?.addEventListener('change', this.updateDevicePixelRatio)
}
dpr = atom<number>(
'devicePixelRatio',
typeof window === 'undefined' ? 1 : window.devicePixelRatio
)
// Get the media query list for the device pixel ratio
getMedia() {
// NOTE: This ignore is only for the test environment.
/* @ts-ignore */
if (window.matchMedia) {
return matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
}
}
// Update the device pixel ratio atom
updateDevicePixelRatio = () => {
this.dpr.set(window.devicePixelRatio)
this.rebind()
}
// Clear the listener
dispose = () => {
this._currentMM?.removeEventListener('change', this.updateDevicePixelRatio)
}
}

View file

@ -235,7 +235,7 @@ export class ExternalContentManager {
const p =
point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.viewportPageCenter)
const defaultProps = editor.getShapeUtil(TextShapeUtil).defaultProps()
const defaultProps = editor.getShapeUtil(TextShapeUtil).getDefaultProps()
const textToPaste = stripTrailingWhitespace(
stripCommonMinimumIndentation(replaceTabsWithSpaces(text))

View file

@ -17,15 +17,15 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
}
override getOutline(shape: Shape) {
return this.bounds(shape).corners
return this.editor.getBounds(shape).corners
}
hitTestPoint(shape: Shape, point: VecLike): boolean {
return pointInPolygon(point, this.outline(shape))
override hitTestPoint(shape: Shape, point: VecLike): boolean {
return pointInPolygon(point, this.editor.getOutline(shape))
}
hitTestLineSegment(shape: Shape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
override hitTestLineSegment(shape: Shape, A: VecLike, B: VecLike): boolean {
const outline = this.editor.getOutline(shape)
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
@ -36,7 +36,7 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
return false
}
onResize: TLOnResizeHandler<any> = (shape, info) => {
override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info)
}
}

View file

@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Box2d, linesIntersect, Vec2d, VecLike } from '@tldraw/primitives'
import { ComputedCache } from '@tldraw/store'
import { StyleProp, TLHandle, TLShape, TLShapePartial, TLUnknownShape } from '@tldraw/tlschema'
import { computed, EMPTY_ARRAY } from 'signia'
import type { Editor } from '../Editor'
import { TLResizeHandle } from '../types/selection-types'
import { TLExportColors } from './shared/TLExportColors'
@ -67,6 +65,29 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*/
static type: string
/**
* Get the default props for a shape.
*
* @public
*/
abstract getDefaultProps(): Shape['props']
/**
* Get a JSX element for the shape (as an HTML element).
*
* @param shape - The shape.
* @public
*/
abstract component(shape: Shape): any
/**
* Get JSX describing the shape's indicator (as an SVG element).
*
* @param shape - The shape.
* @public
*/
abstract indicator(shape: Shape): any
/**
* Whether the shape can be snapped to by another shape.
*
@ -118,14 +139,16 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
canCrop: TLShapeUtilFlag<Shape> = () => false
/**
* Bounds of the shape to edit.
* Does this shape provide a background for its children? If this is true,
* then any children with a `renderBackground` method will have their
* backgrounds rendered _above_ this shape. Otherwise, the children's
* backgrounds will be rendered above either the next ancestor that provides
* a background, or the canvas background.
*
* Note: this could be a text area within a shape for example arrow labels.
*
* @public
* @internal
*/
getEditingBounds = (shape: Shape) => {
return this.bounds(shape)
providesBackgroundForChildren(shape: Shape): boolean {
return false
}
/**
@ -170,36 +193,13 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*/
isAspectRatioLocked: TLShapeUtilFlag<Shape> = () => false
/**
* Get the default props for a shape.
*
* @public
*/
abstract defaultProps(): Shape['props']
/**
* Get a JSX element for the shape (as an HTML element).
*
* @param shape - The shape.
* @public
*/
abstract component(shape: Shape): any
/**
* Get JSX describing the shape's indicator (as an SVG element).
*
* @param shape - The shape.
* @public
*/
abstract indicator(shape: Shape): any
/**
* Get a JSX element for the shape (as an HTML element) to be rendered as part of the canvas background - behind any other shape content.
*
* @param shape - The shape.
* @internal
*/
renderBackground?(shape: Shape): any
backgroundComponent?(shape: Shape): any
/**
* Get an array of handle models for the shape. This is an optional method.
@ -213,25 +213,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @param shape - The shape.
* @public
*/
protected getHandles?(shape: Shape): TLHandle[]
@computed
private get handlesCache(): ComputedCache<TLHandle[], TLShape> {
return this.editor.store.createComputedCache('handles:' + this.type, (shape) => {
return this.getHandles!(shape as any)
})
}
/**
* Get the cached handles (this should not be overridden!)
*
* @param shape - The shape.
* @public
*/
handles(shape: Shape): TLHandle[] {
if (!this.getHandles) return EMPTY_ARRAY
return this.handlesCache.get(shape.id) ?? EMPTY_ARRAY
}
getHandles?(shape: Shape): TLHandle[]
/**
* Get an array of outline segments for the shape. For most shapes,
@ -248,26 +230,8 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @param shape - The shape.
* @public
*/
protected getOutlineSegments(shape: Shape): Vec2d[][] {
return [this.outline(shape)]
}
@computed
private get outlineSegmentsCache(): ComputedCache<Vec2d[][], TLShape> {
return this.editor.store.createComputedCache('outline-segments:' + this.type, (shape) => {
return this.getOutlineSegments!(shape as any)
})
}
/**
* Get the cached outline segments (this should not be overridden!)
*
* @param shape - The shape.
* @public
*/
outlineSegments(shape: Shape): Vec2d[][] {
if (!this.getOutlineSegments) return EMPTY_ARRAY
return this.outlineSegmentsCache.get(shape.id) ?? EMPTY_ARRAY
getOutlineSegments(shape: Shape): Vec2d[][] {
return [this.editor.getOutline(shape)]
}
/**
@ -276,52 +240,16 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @param shape - The shape.
* @public
*/
protected abstract getBounds(shape: Shape): Box2d
@computed
private get boundsCache(): ComputedCache<Box2d, TLShape> {
return this.editor.store.createComputedCache('bounds:' + this.type, (shape) => {
return this.getBounds(shape as any)
})
}
abstract getBounds(shape: Shape): Box2d
/**
* Get the cached bounds for the shape.
* Get the shape's (not cached) outline.
*
* @param shape - The shape.
* @public
*/
bounds(shape: Shape): Box2d {
const result = this.boundsCache.get(shape.id) ?? new Box2d()
if (result.width === 0 || result.height === 0) {
return new Box2d(result.x, result.y, Math.max(result.width, 1), Math.max(result.height, 1))
}
return result
}
/**
* Get the shape's (not cached) outline. Do not override this method!
*
* @param shape - The shape.
* @public
*/
protected abstract getOutline(shape: Shape): Vec2d[]
@computed
private get outlineCache(): ComputedCache<Vec2d[], TLShape> {
return this.editor.store.createComputedCache('outline:' + this.type, (shape) => {
return this.getOutline(shape as any)
})
}
/**
* Get the shape's outline. Do not override this method!
*
* @param shape - The shape.
* @public
*/
outline(shape: Shape): Vec2d[] {
return this.outlineCache.get(shape.id) ?? EMPTY_ARRAY
getOutline(shape: Shape): Vec2d[] {
return this.editor.getBounds(shape).corners
}
/**
@ -331,7 +259,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @public
*/
snapPoints(shape: Shape) {
return this.bounds(shape).snapPoints
return this.editor.getBounds(shape).snapPoints
}
/**
@ -350,7 +278,9 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @param shape - The shape.
* @public
*/
abstract getCenter(shape: Shape): Vec2d
getCenter(shape: Shape) {
return this.editor.getBounds(shape).center
}
/**
* Get whether the shape can receive children of a given type.
@ -403,6 +333,11 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
colors: TLExportColors
): SVGElement | Promise<SVGElement> | null
/** @internal */
expandSelectionOutlinePx(shape: Shape): number {
return 0
}
/**
* Get whether a point intersects the shape.
*
@ -412,7 +347,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @public
*/
hitTestPoint(shape: Shape, point: VecLike): boolean {
return this.bounds(shape).containsPoint(point)
return this.editor.getBounds(shape).containsPoint(point)
}
/**
@ -425,7 +360,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* @public
*/
hitTestLineSegment(shape: Shape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
@ -436,24 +371,6 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
return false
}
/** @internal */
expandSelectionOutlinePx(shape: Shape): number {
return 0
}
/**
* Does this shape provide a background for its children? If this is true,
* then any children with a `renderBackground` method will have their
* backgrounds rendered _above_ this shape. Otherwise, the children's
* backgrounds will be rendered above either the next ancestor that provides
* a background, or the canvas background.
*
* @internal
*/
providesBackgroundForChildren(shape: Shape): boolean {
return false
}
// Events
/**
@ -745,7 +662,7 @@ export type TLResizeInfo<T extends TLShape> = {
export type TLOnResizeHandler<T extends TLShape> = (
shape: T,
info: TLResizeInfo<T>
) => Partial<TLShapePartial<T>> | undefined | void
) => Omit<TLShapePartial<T>, 'id' | 'type'> | undefined | void
/** @public */
export type TLOnResizeStartHandler<T extends TLShape> = TLEventStartHandler<T>

View file

@ -73,7 +73,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
override hideSelectionBoundsFg: TLShapeUtilFlag<TLArrowShape> = () => true
override hideSelectionBoundsBg: TLShapeUtilFlag<TLArrowShape> = () => true
override defaultProps(): TLArrowShape['props'] {
override getDefaultProps(): TLArrowShape['props'] {
return {
dash: 'draw',
size: 'm',
@ -91,7 +91,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
}
getCenter(shape: TLArrowShape): Vec2d {
return this.bounds(shape).center
return this.editor.getBounds(shape).center
}
getBounds(shape: TLArrowShape) {
@ -292,7 +292,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
if (util.isClosed(hitShape)) {
// Test the polygon
return pointInPolygon(pointInTargetSpace, util.outline(hitShape))
return pointInPolygon(pointInTargetSpace, this.editor.getOutline(hitShape))
}
// Test the point using the shape's idea of what a hit is
@ -533,7 +533,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
}
hitTestPoint(shape: TLArrowShape, point: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
const zoomLevel = this.editor.zoomLevel
const offsetDist = STROKE_SIZES[shape.props.size] / zoomLevel
@ -548,7 +548,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
}
hitTestLineSegment(shape: TLArrowShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
for (let i = 0; i < outline.length - 1; i++) {
const C = outline[i]
@ -571,7 +571,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
) && !this.editor.isReadOnly
const info = this.getArrowInfo(shape)
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
const labelSize = this.getLabelBounds(shape)
// eslint-disable-next-line react-hooks/rules-of-hooks
@ -750,7 +750,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape)
const info = this.getArrowInfo(shape)
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
const labelSize = this.getLabelBounds(shape)
if (!info) return null
@ -844,7 +844,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
@computed get labelBoundsCache(): ComputedCache<Box2d | null, TLArrowShape> {
return this.editor.store.createComputedCache('labelBoundsCache', (shape) => {
const info = this.getArrowInfo(shape)
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
const { text, font, size } = shape.props
if (!info) return null
@ -901,10 +901,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
return this.labelBoundsCache.get(shape.id) || null
}
getEditingBounds = (shape: TLArrowShape): Box2d => {
return this.getLabelBounds(shape) ?? new Box2d()
}
onEditEnd: TLOnEditEndHandler<TLArrowShape> = (shape) => {
const {
id,
@ -941,7 +937,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// Arrowhead end path
const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth)
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
const labelSize = this.getLabelBounds(shape)
const maskId = (shape.id + '_clip').replace(':', '_')

View file

@ -74,7 +74,7 @@ export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBen
const endInStartShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, endInPageSpace)
const centerInStartShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, centerInPageSpace)
const isClosed = startShapeInfo.util.isClosed(startShapeInfo.shape)
const { isClosed } = startShapeInfo
const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline
let point: VecLike | undefined
@ -82,7 +82,7 @@ export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBen
let intersections = fn(
centerInStartShapeLocalSpace,
handleArc.radius,
startShapeInfo.util.outline(startShapeInfo.shape)
editor.getOutline(startShapeInfo.shape)
)
if (intersections) {
@ -150,7 +150,7 @@ export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBen
const endInEndShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, endInPageSpace)
const centerInEndShapeLocalSpace = Matrix2d.applyToPoint(inverseTransform, centerInPageSpace)
const isClosed = endShapeInfo.util.isClosed(endShapeInfo.shape)
const isClosed = endShapeInfo.isClosed
const fn = isClosed ? intersectCirclePolygon : intersectCirclePolyline
const angleToMiddle = Vec2d.Angle(handleArc.center, middle)
@ -162,7 +162,7 @@ export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBen
let intersections = fn(
centerInEndShapeLocalSpace,
handleArc.radius,
endShapeInfo.util.outline(endShapeInfo.shape)
editor.getOutline(endShapeInfo.shape)
)
if (intersections) {

View file

@ -1,7 +1,6 @@
import { Matrix2d, Vec2d } from '@tldraw/primitives'
import { TLArrowShape, TLArrowShapeTerminal, TLShape } from '@tldraw/tlschema'
import { Editor } from '../../../Editor'
import { ShapeUtil } from '../../ShapeUtil'
export function getIsArrowStraight(shape: TLArrowShape) {
return Math.abs(shape.props.bend) < 8 // snap to +-8px
@ -9,10 +8,11 @@ export function getIsArrowStraight(shape: TLArrowShape) {
export type BoundShapeInfo<T extends TLShape = TLShape> = {
shape: T
util: ShapeUtil<T>
didIntersect: boolean
isExact: boolean
isClosed: boolean
transform: Matrix2d
outline: Vec2d[]
}
export function getBoundShapeInfoForTerminal(
@ -29,10 +29,11 @@ export function getBoundShapeInfoForTerminal(
return {
shape,
util,
transform,
isClosed: util.isClosed(shape),
isExact: terminal.isExact,
didIntersect: false,
outline: editor.getOutline(shape),
}
}

View file

@ -67,14 +67,14 @@ export function getStraightArrowInfo(editor: Editor, shape: TLArrowShape): Arrow
if (endShapeInfo.didIntersect && !startShapeInfo.didIntersect) {
// ...and if only the end shape intersected, then make it
// a short arrow ending at the end shape intersection.
if (startShapeInfo.util.isClosed(startShapeInfo.shape)) {
if (startShapeInfo.isClosed) {
a.setTo(Vec2d.Nudge(b, a, minDist))
}
} else if (!endShapeInfo.didIntersect) {
// ...and if only the end shape intersected, or if neither
// shape intersected, then make it a short arrow starting
// at the start shape intersection.
if (endShapeInfo.util.isClosed(endShapeInfo.shape)) {
if (endShapeInfo.isClosed) {
b.setTo(Vec2d.Nudge(a, b, minDist))
}
}
@ -179,10 +179,10 @@ function updateArrowheadPointWithBoundShape(
const targetFrom = Matrix2d.applyToPoint(Matrix2d.Inverse(targetShapeInfo.transform), pageFrom)
const targetTo = Matrix2d.applyToPoint(Matrix2d.Inverse(targetShapeInfo.transform), pageTo)
const isClosed = targetShapeInfo.util.isClosed(targetShapeInfo.shape)
const isClosed = targetShapeInfo.isClosed
const fn = isClosed ? intersectLineSegmentPolygon : intersectLineSegmentPolyline
const intersection = fn(targetFrom, targetTo, targetShapeInfo.util.outline(targetShapeInfo.shape))
const intersection = fn(targetFrom, targetTo, targetShapeInfo.outline)
let targetInt: VecLike | undefined

View file

@ -47,7 +47,7 @@ export class Pointing extends StateNode {
const shape = this.editor.getShapeById<TLArrowShape>(id)
if (!shape) return
const handles = util.handles?.(shape)
const handles = this.editor.getHandles(shape)
if (handles) {
// start precise
@ -82,8 +82,7 @@ export class Pointing extends StateNode {
if (!this.shape) return
if (this.editor.inputs.isDragging) {
const util = this.editor.getShapeUtil(this.shape)
const handles = util.handles?.(this.shape)
const handles = this.editor.getHandles(this.shape)
if (!handles) {
this.editor.bailToMark('creating')
@ -96,8 +95,6 @@ export class Pointing extends StateNode {
if (!shape) return
const handles = util.handles(shape)
if (handles) {
const { x, y } = this.editor.getPointInShapeSpace(
shape,

View file

@ -23,7 +23,7 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
override hideSelectionBoundsBg = () => true
override hideSelectionBoundsFg = () => true
override defaultProps(): TLBookmarkShape['props'] {
override getDefaultProps(): TLBookmarkShape['props'] {
return {
url: '',
w: 300,

View file

@ -30,7 +30,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
hideSelectionBoundsBg = (shape: TLDrawShape) => getIsDot(shape)
hideSelectionBoundsFg = (shape: TLDrawShape) => getIsDot(shape)
override defaultProps(): TLDrawShape['props'] {
override getDefaultProps(): TLDrawShape['props'] {
return {
segments: [],
color: 'black',
@ -46,7 +46,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
isClosed = (shape: TLDrawShape) => shape.props.isClosed
getBounds(shape: TLDrawShape) {
return Box2d.FromPoints(this.outline(shape))
return Box2d.FromPoints(this.editor.getOutline(shape))
}
getOutline(shape: TLDrawShape) {
@ -54,11 +54,11 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
}
getCenter(shape: TLDrawShape): Vec2d {
return this.bounds(shape).center
return this.editor.getBounds(shape).center
}
hitTestPoint(shape: TLDrawShape, point: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
const zoomLevel = this.editor.zoomLevel
const offsetDist = STROKE_SIZES[shape.props.size] / zoomLevel
@ -72,7 +72,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
return pointInPolygon(point, outline)
}
if (this.bounds(shape).containsPoint(point)) {
if (this.editor.getBounds(shape).containsPoint(point)) {
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
const D = outline[(i + 1) % outline.length]
@ -85,7 +85,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
}
hitTestLineSegment(shape: TLDrawShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
if (shape.props.segments.length === 1 && shape.props.segments[0].points.length < 4) {
const zoomLevel = this.editor.zoomLevel

View file

@ -38,7 +38,7 @@ export class EmbedShapeUtil extends BaseBoxShapeUtil<TLEmbedShape> {
return !!getEmbedInfo(shape.props.url)?.definition?.doesResize
}
override defaultProps(): TLEmbedShape['props'] {
override getDefaultProps(): TLEmbedShape['props'] {
return {
w: 300,
h: 300,

View file

@ -18,12 +18,12 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
override canEdit = () => true
override defaultProps(): TLFrameShape['props'] {
override getDefaultProps(): TLFrameShape['props'] {
return { w: 160 * 2, h: 90 * 2, name: '' }
}
override component(shape: TLFrameShape) {
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
return (
<>
@ -137,7 +137,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
}
indicator(shape: TLFrameShape) {
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
return (
<rect

View file

@ -50,7 +50,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
canEdit = () => true
override defaultProps(): TLGeoShape['props'] {
override getDefaultProps(): TLGeoShape['props'] {
return {
w: 100,
h: 100,
@ -70,7 +70,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
}
hitTestLineSegment(shape: TLGeoShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
// Check the outline
for (let i = 0; i < outline.length; i++) {
@ -91,7 +91,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
}
hitTestPoint(shape: TLGeoShape, point: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
if (shape.props.fill === 'none') {
const zoomLevel = this.editor.zoomLevel
@ -397,7 +397,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
break
}
default: {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
const lines = getLines(shape.props, strokeWidth)
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
@ -479,7 +479,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
}
default: {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
let path: string
if (props.dash === 'draw' && !forceSolid) {
@ -591,7 +591,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
break
}
default: {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
const lines = getLines(shape.props, strokeWidth)
switch (props.dash) {
@ -635,7 +635,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
}
if (props.text) {
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
const rootTextElm = getTextLabelSvgElement({
editor: this.editor,

View file

@ -15,7 +15,7 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
canBind = () => false
defaultProps(): TLGroupShape['props'] {
getDefaultProps(): TLGroupShape['props'] {
return {}
}
@ -36,11 +36,11 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
}
getCenter(shape: TLGroupShape): Vec2d {
return this.bounds(shape).center
return this.editor.getBounds(shape).center
}
getOutline(shape: TLGroupShape): Vec2d[] {
return this.bounds(shape).corners
return this.editor.getBounds(shape).corners
}
component(shape: TLGroupShape) {
@ -71,7 +71,7 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
return null
}
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
return (
<SVGContainer id={shape.id}>
@ -86,7 +86,7 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
camera: { z: zoomLevel },
} = this.editor
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
return <DashedOutlineBox className="" bounds={bounds} zoomLevel={zoomLevel} />
}

View file

@ -22,7 +22,7 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
hideSelectionBoundsBg = (shape: TLHighlightShape) => getIsDot(shape)
hideSelectionBoundsFg = (shape: TLHighlightShape) => getIsDot(shape)
override defaultProps(): TLHighlightShape['props'] {
override getDefaultProps(): TLHighlightShape['props'] {
return {
segments: [],
color: 'black',
@ -33,7 +33,7 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
}
getBounds(shape: TLHighlightShape) {
return Box2d.FromPoints(this.outline(shape))
return Box2d.FromPoints(this.editor.getOutline(shape))
}
getOutline(shape: TLHighlightShape) {
@ -41,11 +41,11 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
}
getCenter(shape: TLHighlightShape): Vec2d {
return this.bounds(shape).center
return this.editor.getBounds(shape).center
}
hitTestPoint(shape: TLHighlightShape, point: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
const zoomLevel = this.editor.zoomLevel
const offsetDist = getStrokeWidth(shape) / zoomLevel
@ -55,7 +55,7 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
}
}
if (this.bounds(shape).containsPoint(point)) {
if (this.editor.getBounds(shape).containsPoint(point)) {
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
const D = outline[(i + 1) % outline.length]
@ -68,7 +68,7 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
}
hitTestLineSegment(shape: TLHighlightShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
const outline = this.editor.getOutline(shape)
if (shape.props.segments.length === 1 && shape.props.segments[0].points.length < 4) {
const zoomLevel = this.editor.zoomLevel
@ -102,7 +102,7 @@ export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
)
}
renderBackground(shape: TLHighlightShape) {
backgroundComponent(shape: TLHighlightShape) {
return (
<HighlightRenderer
strokeWidth={getStrokeWidth(shape)}

View file

@ -54,7 +54,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
override isAspectRatioLocked = () => true
override canCrop = () => true
override defaultProps(): TLImageShape['props'] {
override getDefaultProps(): TLImageShape['props'] {
return {
w: 100,
h: 100,

View file

@ -35,7 +35,7 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
override hideSelectionBoundsFg = () => true
override isClosed = () => false
override defaultProps(): TLLineShape['props'] {
override getDefaultProps(): TLLineShape['props'] {
return {
dash: 'draw',
size: 'm',
@ -68,10 +68,6 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
return spline.bounds
}
getCenter(shape: TLLineShape) {
return this.bounds(shape).center
}
getHandles(shape: TLLineShape) {
return handlesCache.get(shape.props, () => {
const handles = shape.props.handles
@ -174,11 +170,11 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
hitTestPoint(shape: TLLineShape, point: Vec2d): boolean {
const zoomLevel = this.editor.zoomLevel
const offsetDist = STROKE_SIZES[shape.props.size] / zoomLevel
return pointNearToPolyline(point, this.outline(shape), offsetDist)
return pointNearToPolyline(point, this.editor.getOutline(shape), offsetDist)
}
hitTestLineSegment(shape: TLLineShape, A: VecLike, B: VecLike): boolean {
return intersectLineSegmentPolyline(A, B, this.outline(shape)) !== null
return intersectLineSegmentPolyline(A, B, this.editor.getOutline(shape)) !== null
}
component(shape: TLLineShape) {

View file

@ -29,7 +29,8 @@ export class Pointing extends StateNode {
// if user is holding shift then we are adding points to an existing line
if (inputs.shiftKey && shapeExists) {
const handles = this.editor.getShapeUtil(this.shape).handles(this.shape)
const handles = this.editor.getHandles(this.shape)
if (!handles) return
const vertexHandles = handles.filter((h) => h.type === 'vertex').sort(sortByIndex)
const endHandle = vertexHandles[vertexHandles.length - 1]
@ -96,8 +97,7 @@ export class Pointing extends StateNode {
if (!this.shape) return
if (this.editor.inputs.isDragging) {
const util = this.editor.getShapeUtil(this.shape)
const handles = util.handles?.(this.shape)
const handles = this.editor.getHandles(this.shape)
if (!handles) {
this.editor.bailToMark('creating')
throw Error('No handles found')

View file

@ -19,7 +19,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
hideSelectionBoundsBg = () => true
hideSelectionBoundsFg = () => true
defaultProps(): TLNoteShape['props'] {
getDefaultProps(): TLNoteShape['props'] {
return {
color: 'black',
size: 'm',
@ -42,7 +42,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
}
getOutline(shape: TLNoteShape) {
return this.bounds(shape).corners
return this.editor.getBounds(shape).corners
}
getCenter(_shape: TLNoteShape) {
@ -106,7 +106,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
}
toSvg(shape: TLNoteShape, font: string, colors: TLExportColors) {
const bounds = this.bounds(shape)
const bounds = this.getBounds(shape)
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')

View file

@ -1,5 +1,4 @@
import { TLNoteShape, createShapeId } from '@tldraw/tlschema'
import { NoteShapeUtil } from '../../../shapes/note/NoteShapeUtil'
import { StateNode } from '../../../tools/StateNode'
import { TLEventHandlers, TLInterruptEvent, TLPointerEventInfo } from '../../../types/event-types'
@ -97,9 +96,8 @@ export class Pointing extends StateNode {
true
)
const util = this.editor.getShapeUtil(NoteShapeUtil)
const shape = this.editor.getShapeById<TLNoteShape>(id)!
const bounds = util.bounds(shape)
const bounds = this.editor.getBounds(shape)
// Center the text around the created point
this.editor.updateShapes([

View file

@ -24,7 +24,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
isAspectRatioLocked: TLShapeUtilFlag<TLTextShape> = () => true
defaultProps(): TLTextShape['props'] {
getDefaultProps(): TLTextShape['props'] {
return {
color: 'black',
size: 'm',
@ -63,7 +63,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
}
getOutline(shape: TLTextShape) {
const bounds = this.bounds(shape)
const bounds = this.editor.getBounds(shape)
return [
new Vec2d(0, 0),
@ -73,11 +73,6 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
]
}
getCenter(shape: TLTextShape): Vec2d {
const bounds = this.bounds(shape)
return new Vec2d(bounds.width / 2, bounds.height / 2)
}
component(shape: TLTextShape) {
const {
id,
@ -150,12 +145,12 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
}
indicator(shape: TLTextShape) {
const bounds = this.bounds(shape)
const bounds = this.getBounds(shape)
return <rect width={toDomPrecision(bounds.width)} height={toDomPrecision(bounds.height)} />
}
toSvg(shape: TLTextShape, font: string | undefined, colors: TLExportColors) {
const bounds = this.bounds(shape)
const bounds = this.getBounds(shape)
const text = shape.props.text
const width = bounds.width / (shape.props.scale ?? 1)
@ -204,7 +199,11 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
const { initialBounds, initialShape, scaleX, handle } = info
if (info.mode === 'scale_shape' || (handle !== 'right' && handle !== 'left')) {
return resizeScaled(shape, info)
return {
id: shape.id,
type: shape.type,
...resizeScaled(shape, info),
}
} else {
const prevWidth = initialBounds.width
let nextWidth = prevWidth * scaleX
@ -227,6 +226,8 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
const { x, y } = offset.rot(shape.rotation).add(initialShape)
return {
id: shape.id,
type: shape.type,
x,
y,
props: {

View file

@ -16,7 +16,7 @@ export class VideoShapeUtil extends BaseBoxShapeUtil<TLVideoShape> {
override canEdit = () => true
override isAspectRatioLocked = () => true
override defaultProps(): TLVideoShape['props'] {
override getDefaultProps(): TLVideoShape['props'] {
return {
w: 100,
h: 100,

View file

@ -94,7 +94,7 @@ export class Pointing extends StateNode {
])
const shape = this.editor.getShapeById<TLBaseBoxShape>(id)!
const { w, h } = this.editor.getShapeUtil(shape).defaultProps() as TLBaseBoxShape['props']
const { w, h } = this.editor.getShapeUtil(shape).getDefaultProps() as TLBaseBoxShape['props']
const delta = this.editor.getDeltaInParentSpace(shape, new Vec2d(w / 2, h / 2))
this.editor.updateShapes<TLBaseBoxShape>([

View file

@ -5,6 +5,7 @@ let editor: TestEditor
const ids = {
box1: createShapeId('box1'),
line1: createShapeId('line1'),
embed1: createShapeId('embed1'),
}
@ -135,7 +136,8 @@ describe('PointingHandle', () => {
describe('DraggingHandle', () => {
it('Enters from pointing_handle and exits to idle', () => {
const shape = editor.getShapeById(ids.box1)
editor.createShapes([{ id: ids.line1, type: 'line', x: 100, y: 100 }])
const shape = editor.getShapeById(ids.line1)
editor.pointerDown(150, 150, {
target: 'handle',
shape,
@ -149,7 +151,8 @@ describe('DraggingHandle', () => {
})
it('Bails on escape', () => {
const shape = editor.getShapeById(ids.box1)
editor.createShapes([{ id: ids.line1, type: 'line', x: 100, y: 100 }])
const shape = editor.getShapeById(ids.line1)
editor.pointerDown(150, 150, {
target: 'handle',

View file

@ -58,7 +58,7 @@ export class DraggingHandle extends StateNode {
this.editor.setCursor({ type: isCreating ? 'cross' : 'grabbing', rotation: 0 })
// <!-- Only relevant to arrows
const handles = this.editor.getShapeUtil(shape).handles(shape).sort(sortByIndex)
const handles = this.editor.getHandles(shape)!.sort(sortByIndex)
const index = handles.findIndex((h) => h.id === info.handle.id)
// Find the adjacent handle
@ -227,14 +227,14 @@ export class DraggingHandle extends StateNode {
// Get all the outline segments from the shape
const additionalSegments = util
.outlineSegments(shape)
.getOutlineSegments(shape)
.map((segment) => Matrix2d.applyToPoints(pageTransform, segment))
// We want to skip the segments that include the handle, so
// find the index of the handle that shares the same index property
// as the initial dragging handle; this catches a quirk of create handles
const handleIndex = util
.handles(shape)
const handleIndex = editor
.getHandles(shape)!
.filter(({ type }) => type === 'vertex')
.sort(sortByIndex)
.findIndex(({ index }) => initialHandle.index === index)

View file

@ -405,7 +405,7 @@ export class Resizing extends StateNode {
return {
shape,
bounds: util.bounds(shape),
bounds: this.editor.getBounds(shape),
pageTransform,
pageRotation: Matrix2d.Decompose(pageTransform!).rotation,
isAspectRatioLocked: util.isAspectRatioLocked(shape),

View file

@ -11,6 +11,21 @@ export function useDocumentEvents() {
const isAppFocused = useValue('isFocused', () => editor.isFocused, [editor])
useEffect(() => {
if (typeof matchMedia !== undefined) return
function updateDevicePixelRatio() {
editor.setDevicePixelRatio(window.devicePixelRatio)
}
const MM = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
MM.addEventListener('change', updateDevicePixelRatio)
return () => {
MM.removeEventListener('change', updateDevicePixelRatio)
}
}, [editor])
useEffect(() => {
if (!isAppFocused) return

View file

@ -7,8 +7,7 @@ import { useEditor } from './useEditor'
function getHandle(editor: Editor, id: TLShapeId, handleId: string) {
const shape = editor.getShapeById<TLArrowShape | TLLineShape>(id)!
const util = editor.getShapeUtil(shape)
const handles = util.handles(shape)
const handles = editor.getHandles(shape)!
return { shape, handle: handles.find((h) => h.id === handleId) }
}

View file

@ -455,7 +455,7 @@ describe('getShapeUtil', () => {
class MyFakeShapeUtil extends BaseBoxShapeUtil<any> {
static type = 'fake'
defaultProps() {
getDefaultProps() {
throw new Error('Method not implemented.')
}
component() {
@ -475,7 +475,7 @@ describe('getShapeUtil', () => {
class MyFakeGeoShapeUtil extends BaseBoxShapeUtil<any> {
static type = 'geo'
defaultProps() {
getDefaultProps() {
throw new Error('Method not implemented.')
}
component() {

View file

@ -275,7 +275,7 @@ describe('Custom shapes', () => {
override canResize = (_shape: CardShape) => true
override canBind = (_shape: CardShape) => true
override defaultProps(): CardShape['props'] {
override getDefaultProps(): CardShape['props'] {
return {
w: 300,
h: 300,
@ -283,7 +283,7 @@ describe('Custom shapes', () => {
}
component(shape: CardShape) {
const bounds = this.bounds(shape)
const bounds = this.getBounds(shape)
return (
<HTMLContainer

View file

@ -33,7 +33,7 @@ describe('creating frames', () => {
editor.setSelectedTool('frame')
editor.pointerDown(100, 100).pointerUp(100, 100)
expect(editor.onlySelectedShape?.type).toBe('frame')
const { w, h } = editor.getShapeUtil(FrameShapeUtil).defaultProps()
const { w, h } = editor.getShapeUtil(FrameShapeUtil).getDefaultProps()
expect(editor.getPageBounds(editor.onlySelectedShape!)).toMatchObject({
x: 100 - w / 2,
y: 100 - h / 2,

View file

@ -11,7 +11,6 @@ import {
} from '@tldraw/tlschema'
import { assert, compact } from '@tldraw/utils'
import { ArrowShapeTool } from '../../editor/shapes/arrow/ArrowShapeTool'
import { ArrowShapeUtil } from '../../editor/shapes/arrow/ArrowShapeUtil'
import { DrawShapeTool } from '../../editor/shapes/draw/DrawShapeTool'
import { GroupShapeUtil } from '../../editor/shapes/group/GroupShapeUtil'
import { LineShapeTool } from '../../editor/shapes/line/LineShapeTool'
@ -1679,10 +1678,7 @@ describe('moving handles within a group', () => {
editor.pointerDown(60, 60, {
target: 'handle',
shape: arrow,
handle: editor
.getShapeUtil(ArrowShapeUtil)
.handles(arrow)
.find((h) => h.id === 'end'),
handle: editor.getHandles<TLArrowShape>(arrow)!.find((h) => h.id === 'end'),
})
editor.expectToBeIn('select.pointing_handle')

View file

@ -14,7 +14,7 @@ type __TopLeftSnapOnlyShape = any
class __TopLeftSnapOnlyShapeUtil extends ShapeUtil<__TopLeftSnapOnlyShape> {
static override type = '__test_top_left_snap_only' as const
defaultProps(): __TopLeftSnapOnlyShape['props'] {
getDefaultProps(): __TopLeftSnapOnlyShape['props'] {
return { width: 10, height: 10 }
}
getBounds(shape: __TopLeftSnapOnlyShape): Box2d {

View file

@ -550,7 +550,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
y: bounds.minY + bounds.height * ny,
})
const handles = util.handles(v2ShapeFresh)
const handles = editor.getHandles(v2ShapeFresh)!
const change = util.onHandleChange!(v2ShapeFresh, {
handle: {

View file

@ -12,6 +12,7 @@ export function Tldraw(props: TldrawEditorProps & TldrawUiProps): JSX.Element;
export * from "@tldraw/editor";
export * from "@tldraw/primitives";
export * from "@tldraw/ui";
// (No @packageDocumentation comment for this package)

View file

@ -46,6 +46,8 @@
"dependencies": {
"@tldraw/editor": "workspace:*",
"@tldraw/polyfills": "workspace:*",
"@tldraw/primitives": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/ui": "workspace:*"
},
"peerDependencies": {

View file

@ -2,9 +2,10 @@
/** @internal */
import '@tldraw/polyfills'
// eslint-disable-next-line local/no-export-star
export * from '@tldraw/editor'
// eslint-disable-next-line local/no-export-star
export * from '@tldraw/primitives'
// eslint-disable-next-line local/no-export-star
export * from '@tldraw/ui'
export { Tldraw } from './lib/Tldraw'

View file

@ -706,7 +706,7 @@ export type ShapeProps<Shape extends TLBaseShape<any, any>> = {
// @public (undocumented)
export class StyleProp<Type> implements T.Validatable<Type> {
protected constructor(id: string, defaultValue: Type, type: T.Validatable<Type>);
constructor(id: string, defaultValue: Type, type: T.Validatable<Type>);
// (undocumented)
readonly defaultValue: Type;
// (undocumented)

View file

@ -16,7 +16,7 @@ export class StyleProp<Type> implements T.Validatable<Type> {
return new EnumStyleProp<Values[number]>(uniqueId, defaultValue, values)
}
protected constructor(
constructor(
readonly id: string,
readonly defaultValue: Type,
readonly type: T.Validatable<Type>

View file

@ -4626,6 +4626,8 @@ __metadata:
"@testing-library/react": ^14.0.0
"@tldraw/editor": "workspace:*"
"@tldraw/polyfills": "workspace:*"
"@tldraw/primitives": "workspace:*"
"@tldraw/store": "workspace:*"
"@tldraw/ui": "workspace:*"
chokidar-cli: ^3.0.0
jest-canvas-mock: ^2.4.0