Stickies: release candidate (#3249)

This PR is the target for the stickies PRs that are moving forward. It
should collect changes.

- [x] New icon
- [x] Improved shadows
- [x] Shadow LOD
- [x] New colors / theme options
- [x] Shrink text size to avoid word breaks on the x axis
- [x] Hide indicator whilst typing (reverted)
- [x] Adjacent note positions
  - [x] buttons / clone handles
  - [x] position helpers for creating / translating (pits)
- [x] keyboard shortcuts: (Tab, Shift+tab (RTL aware), Cmd-Enter,
Shift+Cmd+enter)
  - [x] multiple shape translating 
- [x] Text editing
  - [x] Edit on type (feature flagged)
  - [x] click goes in correct place
- [x] Notes as parents (reverted)
- [x] Update colors
- [x] Update SVG appearance

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `feature` — New feature

### Test Plan

Todo: fold in test plans for child PRs

### Unit tests:

- [ ] Shrink text size to avoid word breaks on the x axis
- [x] Adjacent notes
  - [x] buttons (clone handles)
  - [x] position helpers (pits)
- [x] keyboard shortcuts: (Tab, Shift+tab (RTL aware), Cmd-Enter,
Shift+Cmd+enter)
- [ ] Text editing
  - [ ] Edit on type
  - [ ] click goes in correct place

### Release Notes

- Improves sticky notes (see list)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
Co-authored-by: alex <alex@dytry.ch>
Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lu[ke] Wilson <l2wilson94@gmail.com>
Co-authored-by: huppy-bot[bot] <128400622+huppy-bot[bot]@users.noreply.github.com>
This commit is contained in:
Steve Ruiz 2024-04-14 19:40:02 +01:00 committed by GitHub
parent 8c6a9ff47e
commit 41601ac61e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 3514 additions and 784 deletions

View file

@ -6,13 +6,6 @@ const RELEASE_INFO = `${env} ${process.env.NEXT_PUBLIC_TLDRAW_RELEASE_INFO ?? 'u
export function DebugMenuItems() { export function DebugMenuItems() {
return ( return (
<TldrawUiMenuGroup id="release"> <TldrawUiMenuGroup id="release">
<TldrawUiMenuItem
id="release-info"
label={`Version ${RELEASE_INFO}`}
onSelect={() => {
window.alert(`${RELEASE_INFO}`)
}}
/>
<TldrawUiMenuItem <TldrawUiMenuItem
id="v1" id="v1"
label="Test v1 content" label="Test v1 content"
@ -22,6 +15,13 @@ export function DebugMenuItems() {
window.location.reload() window.location.reload()
}} }}
/> />
<TldrawUiMenuItem
id="release-info"
label={'Release info'}
onSelect={() => {
window.alert(`${RELEASE_INFO}`)
}}
/>
</TldrawUiMenuGroup> </TldrawUiMenuGroup>
) )
} }

View file

@ -41,9 +41,9 @@ export async function setupPageWithShapes(page: PlaywrightTestArgs['page']) {
await page.mouse.click(200, 250) await page.mouse.click(200, 250)
await page.keyboard.press('r') await page.keyboard.press('r')
await page.mouse.click(250, 300) await page.mouse.click(250, 300)
// deselect everything // deselect everything
await page.evaluate(() => editor.selectNone()) await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
} }
export async function cleanupPage(page: PlaywrightTestArgs['page']) { export async function cleanupPage(page: PlaywrightTestArgs['page']) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,5 +1,5 @@
import test, { Page, expect } from '@playwright/test' import test, { Page, expect } from '@playwright/test'
import { BoxModel, Editor } from 'tldraw' import { BoxModel, Editor, TLNoteShape, TLShapeId } from 'tldraw'
import { setupPage } from '../shared-e2e' import { setupPage } from '../shared-e2e'
export function sleep(ms: number) { export function sleep(ms: number) {
@ -242,4 +242,92 @@ test.describe('text measurement', () => {
expect(formatLines(spans)).toEqual([[' \n'], [' \n'], [' \n'], [' ']]) expect(formatLines(spans)).toEqual([[' \n'], [' \n'], [' \n'], [' ']])
}) })
test('for auto-font-sizing shapes, should do normal font size for text that does not have long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'this is just some regular text',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(32)
})
test('for auto-font-sizing shapes, should auto-size text that have slightly long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'Amsterdam',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(27)
})
test('for auto-font-sizing shapes, should auto-size text that have long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'this is a tentoonstelling',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(20)
})
test('for auto-font-sizing shapes, should wrap text that has words that are way too long', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'a very long dutch word like ziekenhuisinrichtingsmaatschappij',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(14)
})
}) })

View file

@ -0,0 +1,151 @@
import {
Circle2d,
Geometry2d,
HTMLContainer,
Rectangle2d,
ShapeUtil,
TLBaseShape,
TLShape,
Tldraw,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
// [1]
type MyGridShape = TLBaseShape<'my-grid-shape', Record<string, never>>
type MyCounterShape = TLBaseShape<'my-counter-shape', Record<string, never>>
// [2]
const SLOT_SIZE = 100
class MyCounterShapeUtil extends ShapeUtil<MyCounterShape> {
static override type = 'my-counter-shape' as const
override canResize = () => false
override hideResizeHandles = () => true
getDefaultProps(): MyCounterShape['props'] {
return {}
}
getGeometry(): Geometry2d {
return new Circle2d({ radius: SLOT_SIZE / 2 - 10, isFilled: true })
}
component() {
return (
<HTMLContainer
style={{
backgroundColor: '#e03131',
border: '1px solid #ff8787',
borderRadius: '50%',
}}
/>
)
}
indicator() {
return <circle r={SLOT_SIZE / 2 - 10} cx={SLOT_SIZE / 2 - 10} cy={SLOT_SIZE / 2 - 10} />
}
}
// [3]
class MyGridShapeUtil extends ShapeUtil<MyGridShape> {
static override type = 'my-grid-shape' as const
getDefaultProps(): MyGridShape['props'] {
return {}
}
getGeometry(): Geometry2d {
return new Rectangle2d({
width: SLOT_SIZE * 5,
height: SLOT_SIZE * 2,
isFilled: true,
})
}
override canResize = () => false
override hideResizeHandles = () => true
// [a]
override canDropShapes = (shape: MyGridShape, shapes: TLShape[]) => {
if (shapes.every((s) => s.type === 'my-counter-shape')) {
return true
}
return false
}
// [b]
override onDragShapesOver = (shape: MyGridShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === shape.id)) {
this.editor.reparentShapes(shapes, shape.id)
}
}
// [c]
override onDragShapesOut = (shape: MyGridShape, shapes: TLShape[]) => {
this.editor.reparentShapes(shapes, this.editor.getCurrentPageId())
}
component() {
return (
<HTMLContainer
style={{
backgroundColor: '#efefef',
borderRight: '1px solid #ccc',
borderBottom: '1px solid #ccc',
backgroundSize: `${SLOT_SIZE}px ${SLOT_SIZE}px`,
backgroundImage: `
linear-gradient(to right, #ccc 1px, transparent 1px),
linear-gradient(to bottom, #ccc 1px, transparent 1px)
`,
}}
/>
)
}
indicator() {
return <rect width={SLOT_SIZE * 5} height={SLOT_SIZE * 2} />
}
}
export default function DragAndDropExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={[MyGridShapeUtil, MyCounterShapeUtil]}
onMount={(editor) => {
editor.createShape({ type: 'my-grid-shape', x: 100, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 700, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 750, y: 200 })
editor.createShape({ type: 'my-counter-shape', x: 770, y: 300 })
}}
/>
</div>
)
}
/*
This example demonstrates how to use the drag-and-drop system.
[1] Define some shape types. For the purposes of this example, we'll define two
shapes: a grid and a counter.
[2] Make a shape util for the first shape. For this example, we'll make a simple
red circle that you drag and drop onto the other shape.
[3] Make the other shape util. In this example, we'll make a grid that you can
place the the circle counters onto.
[a] Use the `canDropShapes` method to specify which shapes can be dropped onto
the grid shape.
[b] Use the `onDragShapesOver` method to reparent counters to the grid shape
when they are dragged on top.
[c] Use the `onDragShapesOut` method to reparent counters back to the page
when they are dragged off.
*/

View file

@ -0,0 +1,12 @@
---
title: Drag and drop
component: ./DragAndDropExample.tsx
category: shapes/tools
priority: 1
---
Shapes that can be dragged and dropped onto each other.
---
You can create custom shapes that can be dragged and dropped onto each other.

View file

@ -1,4 +1,3 @@
import { ShapePropsType } from '@tldraw/tlschema/src/shapes/TLBaseShape'
import { import {
DefaultColorStyle, DefaultColorStyle,
DefaultFontStyle, DefaultFontStyle,
@ -9,6 +8,7 @@ import {
Geometry2d, Geometry2d,
LABEL_FONT_SIZES, LABEL_FONT_SIZES,
Polygon2d, Polygon2d,
ShapePropsType,
ShapeUtil, ShapeUtil,
T, T,
TEXT_PROPS, TEXT_PROPS,
@ -20,11 +20,11 @@ import {
TextLabel, TextLabel,
Vec, Vec,
ZERO_INDEX_KEY, ZERO_INDEX_KEY,
getDefaultColorTheme,
resizeBox, resizeBox,
structuredClone, structuredClone,
vecModelValidator, vecModelValidator,
} from 'tldraw' } from 'tldraw'
import { useDefaultColorTheme } from 'tldraw/src/lib/shapes/shared/ShapeFill'
import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers' import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers'
// Copied from tldraw/tldraw // Copied from tldraw/tldraw
@ -176,11 +176,11 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
type, type,
props: { color, font, size, align, text }, props: { color, font, size, align, text },
} = shape } = shape
const theme = getDefaultColorTheme({
isDarkMode: this.editor.user.getIsDarkMode(),
})
const vertices = getSpeechBubbleVertices(shape) const vertices = getSpeechBubbleVertices(shape)
const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z' const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z'
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
return ( return (
<> <>
@ -192,7 +192,6 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
fill={'none'} fill={'none'}
/> />
</svg> </svg>
<TextLabel <TextLabel
id={id} id={id}
type={type} type={type}
@ -202,7 +201,9 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
align={align} align={align}
verticalAlign="start" verticalAlign="start"
text={text} text={text}
labelColor={color} labelColor={theme[color].solid}
isSelected={isSelected}
disableTab
wrap wrap
/> />
</> </>

View file

@ -21,7 +21,7 @@ setDefaultEditorAssetUrls(assetUrls)
setDefaultUiAssetUrls(assetUrls) setDefaultUiAssetUrls(assetUrls)
const gettingStartedExamples = examples.find((e) => e.id === 'Getting started') const gettingStartedExamples = examples.find((e) => e.id === 'Getting started')
if (!gettingStartedExamples) throw new Error('Could not find getting started exmaples') if (!gettingStartedExamples) throw new Error('Could not find getting started exmaples')
const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Persistence key') const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Tldraw component')
if (!basicExample) throw new Error('Could not find initial example') if (!basicExample) throw new Error('Could not find initial example')
const router = createBrowserRouter([ const router = createBrowserRouter([

View file

@ -1,3 +1,4 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.8539 0.315423C24.2744 -0.105141 24.9563 -0.105141 25.3769 0.315423L29.6846 4.62312C30.1051 5.04368 30.1051 5.72555 29.6846 6.14612L21.1928 14.6379C21.0291 14.8016 20.84 14.9379 20.633 15.0414L12.1739 19.2709C11.7593 19.4782 11.2585 19.397 10.9308 19.0692C10.603 18.7414 10.5217 18.2407 10.729 17.8261L14.9586 9.367C15.0621 9.15995 15.1984 8.97093 15.362 8.80723L23.8539 0.315423ZM24.6154 2.59992L16.8851 10.3302L14.6488 14.8027L15.1973 15.3511L19.6697 13.1149L27.4001 5.38462L24.6154 2.59992ZM19.2308 2.15384L17.0769 4.30769H8.24617C7.32369 4.30769 6.69661 4.30853 6.2119 4.34813C5.73976 4.38671 5.4983 4.45663 5.32987 4.54245C4.9246 4.74894 4.5951 5.07844 4.38861 5.48371C4.30279 5.65214 4.23287 5.89359 4.19429 6.36573C4.15469 6.85044 4.15385 7.47753 4.15385 8.4V21.7538C4.15385 22.6763 4.15469 23.3034 4.19429 23.7881C4.23287 24.2603 4.30279 24.5017 4.38861 24.6701C4.5951 25.0754 4.9246 25.4049 5.32987 25.6114C5.4983 25.6972 5.73976 25.7671 6.2119 25.8057C6.69661 25.8453 7.32369 25.8462 8.24617 25.8462H21.6C22.5225 25.8462 23.1496 25.8453 23.6343 25.8057C24.1064 25.7671 24.3479 25.6972 24.5163 25.6114C24.9216 25.4049 25.2511 25.0754 25.4576 24.6701C25.5434 24.5017 25.6133 24.2603 25.6519 23.7881C25.6915 23.3034 25.6923 22.6763 25.6923 21.7538V12.923L27.8462 10.7692V21.7538V21.7983C27.8462 22.6652 27.8462 23.3807 27.7986 23.9635C27.7491 24.5688 27.643 25.1253 27.3767 25.648C26.9637 26.4585 26.3047 27.1175 25.4941 27.5305C24.9715 27.7968 24.415 27.9029 23.8097 27.9524C23.2269 28 22.5114 28 21.6445 28H21.6H8.24617H8.20166C7.33478 28 6.61932 28 6.0365 27.9524C5.43117 27.9029 4.87472 27.7968 4.35205 27.5305C3.5415 27.1175 2.88251 26.4585 2.46951 25.648C2.2032 25.1253 2.09705 24.5688 2.0476 23.9635C1.99998 23.3807 1.99999 22.6652 2 21.7984V21.7983V21.7538V8.4V8.35552V8.35546V8.35545C1.99999 7.48859 1.99998 6.77315 2.0476 6.19034C2.09705 5.585 2.2032 5.02855 2.46951 4.50589C2.88251 3.69534 3.5415 3.03635 4.35205 2.62336C4.87472 2.35704 5.43117 2.2509 6.0365 2.20144C6.61932 2.15382 7.33476 2.15383 8.20163 2.15384H8.20169H8.24617H19.2308Z" fill="black"/> <path d="M17 27V19C17 17.8954 17.8954 17 19 17H27" stroke="black" stroke-width="2"/>
<path d="M17.5789 26.45L26.3775 18.0914C26.775 17.7138 27 17.1896 27 16.6414V5C27 3.89543 26.1046 3 25 3H5C3.89543 3 3 3.89543 3 5V25C3 26.1046 3.89543 27 5 27H16.2014C16.7141 27 17.2072 26.8031 17.5789 26.45Z" stroke="black" stroke-width="2"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 433 B

View file

@ -436,7 +436,20 @@ export function dataUrlToFile(url: string, filename: string, mimeType: string):
export type DebugFlag<T> = DebugFlagDef<T> & Atom<T>; export type DebugFlag<T> = DebugFlagDef<T> & Atom<T>;
// @internal (undocumented) // @internal (undocumented)
export const debugFlags: Record<string, DebugFlag<boolean>>; export const debugFlags: {
readonly logPreventDefaults: DebugFlag<boolean>;
readonly logPointerCaptures: DebugFlag<boolean>;
readonly logElementRemoves: DebugFlag<boolean>;
readonly debugSvg: DebugFlag<boolean>;
readonly showFps: DebugFlag<boolean>;
readonly throwToBlob: DebugFlag<boolean>;
readonly reconnectOnPing: DebugFlag<boolean>;
readonly debugCursors: DebugFlag<boolean>;
readonly forceSrgb: DebugFlag<boolean>;
readonly debugGeometry: DebugFlag<boolean>;
readonly hideShapes: DebugFlag<boolean>;
readonly editOnType: DebugFlag<boolean>;
};
// @internal (undocumented) // @internal (undocumented)
export const DEFAULT_ANIMATION_OPTIONS: { export const DEFAULT_ANIMATION_OPTIONS: {
@ -700,6 +713,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getInstanceState(): TLInstance; getInstanceState(): TLInstance;
getIsMenuOpen(): boolean; getIsMenuOpen(): boolean;
getOnlySelectedShape(): null | TLShape; getOnlySelectedShape(): null | TLShape;
getOnlySelectedShapeId(): null | TLShapeId;
getOpenMenus(): string[]; getOpenMenus(): string[];
getOutermostSelectableShape(shape: TLShape | TLShapeId, filter?: (shape: TLShape) => boolean): TLShape; getOutermostSelectableShape(shape: TLShape | TLShapeId, filter?: (shape: TLShape) => boolean): TLShape;
getPage(page: TLPage | TLPageId): TLPage | undefined; getPage(page: TLPage | TLPageId): TLPage | undefined;
@ -812,7 +826,6 @@ export class Editor extends EventEmitter<TLEventMap> {
pointerVelocity: Vec; pointerVelocity: Vec;
}; };
interrupt(): this; interrupt(): this;
isAncestorSelected(shape: TLShape | TLShapeId): boolean;
isIn(path: string): boolean; isIn(path: string): boolean;
isInAny(...paths: string[]): boolean; isInAny(...paths: string[]): boolean;
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: { isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
@ -1444,6 +1457,9 @@ export class Polygon2d extends Polyline2d {
}); });
} }
// @public (undocumented)
export function polygonIntersectsPolyline(polygon: VecLike[], polyline: VecLike[]): boolean;
// @public (undocumented) // @public (undocumented)
export function polygonsIntersect(a: VecLike[], b: VecLike[]): boolean; export function polygonsIntersect(a: VecLike[], b: VecLike[]): boolean;
@ -1650,9 +1666,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
onDoubleClickEdge?: TLOnDoubleClickHandler<Shape>; onDoubleClickEdge?: TLOnDoubleClickHandler<Shape>;
onDoubleClickHandle?: TLOnDoubleClickHandleHandler<Shape>; onDoubleClickHandle?: TLOnDoubleClickHandleHandler<Shape>;
onDragShapesOut?: TLOnDragHandler<Shape>; onDragShapesOut?: TLOnDragHandler<Shape>;
onDragShapesOver?: TLOnDragHandler<Shape, { onDragShapesOver?: TLOnDragHandler<Shape>;
shouldHint: boolean;
}>;
onDropShapesOver?: TLOnDragHandler<Shape>; onDropShapesOver?: TLOnDragHandler<Shape>;
onEditEnd?: TLOnEditEndHandler<Shape>; onEditEnd?: TLOnEditEndHandler<Shape>;
onHandleDrag?: TLOnHandleDragHandler<Shape>; onHandleDrag?: TLOnHandleDragHandler<Shape>;
@ -1724,6 +1738,9 @@ export class SideEffectManager<CTX extends {
}>): () => void; }>): () => void;
} }
// @public (undocumented)
export const SIDES: readonly ["top", "right", "bottom", "left"];
export { Signal } export { Signal }
// @public (undocumented) // @public (undocumented)
@ -2187,6 +2204,10 @@ export interface TLEventMap {
count: number; count: number;
}]; }];
// (undocumented) // (undocumented)
'select-all-text': [{
shapeId: TLShapeId;
}];
// (undocumented)
'stop-camera-animation': []; 'stop-camera-animation': [];
// (undocumented) // (undocumented)
'stop-following': []; 'stop-following': [];

View file

@ -11297,6 +11297,42 @@
"isAbstract": false, "isAbstract": false,
"name": "getOnlySelectedShape" "name": "getOnlySelectedShape"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getOnlySelectedShapeId:member(1)",
"docComment": "/**\n * The id of the app's only selected shape.\n *\n * @returns Null if there is no shape or more than one selected shape, otherwise the selected shape's id.\n *\n * @public @readonly\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getOnlySelectedShapeId(): "
},
{
"kind": "Content",
"text": "null | "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getOnlySelectedShapeId"
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getOpenMenus:member(1)", "canonicalReference": "@tldraw/editor!Editor#getOpenMenus:member(1)",
@ -14601,64 +14637,6 @@
"isAbstract": false, "isAbstract": false,
"name": "interrupt" "name": "interrupt"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isAncestorSelected:member(1)",
"docComment": "/**\n * Determine whether or not any of a shape's ancestors are selected.\n *\n * @param id - The id of the shape to check.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "isAncestorSelected(shape: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": " | "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "isAncestorSelected"
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isIn:member(1)", "canonicalReference": "@tldraw/editor!Editor#isIn:member(1)",
@ -28086,6 +28064,77 @@
}, },
"implementsTokenRanges": [] "implementsTokenRanges": []
}, },
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!polygonIntersectsPolyline:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function polygonIntersectsPolyline(polygon: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ", polyline: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/primitives/intersect.ts",
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "polygon",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isOptional": false
},
{
"parameterName": "polyline",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 6
},
"isOptional": false
}
],
"name": "polygonIntersectsPolyline"
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/editor!polygonsIntersect:function(1)", "canonicalReference": "@tldraw/editor!polygonsIntersect:function(1)",
@ -31650,7 +31699,7 @@
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/editor!ShapeUtil#onDragShapesOver:member", "canonicalReference": "@tldraw/editor!ShapeUtil#onDragShapesOver:member",
"docComment": "/**\n * A callback called when some other shapes are dragged over this one.\n *\n * @param shape - The shape.\n *\n * @param shapes - The shapes that are being dragged over this one.\n *\n * @returns An object specifying whether the shape should hint that it can receive the dragged shapes.\n *\n * @example\n * ```ts\n * onDragShapesOver = (shape, shapes) => {\n * \treturn { shouldHint: true }\n * }\n * ```\n *\n * @public\n */\n", "docComment": "/**\n * A callback called when some other shapes are dragged over this one.\n *\n * @param shape - The shape.\n *\n * @param shapes - The shapes that are being dragged over this one.\n *\n * @example\n * ```ts\n * onDragShapesOver = (shape, shapes) => {\n * \tthis.editor.reparentShapes(shapes, shape.id)\n * }\n * ```\n *\n * @public\n */\n",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
@ -31663,7 +31712,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<Shape, {\n shouldHint: boolean;\n }>" "text": "<Shape>"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -33294,6 +33343,29 @@
], ],
"implementsTokenRanges": [] "implementsTokenRanges": []
}, },
{
"kind": "Variable",
"canonicalReference": "@tldraw/editor!SIDES:var",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "SIDES: "
},
{
"kind": "Content",
"text": "readonly [\"top\", \"right\", \"bottom\", \"left\"]"
}
],
"fileUrlPath": "packages/editor/src/lib/constants.ts",
"isReadonly": true,
"releaseTag": "Public",
"name": "SIDES",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/editor!SIN:function(1)", "canonicalReference": "@tldraw/editor!SIN:function(1)",
@ -38849,6 +38921,42 @@
"endIndex": 4 "endIndex": 4
} }
}, },
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLEventMap#\"select-all-text\":member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "'select-all-text': "
},
{
"kind": "Content",
"text": "[{\n shapeId: "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": ";\n }]"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "\"select-all-text\"",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
}
},
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLEventMap#\"stop-camera-animation\":member", "canonicalReference": "@tldraw/editor!TLEventMap#\"stop-camera-animation\":member",

View file

@ -30,6 +30,12 @@
--layer-overlays: 400; --layer-overlays: 400;
--layer-following-indicator: 1000; --layer-following-indicator: 1000;
--layer-blocker: 10000; --layer-blocker: 10000;
/* z index for text editors */
--layer-text-container: 1;
--layer-text-content: 3;
--layer-text-editor: 4;
/* Misc */ /* Misc */
--tl-zoom: 1; --tl-zoom: 1;
@ -549,19 +555,16 @@ input,
.tl-handle__create { .tl-handle__create {
opacity: 0; opacity: 0;
} }
.tl-handle__create:hover {
opacity: 1; .tl-handle__clone > .tl-handle__fg {
fill: var(--color-selection-stroke);
stroke: none;
} }
.tl-handle__bg:active { .tl-handle__bg:active {
fill: none; fill: none;
} }
.tl-handle__bg:hover {
cursor: var(--tl-cursor-grab);
fill: var(--color-selection-fill);
}
@media (pointer: coarse) { @media (pointer: coarse) {
.tl-handle__bg:active { .tl-handle__bg:active {
fill: var(--color-selection-fill); fill: var(--color-selection-fill);
@ -790,7 +793,6 @@ input,
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
pointer-events: all;
text-rendering: auto; text-rendering: auto;
text-transform: none; text-transform: none;
text-indent: 0px; text-indent: 0px;
@ -856,6 +858,12 @@ input,
cursor: var(--tl-cursor-text); cursor: var(--tl-cursor-text);
} }
.tl-text-wrapper[data-isediting='false'] .tl-text-input,
.tl-arrow-label[data-isediting='false'] .tl-text-input {
opacity: 0;
cursor: var(--tl-cursor-default);
}
.tl-text-input::selection { .tl-text-input::selection {
background: var(--color-selected); background: var(--color-selected);
color: var(--color-selected-contrast); color: var(--color-selected-contrast);
@ -967,10 +975,6 @@ input,
cursor: var(--tl-cursor-pointer); cursor: var(--tl-cursor-pointer);
} }
.tl-bookmark__link:hover {
color: var(--color-selected);
}
/* ---------------- Hyperlink Button ---------------- */ /* ---------------- Hyperlink Button ---------------- */
.tl-hyperlink-button { .tl-hyperlink-button {
@ -1009,10 +1013,6 @@ input,
pointer-events: none; pointer-events: none;
} }
.tl-hyperlink-button:hover {
color: var(--color-selected);
}
.tl-hyperlink-button:focus-visible { .tl-hyperlink-button:focus-visible {
color: var(--color-selected); color: var(--color-selected);
} }
@ -1053,6 +1053,11 @@ input,
pointer-events: all; pointer-events: all;
} }
.tl-text-wrapper .tl-text-content {
pointer-events: all;
z-index: var(--layer-text-content);
}
.tl-text-label__inner > .tl-text-content { .tl-text-label__inner > .tl-text-content {
position: relative; position: relative;
top: 0px; top: 0px;
@ -1062,7 +1067,6 @@ input,
width: fit-content; width: fit-content;
border-radius: var(--radius-1); border-radius: var(--radius-1);
max-width: 100%; max-width: 100%;
z-index: 3;
} }
.tl-text-label__inner > .tl-text-input { .tl-text-label__inner > .tl-text-input {
@ -1071,7 +1075,26 @@ input,
height: 100%; height: 100%;
width: 100%; width: 100%;
padding: 16px; padding: 16px;
z-index: 4; }
.tl-text-wrapper[data-isselected='true'] .tl-text-input {
z-index: var(--layer-text-editor);
pointer-events: all;
}
/* This part of the rule helps preserve the occlusion rules for the shapes so we
* don't click on shapes that are behind other shapes.
* One extra nuance is we don't use this behavior for:
* - arrows which have weird geometry and just gets in the way.
* - draw shapes, because it feels restrictive to have them be 'in the way' of clicking on a textfield
*/
.tl-canvas[data-iseditinganything='true']
.tl-shape:not([data-shape-type='arrow']):not([data-shape-type='draw']) {
pointer-events: all;
}
/* But, re-disable the pointer-events rule for the svg container. */
.tl-canvas[data-iseditinganything='true'] .tl-shape .tl-svg-container {
pointer-events: none;
} }
.tl-text-label[data-textwrap='true'] > .tl-text-label__inner { .tl-text-label[data-textwrap='true'] > .tl-text-label__inner {
@ -1125,7 +1148,7 @@ input,
position: relative; position: relative;
height: max-content; height: max-content;
width: max-content; width: max-content;
pointer-events: all; pointer-events: none;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -1134,13 +1157,11 @@ input,
.tl-arrow-label .tl-arrow { .tl-arrow-label .tl-arrow {
position: relative; position: relative;
height: max-content; height: max-content;
z-index: 2;
padding: 4px; padding: 4px;
overflow: visible; overflow: visible;
} }
.tl-arrow-label textarea { .tl-arrow-label textarea {
z-index: 3;
padding: 4px; padding: 4px;
/* Don't allow textarea to be zero width */ /* Don't allow textarea to be zero width */
min-width: 4px; min-width: 4px;
@ -1152,27 +1173,18 @@ input,
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: var(--radius-2); pointer-events: all;
box-shadow: var(--shadow-1); opacity: 1;
overflow: hidden; z-index: var(--layer-text-container);
border-color: currentColor; border-radius: 1px;
border-style: solid;
border-width: 1px;
} }
.tl-note__container .tl-text-label { .tl-note__container > .tl-text-label {
text-shadow: none; text-shadow: none;
color: currentColor;
} }
.tl-note__scrim { /* --------------------- Loading -------------------- */
position: absolute;
z-index: 1;
inset: 0px;
height: 100%;
width: 100%;
background-color: var(--color-background);
opacity: 0.28;
}
.tl-loading { .tl-loading {
background-color: var(--color-background); background-color: var(--color-background);
@ -1440,18 +1452,12 @@ it from receiving any pointer events or affecting the cursor. */
color: inherit; color: inherit;
background-color: transparent; background-color: transparent;
} }
.tl-error-boundary__content button:hover {
background-color: var(--color-low);
}
.tl-error-boundary__content a { .tl-error-boundary__content a {
color: var(--color-text-1); color: var(--color-text-1);
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
} }
.tl-error-boundary__content a:hover {
color: var(--color-text-1);
}
.tl-error-boundary__content__error { .tl-error-boundary__content__error {
position: relative; position: relative;
@ -1486,11 +1492,6 @@ it from receiving any pointer events or affecting the cursor. */
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-selected-contrast); color: var(--color-selected-contrast);
} }
.tl-error-boundary__content .tl-error-boundary__refresh:hover {
background-color: var(--color-primary);
opacity: 0.9;
}
/* --------------------- Coarse --------------------- */ /* --------------------- Coarse --------------------- */
.tl-hidden { .tl-hidden {
@ -1521,3 +1522,40 @@ it from receiving any pointer events or affecting the cursor. */
.tl-hit-test-blocker__hidden { .tl-hit-test-blocker__hidden {
display: none; display: none;
} }
@media (hover: hover) {
.tl-handle__create:hover {
opacity: 1;
}
.tl-handle__bg:hover {
cursor: var(--tl-cursor-grab);
fill: var(--color-selection-fill);
}
.tl-bookmark__link:hover {
color: var(--color-selected);
}
.tl-hyperlink-button:hover {
color: var(--color-selected);
}
.tl-error-boundary__content button:hover {
background-color: var(--color-low);
}
.tl-error-boundary__content a:hover {
color: var(--color-text-1);
}
.tl-error-boundary__content .tl-error-boundary__refresh:hover {
background-color: var(--color-primary);
opacity: 0.9;
}
/* These three rules help preserve clicking into specific points in text areas *while*
* already in edit mode when jumping from shape to shape. */
.tl-canvas[data-iseditinganything='true'] .tl-text-wrapper:hover .tl-text-input {
z-index: var(--layer-text-editor);
pointer-events: all;
}
}

View file

@ -121,6 +121,7 @@ export {
MAX_ZOOM, MAX_ZOOM,
MIN_ZOOM, MIN_ZOOM,
MULTI_CLICK_DURATION, MULTI_CLICK_DURATION,
SIDES,
SVG_PADDING, SVG_PADDING,
ZOOMS, ZOOMS,
} from './lib/constants' } from './lib/constants'
@ -296,6 +297,7 @@ export {
intersectPolygonBounds, intersectPolygonBounds,
intersectPolygonPolygon, intersectPolygonPolygon,
linesIntersect, linesIntersect,
polygonIntersectsPolyline,
polygonsIntersect, polygonsIntersect,
} from './lib/primitives/intersect' } from './lib/primitives/intersect'
export { export {

View file

@ -1,3 +1,4 @@
import classNames from 'classnames'
import * as React from 'react' import * as React from 'react'
/** @public */ /** @public */
@ -6,7 +7,7 @@ export type HTMLContainerProps = React.HTMLAttributes<HTMLDivElement>
/** @public */ /** @public */
export function HTMLContainer({ children, className = '', ...rest }: HTMLContainerProps) { export function HTMLContainer({ children, className = '', ...rest }: HTMLContainerProps) {
return ( return (
<div {...rest} className={`tl-html-container ${className}`}> <div {...rest} className={classNames('tl-html-container', className)}>
{children} {children}
</div> </div>
) )

View file

@ -1,3 +1,4 @@
import classNames from 'classnames'
import * as React from 'react' import * as React from 'react'
/** @public */ /** @public */
@ -6,7 +7,7 @@ export type SVGContainerProps = React.HTMLAttributes<SVGElement>
/** @public */ /** @public */
export function SVGContainer({ children, className = '', ...rest }: SVGContainerProps) { export function SVGContainer({ children, className = '', ...rest }: SVGContainerProps) {
return ( return (
<svg {...rest} className={`tl-svg-container ${className}`}> <svg {...rest} className={classNames('tl-svg-container', className)}>
{children} {children}
</svg> </svg>
) )

View file

@ -116,11 +116,17 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
const debugGeometry = useValue('debug_geometry', () => debugFlags.debugGeometry.get(), [ const debugGeometry = useValue('debug_geometry', () => debugFlags.debugGeometry.get(), [
debugFlags, debugFlags,
]) ])
const isEditingAnything = useValue(
'isEditingAnything',
() => editor.getEditingShapeId() !== null,
[editor]
)
return ( return (
<div <div
ref={rCanvas} ref={rCanvas}
draggable={false} draggable={false}
data-iseditinganything={isEditingAnything}
className={classNames('tl-canvas', className)} className={classNames('tl-canvas', className)}
data-testid="canvas" data-testid="canvas"
{...events} {...events}
@ -559,7 +565,10 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) {
const isSingleFrame = editor.isShapeOfType(id, 'frame') const isSingleFrame = editor.isShapeOfType(id, 'frame')
const padding = isSingleFrame ? 0 : 10 const padding = isSingleFrame ? 0 : 10
const bounds = editor.getShapePageBounds(id)!.clone().expandBy(padding) let bounds = editor.getShapePageBounds(id)
if (!bounds) return
bounds = bounds.clone().expandBy(padding)
const result = await editor.getSvgString([id], { const result = await editor.getSvgString([id], {
padding, padding,
background: editor.getInstanceState().exportBackground, background: editor.getInstanceState().exportBackground,

View file

@ -1,6 +1,6 @@
import { TLHandle, TLShapeId } from '@tldraw/tlschema' import { TLHandle, TLShapeId } from '@tldraw/tlschema'
import classNames from 'classnames' import classNames from 'classnames'
import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS } from '../../constants' import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS, SIDES } from '../../constants'
/** @public */ /** @public */
export type TLHandleProps = { export type TLHandleProps = {
@ -13,22 +13,31 @@ export type TLHandleProps = {
/** @public */ /** @public */
export function DefaultHandle({ handle, isCoarse, className, zoom }: TLHandleProps) { export function DefaultHandle({ handle, isCoarse, className, zoom }: TLHandleProps) {
const bgRadius = (isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / zoom const br = (isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / zoom
const fgRadius = (handle.type === 'create' && isCoarse ? 3 : 4) / zoom
if (handle.type === 'clone') {
// bouba
const fr = 3 / Math.max(zoom, 0.35)
const path = `M0,${-fr} A${fr},${fr} 0 0,1 0,${fr}`
// kiki
// const fr = 4 / Math.max(zoom, 0.35)
// const path = `M0,${-fr} L${fr},0 L0,${fr} Z`
const index = SIDES.indexOf(handle.id as (typeof SIDES)[number])
return ( return (
<g <g className={classNames(`tl-handle tl-handle__${handle.type}`, className)}>
className={classNames( <circle className="tl-handle__bg" r={br} />
'tl-handle', {/* Half circle */}
{ <path className="tl-handle__fg" d={path} transform={`rotate(${-90 + 90 * index})`} />
'tl-handle__virtual': handle.type === 'virtual', </g>
'tl-handle__create': handle.type === 'create', )
}, }
className
)} const fr = (handle.type === 'create' && isCoarse ? 3 : 4) / Math.max(zoom, 0.35)
> return (
<circle className="tl-handle__bg" r={bgRadius} /> <g className={classNames(`tl-handle tl-handle__${handle.type}`, className)}>
<circle className="tl-handle__fg" r={fgRadius} /> <circle className="tl-handle__bg" r={br} />
<circle className="tl-handle__fg" r={fr} />
</g> </g>
) )
} }

View file

@ -105,6 +105,9 @@ export const COARSE_HANDLE_RADIUS = 20
/** @internal */ /** @internal */
export const HANDLE_RADIUS = 12 export const HANDLE_RADIUS = 12
/** @public */
export const SIDES = ['top', 'right', 'bottom', 'left'] as const
/** @internal */ /** @internal */
export const LONG_PRESS_DURATION = 500 export const LONG_PRESS_DURATION = 500

View file

@ -795,6 +795,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
undo(): this { undo(): this {
this._flushEventsForTick(0)
this.history.undo() this.history.undo()
return this return this
} }
@ -819,6 +820,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
redo(): this { redo(): this {
this._flushEventsForTick(0)
this.history.redo() this.history.redo()
return this return this
} }
@ -1503,21 +1505,6 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
) )
/**
* Determine whether or not any of a shape's ancestors are selected.
*
* @param id - The id of the shape to check.
*
* @public
*/
isAncestorSelected(shape: TLShape | TLShapeId): boolean {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
const _shape = this.getShape(id)
if (!_shape) return false
const selectedShapeIds = this.getSelectedShapeIds()
return !!this.findShapeAncestor(_shape, (parent) => selectedShapeIds.includes(parent.id))
}
/** /**
* Select one or more shapes. * Select one or more shapes.
* *
@ -1599,11 +1586,22 @@ export class Editor extends EventEmitter<TLEventMap> {
return this return this
} }
/**
* The id of the app's only selected shape.
*
* @returns Null if there is no shape or more than one selected shape, otherwise the selected shape's id.
*
* @public
* @readonly
*/
@computed getOnlySelectedShapeId(): TLShapeId | null {
return this.getOnlySelectedShape()?.id ?? null
}
/** /**
* The app's only selected shape. * The app's only selected shape.
* *
* @returns Null if there is no shape or more than one selected shape, otherwise the selected * @returns Null if there is no shape or more than one selected shape, otherwise the selected shape.
* shape.
* *
* @public * @public
* @readonly * @readonly
@ -3993,7 +3991,13 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */ /** @internal */
@computed private _getShapeMaskCache(): ComputedCache<Vec[], TLShape> { @computed private _getShapeMaskCache(): ComputedCache<Vec[], TLShape> {
return this.store.createComputedCache('pageMaskCache', (shape) => { return this.store.createComputedCache('pageMaskCache', (shape) => {
if (isPageId(shape.parentId)) return undefined // todo: Consider adding a flag for this hardcoded behaviour
if (
isPageId(shape.parentId) ||
shape.type === 'note' ||
this.findShapeAncestor(shape, (v) => v.type === 'note')
)
return undefined
const frameAncestors = this.getShapeAncestors(shape.id).filter((shape) => const frameAncestors = this.getShapeAncestors(shape.id).filter((shape) =>
this.isShapeOfType<TLFrameShape>(shape, 'frame') this.isShapeOfType<TLFrameShape>(shape, 'frame')
@ -4634,7 +4638,8 @@ export class Editor extends EventEmitter<TLEventMap> {
arg: TLUnknownShape | TLUnknownShape['id'], arg: TLUnknownShape | TLUnknownShape['id'],
type: T['type'] type: T['type']
) { ) {
const shape = typeof arg === 'string' ? this.getShape(arg)! : arg const shape = typeof arg === 'string' ? this.getShape(arg) : arg
if (!shape) return false
return shape.type === type return shape.type === type
} }
@ -4993,6 +4998,8 @@ export class Editor extends EventEmitter<TLEventMap> {
const shape = currentPageShapesSorted[i] const shape = currentPageShapesSorted[i]
if ( if (
// don't allow dropping on selected shapes
this.getSelectedShapeIds().includes(shape.id) ||
// only allow shapes that can receive children // only allow shapes that can receive children
!this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) || !this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) ||
// don't allow dropping a shape on itself or one of it's children // don't allow dropping a shape on itself or one of it's children
@ -8181,7 +8188,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const sx = info.point.x - screenBounds.x const sx = info.point.x - screenBounds.x
const sy = info.point.y - screenBounds.y const sy = info.point.y - screenBounds.y
const sz = info.point.z const sz = info.point.z ?? 0.5
previousScreenPoint.setTo(currentScreenPoint) previousScreenPoint.setTo(currentScreenPoint)
previousPagePoint.setTo(currentPagePoint) previousPagePoint.setTo(currentPagePoint)
@ -8191,7 +8198,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// it will be 0,0 when its actual screen position is equal // it will be 0,0 when its actual screen position is equal
// to screenBounds.point. This is confusing! // to screenBounds.point. This is confusing!
currentScreenPoint.set(sx, sy) currentScreenPoint.set(sx, sy)
currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz ?? 0.5) currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz)
this.inputs.isPen = info.type === 'pointer' && info.isPen this.inputs.isPen = info.type === 'pointer' && info.isPen

View file

@ -70,10 +70,11 @@ export class TextManager {
* space are preserved. * space are preserved.
*/ */
maxWidth: null | number maxWidth: null | number
minWidth?: string minWidth?: null | number
padding: string padding: string
disableOverflowWrapBreaking?: boolean
} }
): BoxModel => { ): BoxModel & { scrollWidth: number } => {
// Duplicate our base element; we don't need to clone deep // Duplicate our base element; we don't need to clone deep
const elm = this.baseElm?.cloneNode() as HTMLDivElement const elm = this.baseElm?.cloneNode() as HTMLDivElement
this.baseElm.insertAdjacentElement('afterend', elm) this.baseElm.insertAdjacentElement('afterend', elm)
@ -85,10 +86,15 @@ export class TextManager {
elm.style.setProperty('font-size', opts.fontSize + 'px') elm.style.setProperty('font-size', opts.fontSize + 'px')
elm.style.setProperty('line-height', opts.lineHeight * opts.fontSize + 'px') elm.style.setProperty('line-height', opts.lineHeight * opts.fontSize + 'px')
elm.style.setProperty('max-width', opts.maxWidth === null ? null : opts.maxWidth + 'px') elm.style.setProperty('max-width', opts.maxWidth === null ? null : opts.maxWidth + 'px')
elm.style.setProperty('min-width', opts.minWidth ?? null) elm.style.setProperty('min-width', opts.minWidth === null ? null : opts.minWidth + 'px')
elm.style.setProperty('padding', opts.padding) elm.style.setProperty('padding', opts.padding)
elm.style.setProperty(
'overflow-wrap',
opts.disableOverflowWrapBreaking ? 'normal' : 'break-word'
)
elm.textContent = normalizeTextForDom(textToMeasure) elm.textContent = normalizeTextForDom(textToMeasure)
const scrollWidth = elm.scrollWidth
const rect = elm.getBoundingClientRect() const rect = elm.getBoundingClientRect()
elm.remove() elm.remove()
@ -97,6 +103,7 @@ export class TextManager {
y: 0, y: 0,
w: rect.width, w: rect.width,
h: rect.height, h: rect.height,
scrollWidth,
} }
} }

View file

@ -320,16 +320,15 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
* *
* ```ts * ```ts
* onDragShapesOver = (shape, shapes) => { * onDragShapesOver = (shape, shapes) => {
* return { shouldHint: true } * this.editor.reparentShapes(shapes, shape.id)
* } * }
* ``` * ```
* *
* @param shape - The shape. * @param shape - The shape.
* @param shapes - The shapes that are being dragged over this one. * @param shapes - The shapes that are being dragged over this one.
* @returns An object specifying whether the shape should hint that it can receive the dragged shapes.
* @public * @public
*/ */
onDragShapesOver?: TLOnDragHandler<Shape, { shouldHint: boolean }> onDragShapesOver?: TLOnDragHandler<Shape>
/** /**
* A callback called when some other shapes are dragged out of this one. * A callback called when some other shapes are dragged out of this one.

View file

@ -1,5 +1,5 @@
import { HistoryEntry } from '@tldraw/store' import { HistoryEntry } from '@tldraw/store'
import { TLPageId, TLRecord } from '@tldraw/tlschema' import { TLPageId, TLRecord, TLShapeId } from '@tldraw/tlschema'
import { TLEventInfo } from './event-types' import { TLEventInfo } from './event-types'
/** @public */ /** @public */
@ -17,6 +17,7 @@ export interface TLEventMap {
frame: [number] frame: [number]
'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }] 'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }]
'mark-history': [{ id: string }] 'mark-history': [{ id: string }]
'select-all-text': [{ shapeId: TLShapeId }]
} }
/** @public */ /** @public */

View file

@ -107,7 +107,15 @@ export function useCanvasEvents() {
;(e as any).isKilled = true ;(e as any).isKilled = true
if ( if (
(e.target as HTMLElement).tagName !== 'A' && (e.target as HTMLElement).tagName !== 'A' &&
(e.target as HTMLElement).tagName !== 'TEXTAREA' (e.target as HTMLElement).tagName !== 'TEXTAREA' &&
// When in EditingShape state, we are actually clicking on a 'DIV'
// not A/TEXTAREA element yet. So, to preserve cursor position
// for edit mode on mobile we need to not preventDefault.
// TODO: Find out if we still need this preventDefault in general though.
!(
editor.getEditingShape() &&
(e.target as HTMLElement).className.includes('tl-text-content')
)
) { ) {
preventDefault(e) preventDefault(e)
} }

View file

@ -308,6 +308,7 @@ export class Vec {
static Per(A: VecLike): Vec { static Per(A: VecLike): Vec {
return new Vec(A.y, -A.x) return new Vec(A.y, -A.x)
} }
static Abs(A: VecLike): Vec { static Abs(A: VecLike): Vec {
return new Vec(Math.abs(A.x), Math.abs(A.y)) return new Vec(Math.abs(A.x), Math.abs(A.y))
} }

View file

@ -316,3 +316,19 @@ export function polygonsIntersect(a: VecLike[], b: VecLike[]) {
} }
return false return false
} }
/** @public */
export function polygonIntersectsPolyline(polygon: VecLike[], polyline: VecLike[]) {
let a: VecLike, b: VecLike, c: VecLike, d: VecLike
for (let i = 0, n = polygon.length; i < n; i++) {
a = polygon[i]
b = polygon[(i + 1) % n]
for (let j = 1, m = polyline.length; j < m; j++) {
c = polyline[j - 1]
d = polyline[j]
if (linesIntersect(a, b, c, d)) return true
}
}
return false
}

View file

@ -24,15 +24,15 @@ export const pointerCaptureTrackingObject = createDebugValue(
) )
/** @internal */ /** @internal */
export const debugFlags: Record<string, DebugFlag<boolean>> = { export const debugFlags = {
// --- DEBUG VALUES --- // --- DEBUG VALUES ---
preventDefaultLogging: createDebugValue('preventDefaultLogging', { logPreventDefaults: createDebugValue('logPreventDefaults', {
defaults: { all: false }, defaults: { all: false },
}), }),
pointerCaptureTracking: createDebugValue('pointerCaptureTracking', { logPointerCaptures: createDebugValue('logPointerCaptures', {
defaults: { all: false }, defaults: { all: false },
}), }),
elementRemovalLogging: createDebugValue('elementRemovalLogging', { logElementRemoves: createDebugValue('logElementRemoves', {
defaults: { all: false }, defaults: { all: false },
}), }),
debugSvg: createDebugValue('debugSvg', { debugSvg: createDebugValue('debugSvg', {
@ -44,7 +44,7 @@ export const debugFlags: Record<string, DebugFlag<boolean>> = {
throwToBlob: createDebugValue('throwToBlob', { throwToBlob: createDebugValue('throwToBlob', {
defaults: { all: false }, defaults: { all: false },
}), }),
resetConnectionEveryPing: createDebugValue('resetConnectionEveryPing', { reconnectOnPing: createDebugValue('reconnectOnPing', {
defaults: { all: false }, defaults: { all: false },
}), }),
debugCursors: createDebugValue('debugCursors', { debugCursors: createDebugValue('debugCursors', {
@ -53,7 +53,8 @@ export const debugFlags: Record<string, DebugFlag<boolean>> = {
forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }), forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }), debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }), hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }),
} editOnType: createDebugValue('editOnType', { defaults: { all: false } }),
} as const
declare global { declare global {
interface Window { interface Window {
@ -77,7 +78,7 @@ declare global {
if (typeof Element !== 'undefined') { if (typeof Element !== 'undefined') {
const nativeElementRemoveChild = Element.prototype.removeChild const nativeElementRemoveChild = Element.prototype.removeChild
react('element removal logging', () => { react('element removal logging', () => {
if (debugFlags.elementRemovalLogging.get()) { if (debugFlags.logElementRemoves.get()) {
Element.prototype.removeChild = function <T extends Node>(this: any, child: Node): T { Element.prototype.removeChild = function <T extends Node>(this: any, child: Node): T {
console.warn('[tldraw] removing child:', child) console.warn('[tldraw] removing child:', child)
return nativeElementRemoveChild.call(this, child) as T return nativeElementRemoveChild.call(this, child) as T

View file

@ -37,7 +37,7 @@ export function loopToHtmlElement(elm: Element): HTMLElement {
*/ */
export function preventDefault(event: React.BaseSyntheticEvent | Event) { export function preventDefault(event: React.BaseSyntheticEvent | Event) {
event.preventDefault() event.preventDefault()
if (debugFlags.preventDefaultLogging.get()) { if (debugFlags.logPreventDefaults.get()) {
console.warn('preventDefault called on event:', event) console.warn('preventDefault called on event:', event)
} }
} }
@ -48,7 +48,7 @@ export function setPointerCapture(
event: React.PointerEvent<Element> | PointerEvent event: React.PointerEvent<Element> | PointerEvent
) { ) {
element.setPointerCapture(event.pointerId) element.setPointerCapture(event.pointerId)
if (debugFlags.pointerCaptureTracking.get()) { if (debugFlags.logPointerCaptures.get()) {
const trackingObj = pointerCaptureTrackingObject.get() const trackingObj = pointerCaptureTrackingObject.get()
trackingObj.set(element, (trackingObj.get(element) ?? 0) + 1) trackingObj.set(element, (trackingObj.get(element) ?? 0) + 1)
console.warn('setPointerCapture called on element:', element, event) console.warn('setPointerCapture called on element:', element, event)
@ -65,7 +65,7 @@ export function releasePointerCapture(
} }
element.releasePointerCapture(event.pointerId) element.releasePointerCapture(event.pointerId)
if (debugFlags.pointerCaptureTracking.get()) { if (debugFlags.logPointerCaptures.get()) {
const trackingObj = pointerCaptureTrackingObject.get() const trackingObj = pointerCaptureTrackingObject.get()
if (trackingObj.get(element) === 1) { if (trackingObj.get(element) === 1) {
trackingObj.delete(element) trackingObj.delete(element)

View file

@ -62,7 +62,6 @@ import { TLBookmarkShape } from '@tldraw/editor';
import { TLCancelEvent } from '@tldraw/editor'; import { TLCancelEvent } from '@tldraw/editor';
import { TLClickEvent } from '@tldraw/editor'; import { TLClickEvent } from '@tldraw/editor';
import { TLClickEventInfo } from '@tldraw/editor'; import { TLClickEventInfo } from '@tldraw/editor';
import { TLDefaultColorStyle } from '@tldraw/editor';
import { TLDefaultColorTheme } from '@tldraw/editor'; import { TLDefaultColorTheme } from '@tldraw/editor';
import { TLDefaultFillStyle } from '@tldraw/editor'; import { TLDefaultFillStyle } from '@tldraw/editor';
import { TLDefaultFontStyle } from '@tldraw/editor'; import { TLDefaultFontStyle } from '@tldraw/editor';
@ -93,7 +92,6 @@ import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
import { TLOnDoubleClickHandler } from '@tldraw/editor'; import { TLOnDoubleClickHandler } from '@tldraw/editor';
import { TLOnEditEndHandler } from '@tldraw/editor'; import { TLOnEditEndHandler } from '@tldraw/editor';
import { TLOnHandleDragHandler } from '@tldraw/editor'; import { TLOnHandleDragHandler } from '@tldraw/editor';
import { TLOnResizeEndHandler } from '@tldraw/editor';
import { TLOnResizeHandler } from '@tldraw/editor'; import { TLOnResizeHandler } from '@tldraw/editor';
import { TLOnTranslateHandler } from '@tldraw/editor'; import { TLOnTranslateHandler } from '@tldraw/editor';
import { TLOnTranslateStartHandler } from '@tldraw/editor'; import { TLOnTranslateStartHandler } from '@tldraw/editor';
@ -656,14 +654,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// (undocumented) // (undocumented)
onDragShapesOut: (_shape: TLFrameShape, shapes: TLShape[]) => void; onDragShapesOut: (_shape: TLFrameShape, shapes: TLShape[]) => void;
// (undocumented) // (undocumented)
onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => { onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => void;
shouldHint: boolean;
};
// (undocumented) // (undocumented)
onResize: TLOnResizeHandler<any>; onResize: TLOnResizeHandler<any>;
// (undocumented) // (undocumented)
onResizeEnd: TLOnResizeEndHandler<TLFrameShape>;
// (undocumented)
static props: { static props: {
w: Validator<number>; w: Validator<number>;
h: Validator<number>; h: Validator<number>;
@ -837,6 +831,9 @@ export function GeoStylePickerSet({ styles }: {
// @public // @public
export function getEmbedInfo(inputUrl: string): TLEmbedResult; export function getEmbedInfo(inputUrl: string): TLEmbedResult;
// @public (undocumented)
export function getOccludedChildren(editor: Editor, parent: TLShape): TLShapeId[];
// @public (undocumented) // @public (undocumented)
export function getSvgAsImage(svgString: string, isSafari: boolean, options: { export function getSvgAsImage(svgString: string, isSafari: boolean, options: {
type: 'jpeg' | 'png' | 'webp'; type: 'jpeg' | 'png' | 'webp';
@ -974,6 +971,9 @@ export function isGifAnimated(file: Blob): Promise<boolean>;
// @public (undocumented) // @public (undocumented)
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null; export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;
// @internal (undocumented)
export function kickoutOccludedShapes(editor: Editor, shapeIds: TLShapeId[]): void;
// @public (undocumented) // @public (undocumented)
export const LABEL_FONT_SIZES: Record<TLDefaultSizeStyle, number>; export const LABEL_FONT_SIZES: Record<TLDefaultSizeStyle, number>;
@ -1100,9 +1100,9 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
// (undocumented) // (undocumented)
getDefaultProps(): TLNoteShape['props']; getDefaultProps(): TLNoteShape['props'];
// (undocumented) // (undocumented)
getGeometry(shape: TLNoteShape): Rectangle2d; getGeometry(shape: TLNoteShape): Group2d;
// (undocumented) // (undocumented)
getHeight(shape: TLNoteShape): number; getHandles(shape: TLNoteShape): TLHandle[];
// (undocumented) // (undocumented)
hideResizeHandles: () => boolean; hideResizeHandles: () => boolean;
// (undocumented) // (undocumented)
@ -1115,6 +1115,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
onBeforeCreate: (next: TLNoteShape) => { onBeforeCreate: (next: TLNoteShape) => {
props: { props: {
growY: number; growY: number;
fontSizeAdjustment: number;
color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow"; color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow";
size: "l" | "m" | "s" | "xl"; size: "l" | "m" | "s" | "xl";
font: "draw" | "mono" | "sans" | "serif"; font: "draw" | "mono" | "sans" | "serif";
@ -1139,6 +1140,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
onBeforeUpdate: (prev: TLNoteShape, next: TLNoteShape) => { onBeforeUpdate: (prev: TLNoteShape, next: TLNoteShape) => {
props: { props: {
growY: number; growY: number;
fontSizeAdjustment: number;
color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow"; color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow";
size: "l" | "m" | "s" | "xl"; size: "l" | "m" | "s" | "xl";
font: "draw" | "mono" | "sans" | "serif"; font: "draw" | "mono" | "sans" | "serif";
@ -1165,6 +1167,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
static props: { static props: {
color: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">; color: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">;
size: EnumStyleProp<"l" | "m" | "s" | "xl">; size: EnumStyleProp<"l" | "m" | "s" | "xl">;
fontSizeAdjustment: Validator<number>;
font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">; font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">;
align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">; align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">;
verticalAlign: EnumStyleProp<"end" | "middle" | "start">; verticalAlign: EnumStyleProp<"end" | "middle" | "start">;
@ -2386,6 +2389,7 @@ export type TLUiTranslation = {
readonly locale: string; readonly locale: string;
readonly label: string; readonly label: string;
readonly messages: Record<TLUiTranslationKey, string>; readonly messages: Record<TLUiTranslationKey, string>;
readonly dir: 'ltr' | 'rtl';
}; };
// @public (undocumented) // @public (undocumented)
@ -2477,6 +2481,9 @@ export function useCanUndo(): boolean;
// @public (undocumented) // @public (undocumented)
export function useCopyAs(): (ids: TLShapeId[], format?: TLCopyType) => void; export function useCopyAs(): (ids: TLShapeId[], format?: TLCopyType) => void;
// @public (undocumented)
export const useCurrentTranslation: () => TLUiTranslation;
// @public (undocumented) // @public (undocumented)
export function useDefaultHelpers(): { export function useDefaultHelpers(): {
addToast: (toast: Omit<TLUiToast, "id"> & { addToast: (toast: Omit<TLUiToast, "id"> & {
@ -2498,16 +2505,19 @@ export function useDefaultHelpers(): {
export function useDialogs(): TLUiDialogsContextType; export function useDialogs(): TLUiDialogsContextType;
// @public (undocumented) // @public (undocumented)
export function useEditableText(id: TLShapeId, type: string, text: string): { export function useEditableText(id: TLShapeId, type: string, text: string, opts?: {
disableTab: boolean;
}): {
rInput: React_2.RefObject<HTMLTextAreaElement>; rInput: React_2.RefObject<HTMLTextAreaElement>;
isEditing: boolean; handleFocus: typeof noop;
handleFocus: () => void;
handleBlur: () => void; handleBlur: () => void;
handleKeyDown: (e: React_2.KeyboardEvent<HTMLTextAreaElement>) => void; handleKeyDown: (e: React_2.KeyboardEvent<HTMLTextAreaElement>) => void;
handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void; handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void;
handleInputPointerDown: (e: React_2.PointerEvent) => void; handleInputPointerDown: (e: React_2.PointerEvent) => void;
handleDoubleClick: (e: any) => any; handleDoubleClick: (e: any) => any;
isEmpty: boolean; isEmpty: boolean;
isEditing: boolean;
isEditingAnything: boolean;
}; };
// @public (undocumented) // @public (undocumented)

View file

@ -7567,7 +7567,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "[]) => {\n shouldHint: boolean;\n }" "text": "[]) => void"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -7621,50 +7621,6 @@
"isProtected": false, "isProtected": false,
"isAbstract": false "isAbstract": false
}, },
{
"kind": "Property",
"canonicalReference": "tldraw!FrameShapeUtil#onResizeEnd:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onResizeEnd: "
},
{
"kind": "Reference",
"text": "TLOnResizeEndHandler",
"canonicalReference": "@tldraw/editor!TLOnResizeEndHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLFrameShape",
"canonicalReference": "@tldraw/tlschema!TLFrameShape:type"
},
{
"kind": "Content",
"text": ">"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onResizeEnd",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "tldraw!FrameShapeUtil.props:member", "canonicalReference": "tldraw!FrameShapeUtil.props:member",
@ -9178,6 +9134,74 @@
], ],
"name": "getEmbedInfo" "name": "getEmbedInfo"
}, },
{
"kind": "Function",
"canonicalReference": "tldraw!getOccludedChildren:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function getOccludedChildren(editor: "
},
{
"kind": "Reference",
"text": "Editor",
"canonicalReference": "@tldraw/editor!Editor:class"
},
{
"kind": "Content",
"text": ", parent: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/tools/SelectTool/selectHelpers.ts",
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 7
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "editor",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "parent",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"name": "getOccludedChildren"
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "tldraw!getSvgAsImage:function(1)", "canonicalReference": "tldraw!getSvgAsImage:function(1)",
@ -12920,8 +12944,8 @@
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "Rectangle2d", "text": "Group2d",
"canonicalReference": "@tldraw/editor!Rectangle2d:class" "canonicalReference": "@tldraw/editor!Group2d:class"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -12952,12 +12976,12 @@
}, },
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "tldraw!NoteShapeUtil#getHeight:member(1)", "canonicalReference": "tldraw!NoteShapeUtil#getHandles:member(1)",
"docComment": "", "docComment": "",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
"text": "getHeight(shape: " "text": "getHandles(shape: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -12968,9 +12992,14 @@
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
}, },
{
"kind": "Reference",
"text": "TLHandle",
"canonicalReference": "@tldraw/tlschema!TLHandle:interface"
},
{ {
"kind": "Content", "kind": "Content",
"text": "number" "text": "[]"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -12980,7 +13009,7 @@
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 3, "startIndex": 3,
"endIndex": 4 "endIndex": 5
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -12997,7 +13026,7 @@
], ],
"isOptional": false, "isOptional": false,
"isAbstract": false, "isAbstract": false,
"name": "getHeight" "name": "getHandles"
}, },
{ {
"kind": "Property", "kind": "Property",
@ -13168,7 +13197,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ") => {\n props: {\n growY: number;\n color: \"black\" | \"blue\" | \"green\" | \"grey\" | \"light-blue\" | \"light-green\" | \"light-red\" | \"light-violet\" | \"orange\" | \"red\" | \"violet\" | \"white\" | \"yellow\";\n size: \"l\" | \"m\" | \"s\" | \"xl\";\n font: \"draw\" | \"mono\" | \"sans\" | \"serif\";\n align: \"end-legacy\" | \"end\" | \"middle-legacy\" | \"middle\" | \"start-legacy\" | \"start\";\n verticalAlign: \"end\" | \"middle\" | \"start\";\n url: string;\n text: string;\n };\n type: \"note\";\n x: number;\n y: number;\n rotation: number;\n index: import(\"@tldraw/editor\")." "text": ") => {\n props: {\n growY: number;\n fontSizeAdjustment: number;\n color: \"black\" | \"blue\" | \"green\" | \"grey\" | \"light-blue\" | \"light-green\" | \"light-red\" | \"light-violet\" | \"orange\" | \"red\" | \"violet\" | \"white\" | \"yellow\";\n size: \"l\" | \"m\" | \"s\" | \"xl\";\n font: \"draw\" | \"mono\" | \"sans\" | \"serif\";\n align: \"end-legacy\" | \"end\" | \"middle-legacy\" | \"middle\" | \"start-legacy\" | \"start\";\n verticalAlign: \"end\" | \"middle\" | \"start\";\n url: string;\n text: string;\n };\n type: \"note\";\n x: number;\n y: number;\n rotation: number;\n index: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -13195,7 +13224,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n id: import(\"@tldraw/editor\")." "text": ";\n id: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -13252,7 +13281,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ") => {\n props: {\n growY: number;\n color: \"black\" | \"blue\" | \"green\" | \"grey\" | \"light-blue\" | \"light-green\" | \"light-red\" | \"light-violet\" | \"orange\" | \"red\" | \"violet\" | \"white\" | \"yellow\";\n size: \"l\" | \"m\" | \"s\" | \"xl\";\n font: \"draw\" | \"mono\" | \"sans\" | \"serif\";\n align: \"end-legacy\" | \"end\" | \"middle-legacy\" | \"middle\" | \"start-legacy\" | \"start\";\n verticalAlign: \"end\" | \"middle\" | \"start\";\n url: string;\n text: string;\n };\n type: \"note\";\n x: number;\n y: number;\n rotation: number;\n index: import(\"@tldraw/editor\")." "text": ") => {\n props: {\n growY: number;\n fontSizeAdjustment: number;\n color: \"black\" | \"blue\" | \"green\" | \"grey\" | \"light-blue\" | \"light-green\" | \"light-red\" | \"light-violet\" | \"orange\" | \"red\" | \"violet\" | \"white\" | \"yellow\";\n size: \"l\" | \"m\" | \"s\" | \"xl\";\n font: \"draw\" | \"mono\" | \"sans\" | \"serif\";\n align: \"end-legacy\" | \"end\" | \"middle-legacy\" | \"middle\" | \"start-legacy\" | \"start\";\n verticalAlign: \"end\" | \"middle\" | \"start\";\n url: string;\n text: string;\n };\n type: \"note\";\n x: number;\n y: number;\n rotation: number;\n index: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -13279,7 +13308,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n id: import(\"@tldraw/editor\")." "text": ";\n id: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -13380,7 +13409,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<\"l\" | \"m\" | \"s\" | \"xl\">;\n font: import(\"@tldraw/editor\")." "text": "<\"l\" | \"m\" | \"s\" | \"xl\">;\n fontSizeAdjustment: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "Validator",
"canonicalReference": "@tldraw/validate!Validator:class"
},
{
"kind": "Content",
"text": "<number>;\n font: import(\"@tldraw/editor\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -13447,7 +13485,7 @@
"name": "props", "name": "props",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 18 "endIndex": 20
}, },
"isStatic": true, "isStatic": true,
"isProtected": false, "isProtected": false,
@ -14090,7 +14128,7 @@
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "tldraw!removeFrame:function(1)", "canonicalReference": "tldraw!removeFrame:function(1)",
"docComment": "/**\n * Remove a frame.\n *\n * @param editor - tlraw editor instance.\n *\n * @param ids - Ids of the frames you wish to remove.\n *\n * @public\n */\n", "docComment": "/**\n * Remove a frame.\n *\n * @param editor - tldraw editor instance.\n *\n * @param ids - Ids of the frames you wish to remove.\n *\n * @public\n */\n",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
@ -26258,7 +26296,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ", string>;\n}" "text": ", string>;\n readonly dir: 'ltr' | 'rtl';\n}"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -27267,6 +27305,31 @@
"parameters": [], "parameters": [],
"name": "useCopyAs" "name": "useCopyAs"
}, },
{
"kind": "Function",
"canonicalReference": "tldraw!useCurrentTranslation:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "useCurrentTranslation: () => "
},
{
"kind": "Reference",
"text": "TLUiTranslation",
"canonicalReference": "tldraw!TLUiTranslation:type"
}
],
"fileUrlPath": "packages/tldraw/src/lib/ui/hooks/useTranslation/useTranslation.tsx",
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [],
"name": "useCurrentTranslation"
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "tldraw!useDefaultHelpers:function(1)", "canonicalReference": "tldraw!useDefaultHelpers:function(1)",
@ -27408,6 +27471,14 @@
"kind": "Content", "kind": "Content",
"text": "string" "text": "string"
}, },
{
"kind": "Content",
"text": ", opts?: "
},
{
"kind": "Content",
"text": "{\n disableTab: boolean;\n}"
},
{ {
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
@ -27432,7 +27503,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ">;\n isEditing: boolean;\n handleFocus: () => void;\n handleBlur: () => void;\n handleKeyDown: (e: " "text": ">;\n handleFocus: typeof "
},
{
"kind": "Reference",
"text": "noop",
"canonicalReference": "tldraw!~noop:function"
},
{
"kind": "Content",
"text": ";\n handleBlur: () => void;\n handleKeyDown: (e: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -27477,7 +27557,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ") => void;\n handleDoubleClick: (e: any) => any;\n isEmpty: boolean;\n}" "text": ") => void;\n handleDoubleClick: (e: any) => any;\n isEmpty: boolean;\n isEditing: boolean;\n isEditingAnything: boolean;\n}"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -27486,8 +27566,8 @@
], ],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts", "fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts",
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 7, "startIndex": 9,
"endIndex": 22 "endIndex": 26
}, },
"releaseTag": "Public", "releaseTag": "Public",
"overloadIndex": 1, "overloadIndex": 1,
@ -27515,6 +27595,14 @@
"endIndex": 6 "endIndex": 6
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": true
} }
], ],
"name": "useEditableText" "name": "useEditableText"

View file

@ -40,6 +40,7 @@ export { EraserTool } from './lib/tools/EraserTool/EraserTool'
export { HandTool } from './lib/tools/HandTool/HandTool' export { HandTool } from './lib/tools/HandTool/HandTool'
export { LaserTool } from './lib/tools/LaserTool/LaserTool' export { LaserTool } from './lib/tools/LaserTool/LaserTool'
export { SelectTool } from './lib/tools/SelectTool/SelectTool' export { SelectTool } from './lib/tools/SelectTool/SelectTool'
export { getOccludedChildren, kickoutOccludedShapes } from './lib/tools/SelectTool/selectHelpers'
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool' export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
// UI // UI
export { useEditableText } from './lib/shapes/shared/useEditableText' export { useEditableText } from './lib/shapes/shared/useEditableText'
@ -97,6 +98,7 @@ export {
export { type TLUiTranslationKey } from './lib/ui/hooks/useTranslation/TLUiTranslationKey' export { type TLUiTranslationKey } from './lib/ui/hooks/useTranslation/TLUiTranslationKey'
export { type TLUiTranslation } from './lib/ui/hooks/useTranslation/translations' export { type TLUiTranslation } from './lib/ui/hooks/useTranslation/translations'
export { export {
useCurrentTranslation,
useTranslation, useTranslation,
type TLUiTranslationContextType, type TLUiTranslationContextType,
} from './lib/ui/hooks/useTranslation/useTranslation' } from './lib/ui/hooks/useTranslation/useTranslation'

View file

@ -3,9 +3,21 @@ import { TLHandlesProps, useEditor, useValue } from '@tldraw/editor'
/** @public */ /** @public */
export function TldrawHandles({ children }: TLHandlesProps) { export function TldrawHandles({ children }: TLHandlesProps) {
const editor = useEditor() const editor = useEditor()
// todo: maybe display note shape handles here?
const shouldDisplayHandles = useValue( const shouldDisplayHandles = useValue(
'shouldDisplayHandles', 'shouldDisplayHandles',
() => editor.isInAny('select.idle', 'select.pointing_handle'), () => {
if (editor.isInAny('select.idle', 'select.pointing_handle', 'select.pointing_shape')) {
return true
}
if (editor.isInAny('select.editing_shape')) {
const onlySelectedShape = editor.getOnlySelectedShape()
return onlySelectedShape && editor.isShapeOfType(onlySelectedShape, 'note')
}
return false
},
[editor] [editor]
) )

View file

@ -272,6 +272,22 @@ export function registerDefaultExternalContentHandlers(
const textToPaste = cleanupText(text) const textToPaste = cleanupText(text)
// If we're pasting into a text shape, update the text.
const onlySelectedShape = editor.getOnlySelectedShape()
if (onlySelectedShape && 'text' in onlySelectedShape.props) {
editor.updateShapes([
{
id: onlySelectedShape.id,
type: onlySelectedShape.type,
props: {
text: textToPaste,
},
},
])
return
}
// Measure the text with default values // Measure the text with default values
let w: number let w: number
let h: number let h: number

View file

@ -24,6 +24,7 @@ import {
arrowShapeMigrations, arrowShapeMigrations,
arrowShapeProps, arrowShapeProps,
getArrowTerminalsInArrowSpace, getArrowTerminalsInArrowSpace,
getDefaultColorTheme,
mapObjectMapValues, mapObjectMapValues,
objectMapEntries, objectMapEntries,
structuredClone, structuredClone,
@ -306,15 +307,20 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// If no bound shapes are in the selection, unbind any bound shapes // If no bound shapes are in the selection, unbind any bound shapes
const selectedShapeIds = this.editor.getSelectedShapeIds() const selectedShapeIds = this.editor.getSelectedShapeIds()
const shapesToCheck = new Set<string>()
if ( if (startBindingId) {
(startBindingId && // Add shape and all ancestors to set
(selectedShapeIds.includes(startBindingId) || shapesToCheck.add(startBindingId)
this.editor.isAncestorSelected(startBindingId))) || this.editor.getShapeAncestors(startBindingId).forEach((a) => shapesToCheck.add(a.id))
(endBindingId && }
(selectedShapeIds.includes(endBindingId) || this.editor.isAncestorSelected(endBindingId))) if (endBindingId) {
) { // Add shape and all ancestors to set
return shapesToCheck.add(endBindingId)
this.editor.getShapeAncestors(endBindingId).forEach((a) => shapesToCheck.add(a.id))
}
// If any of the shapes are selected, return
for (const id of selectedShapeIds) {
if (shapesToCheck.has(id)) return
} }
let result = shape let result = shape
@ -530,6 +536,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
if (!info?.isValid) return null if (!info?.isValid) return null
const labelPosition = getArrowLabelPosition(this.editor, shape) const labelPosition = getArrowLabelPosition(this.editor, shape)
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const isEditing = this.editor.getEditingShapeId() === shape.id const isEditing = this.editor.getEditingShapeId() === shape.id
const showArrowLabel = isEditing || shape.props.text const showArrowLabel = isEditing || shape.props.text
@ -549,6 +556,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
size={shape.props.size} size={shape.props.size}
position={labelPosition.box.center} position={labelPosition.box.center}
width={labelPosition.box.w} width={labelPosition.box.w}
isSelected={isSelected}
labelColor={shape.props.labelColor} labelColor={shape.props.labelColor}
/> />
)} )}
@ -692,6 +700,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
override toSvg(shape: TLArrowShape, ctx: SvgExportContext) { override toSvg(shape: TLArrowShape, ctx: SvgExportContext) {
ctx.addExportDef(getFillDefForExport(shape.props.fill)) ctx.addExportDef(getFillDefForExport(shape.props.fill))
if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font)) if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font))
const theme = getDefaultColorTheme(ctx)
return ( return (
<> <>
@ -702,7 +711,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
align="middle" align="middle"
verticalAlign="middle" verticalAlign="middle"
text={shape.props.text} text={shape.props.text}
labelColor={shape.props.labelColor} labelColor={theme[shape.props.labelColor].solid}
bounds={getArrowLabelPosition(this.editor, shape).box} bounds={getArrowLabelPosition(this.editor, shape).box}
padding={4} padding={4}
/> />

View file

@ -1,5 +1,6 @@
import { TLArrowShape, TLDefaultColorStyle, TLShapeId, VecLike } from '@tldraw/editor' import { TLArrowShape, TLDefaultColorStyle, TLShapeId, VecLike } from '@tldraw/editor'
import * as React from 'react' import * as React from 'react'
import { useDefaultColorTheme } from '../../shared/ShapeFill'
import { TextLabel } from '../../shared/TextLabel' import { TextLabel } from '../../shared/TextLabel'
import { ARROW_LABEL_FONT_SIZES, TEXT_PROPS } from '../../shared/default-shape-constants' import { ARROW_LABEL_FONT_SIZES, TEXT_PROPS } from '../../shared/default-shape-constants'
@ -10,11 +11,16 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
font, font,
position, position,
width, width,
isSelected,
labelColor, labelColor,
}: { id: TLShapeId; position: VecLike; width?: number; labelColor: TLDefaultColorStyle } & Pick< }: {
TLArrowShape['props'], id: TLShapeId
'text' | 'size' | 'font' position: VecLike
>) { width?: number
labelColor: TLDefaultColorStyle
isSelected: boolean
} & Pick<TLArrowShape['props'], 'text' | 'size' | 'font'>) {
const theme = useDefaultColorTheme()
return ( return (
<TextLabel <TextLabel
id={id} id={id}
@ -26,8 +32,10 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
align="middle" align="middle"
verticalAlign="middle" verticalAlign="middle"
text={text} text={text}
labelColor={labelColor} labelColor={theme[labelColor].solid}
textWidth={width} textWidth={width}
isSelected={isSelected}
disableTab
style={{ style={{
transform: `translate(${position.x}px, ${position.y}px)`, transform: `translate(${position.x}px, ${position.y}px)`,
}} }}

View file

@ -89,7 +89,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
component(shape: TLDrawShape) { component(shape: TLDrawShape) {
return ( return (
<SVGContainer id={shape.id}> <SVGContainer id={shape.id}>
<DrawShapSvg shape={shape} forceSolid={useForceSolid()} /> <DrawShapeSvg shape={shape} forceSolid={useForceSolid()} />
</SVGContainer> </SVGContainer>
) )
} }
@ -122,7 +122,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
override toSvg(shape: TLDrawShape, ctx: SvgExportContext) { override toSvg(shape: TLDrawShape, ctx: SvgExportContext) {
ctx.addExportDef(getFillDefForExport(shape.props.fill)) ctx.addExportDef(getFillDefForExport(shape.props.fill))
return <DrawShapSvg shape={shape} forceSolid={false} /> return <DrawShapeSvg shape={shape} forceSolid={false} />
} }
override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] { override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
@ -171,7 +171,7 @@ function getIsDot(shape: TLDrawShape) {
return shape.props.segments.length === 1 && shape.props.segments[0].points.length < 2 return shape.props.segments.length === 1 && shape.props.segments[0].points.length < 2
} }
function DrawShapSvg({ shape, forceSolid }: { shape: TLDrawShape; forceSolid: boolean }) { function DrawShapeSvg({ shape, forceSolid }: { shape: TLDrawShape; forceSolid: boolean }) {
const theme = useDefaultColorTheme() const theme = useDefaultColorTheme()
const strokeWidth = STROKE_SIZES[shape.props.size] const strokeWidth = STROKE_SIZES[shape.props.size]
const allPointsFromSegments = getPointsFromSegments(shape.props.segments) const allPointsFromSegments = getPointsFromSegments(shape.props.segments)

View file

@ -3,16 +3,12 @@ import {
Geometry2d, Geometry2d,
Rectangle2d, Rectangle2d,
SVGContainer, SVGContainer,
SelectionEdge,
SvgExportContext, SvgExportContext,
TLFrameShape, TLFrameShape,
TLGroupShape, TLGroupShape,
TLOnResizeEndHandler,
TLOnResizeHandler, TLOnResizeHandler,
TLShape, TLShape,
TLShapeId,
canonicalizeRotation, canonicalizeRotation,
exhaustiveSwitchError,
frameShapeMigrations, frameShapeMigrations,
frameShapeProps, frameShapeProps,
getDefaultColorTheme, getDefaultColorTheme,
@ -70,7 +66,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } }) const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } })
?.info ?.info
if (!info) return false if (!info) return false
return info.isCreating && this.editor.getOnlySelectedShape()?.id === shape.id return info.isCreating && this.editor.getOnlySelectedShapeId() === shape.id
}, },
[shape.id] [shape.id]
) )
@ -108,28 +104,26 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// rotate right 45 deg // rotate right 45 deg
const offsetRotation = pageRotation + Math.PI / 4 const offsetRotation = pageRotation + Math.PI / 4
const scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4 const scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4
const labelSide: SelectionEdge = (['top', 'left', 'bottom', 'right'] as const)[ const labelSide = Math.floor(scaledRotation)
Math.floor(scaledRotation)
]
let labelTranslate: string let labelTranslate: string
switch (labelSide) { switch (labelSide) {
case 'top': case 0: // top
labelTranslate = `` labelTranslate = ``
break break
case 'right': case 3: // right
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, 0) rotate(90)` labelTranslate = `translate(${toDomPrecision(shape.props.w)}, 0) rotate(90)`
break break
case 'bottom': case 2: // bottom
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, ${toDomPrecision( labelTranslate = `translate(${toDomPrecision(shape.props.w)}, ${toDomPrecision(
shape.props.h shape.props.h
)}) rotate(180)` )}) rotate(180)`
break break
case 'left': case 1: // left
labelTranslate = `translate(0, ${toDomPrecision(shape.props.h)}) rotate(270)` labelTranslate = `translate(0, ${toDomPrecision(shape.props.h)}) rotate(270)`
break break
default: default:
exhaustiveSwitchError(labelSide) throw Error('labelSide out of bounds')
} }
// Truncate with ellipsis // Truncate with ellipsis
@ -211,15 +205,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
return !shape.isLocked return !shape.isLocked
} }
override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]): { shouldHint: boolean } => { override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === frame.id)) { if (!shapes.every((child) => child.parentId === frame.id)) {
this.editor.reparentShapes( this.editor.reparentShapes(shapes, frame.id)
shapes.map((shape) => shape.id),
frame.id
)
return { shouldHint: true }
} }
return { shouldHint: false }
} }
override onDragShapesOut = (_shape: TLFrameShape, shapes: TLShape[]): void => { override onDragShapesOut = (_shape: TLFrameShape, shapes: TLShape[]): void => {
@ -236,24 +225,6 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
} }
} }
override onResizeEnd: TLOnResizeEndHandler<TLFrameShape> = (shape) => {
const bounds = this.editor.getShapePageBounds(shape)!
const children = this.editor.getSortedChildIdsForParent(shape.id)
const shapesToReparent: TLShapeId[] = []
for (const childId of children) {
const childBounds = this.editor.getShapePageBounds(childId)!
if (!bounds.includes(childBounds)) {
shapesToReparent.push(childId)
}
}
if (shapesToReparent.length > 0) {
this.editor.reparentShapes(shapesToReparent, this.editor.getCurrentPageId())
}
}
override onResize: TLOnResizeHandler<any> = (shape, info) => { override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info) return resizeBox(shape, info)
} }

View file

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { import {
BaseBoxShapeUtil, BaseBoxShapeUtil,
Editor, Editor,
@ -22,10 +23,12 @@ import {
exhaustiveSwitchError, exhaustiveSwitchError,
geoShapeMigrations, geoShapeMigrations,
geoShapeProps, geoShapeProps,
getDefaultColorTheme,
getPolygonVertices, getPolygonVertices,
} from '@tldraw/editor' } from '@tldraw/editor'
import { HyperlinkButton } from '../shared/HyperlinkButton' import { HyperlinkButton } from '../shared/HyperlinkButton'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel' import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel' import { TextLabel } from '../shared/TextLabel'
import { import {
@ -292,8 +295,13 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
} }
const labelSize = getLabelSize(this.editor, shape) const labelSize = getLabelSize(this.editor, shape)
const labelWidth = Math.min(w, Math.max(labelSize.w, Math.min(32, Math.max(1, w - 8)))) const minWidth = Math.min(100, w / 2)
const labelHeight = Math.min(h, Math.max(labelSize.h, Math.min(32, Math.max(1, w - 8)))) // not sure if bug const labelWidth = Math.min(w, Math.max(labelSize.w, Math.min(minWidth, Math.max(1, w - 8))))
const minHeight = Math.min(
LABEL_FONT_SIZES[shape.props.size] * TEXT_PROPS.lineHeight + LABEL_PADDING * 2,
h / 2
)
const labelHeight = Math.min(h, Math.max(labelSize.h, Math.min(minHeight, Math.max(1, w - 8)))) // not sure if bug
const lines = getLines(shape.props, strokeWidth) const lines = getLines(shape.props, strokeWidth)
const edges = lines ? lines.map((line) => new Polyline2d({ points: line })) : [] const edges = lines ? lines.map((line) => new Polyline2d({ points: line })) : []
@ -381,10 +389,11 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
component(shape: TLGeoShape) { component(shape: TLGeoShape) {
const { id, type, props } = shape const { id, type, props } = shape
const { labelColor, fill, font, align, verticalAlign, size, text } = props const { fill, font, align, verticalAlign, size, text } = props
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const isEditing = this.editor.getEditingShapeId() === id const theme = useDefaultColorTheme()
const showHtmlContainer = isEditing || shape.props.url || shape.props.text const isEditingAnything = this.editor.getEditingShapeId() !== null
const showHtmlContainer = isEditingAnything || shape.props.text
return ( return (
<> <>
@ -410,15 +419,16 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
align={align} align={align}
verticalAlign={verticalAlign} verticalAlign={verticalAlign}
text={text} text={text}
labelColor={labelColor} isSelected={isSelected}
labelColor={theme[props.labelColor].solid}
disableTab
wrap wrap
bounds={props.geo === 'cloud' ? this.getGeometry(shape).bounds : undefined}
/> />
</HTMLContainer>
)}
{shape.props.url && ( {shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} /> <HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
)} )}
</HTMLContainer>
)}
</> </>
) )
} }
@ -478,6 +488,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
let textEl let textEl
if (props.text) { if (props.text) {
ctx.addExportDef(getFontDefForExport(shape.props.font)) ctx.addExportDef(getFontDefForExport(shape.props.font))
const theme = getDefaultColorTheme(ctx)
const bounds = this.editor.getShapeGeometry(shape).bounds const bounds = this.editor.getShapeGeometry(shape).bounds
textEl = ( textEl = (
@ -487,7 +498,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
align={props.align} align={props.align}
verticalAlign={props.verticalAlign} verticalAlign={props.verticalAlign}
text={props.text} text={props.text}
labelColor={props.labelColor} labelColor={theme[props.labelColor].solid}
bounds={bounds} bounds={bounds}
/> />
) )
@ -761,7 +772,7 @@ function getLabelSize(editor: Editor, shape: TLGeoShape) {
...TEXT_PROPS, ...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font], fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size], fontSize: LABEL_FONT_SIZES[shape.props.size],
minWidth: minSize.w + 'px', minWidth: minSize.w,
maxWidth: Math.max( maxWidth: Math.max(
// Guard because a DOM nodes can't be less 0 // Guard because a DOM nodes can't be less 0
0, 0,

View file

@ -50,7 +50,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : undefined
const isSelected = shape.id === this.editor.getOnlySelectedShape()?.id const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
useEffect(() => { useEffect(() => {
if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') { if (asset?.props.src && 'mimeType' in asset.props && asset?.props.mimeType === 'image/gif') {

View file

@ -144,3 +144,132 @@ describe('When in the pointing state', () => {
expect(editor.getCurrentPageShapes().length).toBe(1) expect(editor.getCurrentPageShapes().length).toBe(1)
}) })
}) })
describe('Grid placement helpers', () => {
it('Creates a new sticky note outside of a sticky pit', () => {
editor.createShape({ type: 'note', x: 0, y: 0 })
for (const pit of [
{ x: 100, y: -120 },
{ x: 320, y: 100 },
{ x: 100, y: 320 },
{ x: -120, y: 100 },
]) {
const OFFSET_DISTANCE = 8
editor
.setCurrentTool('note')
.pointerMove(pit.x + OFFSET_DISTANCE, pit.y + OFFSET_DISTANCE) // too far from the pit
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: pit.x + OFFSET_DISTANCE - 100,
y: pit.y + OFFSET_DISTANCE - 100,
})
}
})
it('Creates a new sticky note in a sticky pit', () => {
editor.createShape({ type: 'note', x: 0, y: 0 })
for (const pit of [
{ x: 100, y: -120 },
{ x: 320, y: 100 },
{ x: 100, y: 320 },
{ x: -120, y: 100 },
]) {
const OFFSET_DISTANCE = 7 // close enough to the pit to fall into it
editor
.setCurrentTool('note')
.pointerMove(pit.x + OFFSET_DISTANCE, pit.y + OFFSET_DISTANCE)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: pit.x - 100,
y: pit.y - 100,
})
}
})
it('Falls into a sticky pit when empty', () => {
editor
.createShape({ type: 'note', x: 0, y: 0 })
.setCurrentTool('note')
.pointerMove(324, 104)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
// in da pit
x: 220,
y: 0,
})
})
it('Does not create a new sticky note in a sticky pit if a note is already there', () => {
editor
.createShape({ type: 'note', x: 0, y: 0 })
.createShape({ type: 'note', x: 330, y: 8 }) // make a shape kinda there already!
.setCurrentTool('note')
.pointerMove(300, 104)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
// outta da pit
x: 200,
y: 4,
})
})
it('Does not fall into pits around rotated notes', () => {
editor.createShape({ type: 'note', x: 0, y: 0, rotation: 0.0000001 })
for (const pit of [
{ x: 100, y: -120 },
{ x: 320, y: 100 },
{ x: 100, y: 320 },
{ x: -120, y: 100 },
]) {
const OFFSET_DISTANCE = 7 // close enough to the pit to fall into it (if it weren't rotated)
editor
.setCurrentTool('note')
.pointerMove(pit.x + OFFSET_DISTANCE, pit.y + OFFSET_DISTANCE)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: pit.x + OFFSET_DISTANCE - 100,
y: pit.y + OFFSET_DISTANCE - 100,
})
}
})
it('Falls into correct pit below notes with growY', () => {
editor.createShape({ type: 'note', x: 0, y: 0 }).updateShape({
...editor.getLastCreatedShape(),
props: { growY: 100 },
})
// Misses the pit below the note because the note has growY
// instead of being at 100, 320, it's at 100, 320 + 100 = 320
editor
.setCurrentTool('note')
.pointerMove(100, 324)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: 0,
y: 224,
})
.undo()
// Let's get it in that pit
editor
.setCurrentTool('note')
.pointerMove(100, 424)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: 0,
y: 320,
})
.undo()
})
})

View file

@ -1,23 +1,49 @@
import { import {
Editor, Editor,
Group2d,
IndexKey,
Rectangle2d, Rectangle2d,
ShapeUtil, ShapeUtil,
SvgExportContext, SvgExportContext,
TLHandle,
TLNoteShape, TLNoteShape,
TLOnEditEndHandler, TLOnEditEndHandler,
TLShape,
TLShapeId,
Vec,
WeakMapCache,
getDefaultColorTheme, getDefaultColorTheme,
noteShapeMigrations, noteShapeMigrations,
noteShapeProps, noteShapeProps,
rng,
toDomPrecision, toDomPrecision,
useEditor,
useValue,
} from '@tldraw/editor' } from '@tldraw/editor'
import { useCallback } from 'react'
import { useCurrentTranslation } from '../../ui/hooks/useTranslation/useTranslation'
import { isRightToLeftLanguage } from '../../utils/text/text'
import { HyperlinkButton } from '../shared/HyperlinkButton' import { HyperlinkButton } from '../shared/HyperlinkButton'
import { useDefaultColorTheme } from '../shared/ShapeFill' import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel' import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel' import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants' import {
FONT_FAMILIES,
LABEL_FONT_SIZES,
LABEL_PADDING,
TEXT_PROPS,
} from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs' import { getFontDefForExport } from '../shared/defaultStyleDefs'
const NOTE_SIZE = 200 import { startEditingShapeWithLabel } from '../../tools/SelectTool/selectHelpers'
import { useForceSolid } from '../shared/useForceSolid'
import {
ADJACENT_NOTE_MARGIN,
CLONE_HANDLE_MARGIN,
NOTE_CENTER_OFFSET,
NOTE_SIZE,
getNoteShapeForAdjacentPosition,
} from './noteHelpers'
/** @public */ /** @public */
export class NoteShapeUtil extends ShapeUtil<TLNoteShape> { export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
@ -27,7 +53,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
override canEdit = () => true override canEdit = () => true
override hideResizeHandles = () => true override hideResizeHandles = () => true
override hideSelectionBoundsFg = () => true override hideSelectionBoundsFg = () => false
getDefaultProps(): TLNoteShape['props'] { getDefaultProps(): TLNoteShape['props'] {
return { return {
@ -38,61 +64,136 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
align: 'middle', align: 'middle',
verticalAlign: 'middle', verticalAlign: 'middle',
growY: 0, growY: 0,
fontSizeAdjustment: 0,
url: '', url: '',
} }
} }
getHeight(shape: TLNoteShape) { getGeometry(shape: TLNoteShape) {
return NOTE_SIZE + shape.props.growY const noteHeight = getNoteHeight(shape)
const { labelHeight, labelWidth } = getLabelSize(this.editor, shape)
return new Group2d({
children: [
new Rectangle2d({ width: NOTE_SIZE, height: noteHeight, isFilled: true }),
new Rectangle2d({
x:
shape.props.align === 'start'
? 0
: shape.props.align === 'end'
? NOTE_SIZE - labelWidth
: (NOTE_SIZE - labelWidth) / 2,
y:
shape.props.verticalAlign === 'start'
? 0
: shape.props.verticalAlign === 'end'
? noteHeight - labelHeight
: (noteHeight - labelHeight) / 2,
width: labelWidth,
height: labelHeight,
isFilled: true,
isLabel: true,
}),
],
})
} }
getGeometry(shape: TLNoteShape) { override getHandles(shape: TLNoteShape): TLHandle[] {
const height = this.getHeight(shape) const zoom = this.editor.getZoomLevel()
return new Rectangle2d({ width: NOTE_SIZE, height, isFilled: true }) const offset = CLONE_HANDLE_MARGIN / zoom
const noteHeight = getNoteHeight(shape)
if (zoom < 0.25) return []
return [
{
id: 'top',
index: 'a1' as IndexKey,
type: 'clone',
x: NOTE_SIZE / 2,
y: -offset,
},
{
id: 'right',
index: 'a2' as IndexKey,
type: 'clone',
x: NOTE_SIZE + offset,
y: noteHeight / 2,
},
{
id: 'bottom',
index: 'a3' as IndexKey,
type: 'clone',
x: NOTE_SIZE / 2,
y: noteHeight + offset,
},
{
id: 'left',
index: 'a4' as IndexKey,
type: 'clone',
x: -offset,
y: noteHeight / 2,
},
]
} }
component(shape: TLNoteShape) { component(shape: TLNoteShape) {
const { const {
id, id,
type, type,
props: { color, font, size, align, text, verticalAlign }, props: { color, font, size, align, text, verticalAlign, fontSizeAdjustment },
} = shape } = shape
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleKeyDown = useNoteKeydownHandler(id)
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme() const theme = useDefaultColorTheme()
const adjustedColor = color === 'black' ? 'yellow' : color const noteHeight = getNoteHeight(shape)
// eslint-disable-next-line react-hooks/rules-of-hooks
const rotation = useValue(
'shape rotation',
() => this.editor.getShapePageTransform(id)?.rotation() ?? 0,
[this.editor]
)
// todo: consider hiding shadows on dark mode if they're invisible anyway
// eslint-disable-next-line react-hooks/rules-of-hooks
const hideShadows = useForceSolid()
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
return ( return (
<> <>
<div <div
style={{ id={id}
position: 'absolute',
width: NOTE_SIZE,
height: this.getHeight(shape),
}}
>
<div
className="tl-note__container" className="tl-note__container"
style={{ style={{
color: theme[adjustedColor].solid, width: NOTE_SIZE,
backgroundColor: theme[adjustedColor].solid, height: noteHeight,
backgroundColor: theme[color].note.fill,
borderBottom: hideShadows ? `3px solid rgb(15, 23, 31, .2)` : `none`,
boxShadow: hideShadows ? 'none' : getNoteShadow(shape.id, rotation),
}} }}
> >
<div className="tl-note__scrim" />
<TextLabel <TextLabel
id={id} id={id}
type={type} type={type}
font={font} font={font}
fontSize={LABEL_FONT_SIZES[size]} fontSize={fontSizeAdjustment || LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight} lineHeight={TEXT_PROPS.lineHeight}
align={align} align={align}
verticalAlign={verticalAlign} verticalAlign={verticalAlign}
text={text} text={text}
labelColor="black" isNote
isSelected={isSelected}
labelColor={theme[color].note.text}
disableTab
wrap wrap
onKeyDown={handleKeyDown}
/> />
</div> </div>
</div>
{'url' in shape.props && shape.props.url && ( {'url' in shape.props && shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} /> <HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
)} )}
@ -103,9 +204,9 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
indicator(shape: TLNoteShape) { indicator(shape: TLNoteShape) {
return ( return (
<rect <rect
rx="6" rx="1"
width={toDomPrecision(NOTE_SIZE)} width={toDomPrecision(NOTE_SIZE)}
height={toDomPrecision(this.getHeight(shape))} height={toDomPrecision(getNoteHeight(shape))}
/> />
) )
} }
@ -115,26 +216,22 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font)) if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font))
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }) const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
const bounds = this.editor.getShapeGeometry(shape).bounds const bounds = this.editor.getShapeGeometry(shape).bounds
const adjustedColor = shape.props.color === 'black' ? 'yellow' : shape.props.color
return ( return (
<> <>
<rect x={5} y={5} rx={1} width={NOTE_SIZE - 10} height={bounds.h} fill="rgba(0,0,0,.1)" />
<rect <rect
rx={10} rx={1}
width={NOTE_SIZE} width={NOTE_SIZE}
height={bounds.h} height={bounds.h}
fill={theme[adjustedColor].solid} fill={theme[shape.props.color].note.fill}
stroke={theme[adjustedColor].solid}
strokeWidth={1}
/> />
<rect rx={10} width={NOTE_SIZE} height={bounds.h} fill={theme.background} opacity={0.28} />
<SvgTextLabel <SvgTextLabel
fontSize={LABEL_FONT_SIZES[shape.props.size]} fontSize={shape.props.fontSizeAdjustment || LABEL_FONT_SIZES[shape.props.size]}
font={shape.props.font} font={shape.props.font}
align={shape.props.align} align={shape.props.align}
verticalAlign={shape.props.verticalAlign} verticalAlign={shape.props.verticalAlign}
text={shape.props.text} text={shape.props.text}
labelColor="black" labelColor={theme[shape.props.color].note.text}
bounds={bounds} bounds={bounds}
stroke={false} stroke={false}
/> />
@ -143,7 +240,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
} }
override onBeforeCreate = (next: TLNoteShape) => { override onBeforeCreate = (next: TLNoteShape) => {
return getGrowY(this.editor, next, next.props.growY) return getNoteSizeAdjustments(this.editor, next)
} }
override onBeforeUpdate = (prev: TLNoteShape, next: TLNoteShape) => { override onBeforeUpdate = (prev: TLNoteShape, next: TLNoteShape) => {
@ -155,7 +252,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
return return
} }
return getGrowY(this.editor, next, prev.props.growY) return getNoteSizeAdjustments(this.editor, next)
} }
override onEditEnd: TLOnEditEndHandler<TLNoteShape> = (shape) => { override onEditEnd: TLOnEditEndHandler<TLNoteShape> = (shape) => {
@ -179,35 +276,148 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
} }
} }
function getGrowY(editor: Editor, shape: TLNoteShape, prevGrowY = 0) { /**
const PADDING = 17 * Get the growY and fontSizeAdjustment for a shape.
*/
function getNoteSizeAdjustments(editor: Editor, shape: TLNoteShape) {
const { labelHeight, fontSizeAdjustment } = getLabelSize(editor, shape)
// When the label height is more than the height of the shape, we add extra height to it
const growY = Math.max(0, labelHeight - NOTE_SIZE)
const nextTextSize = editor.textMeasure.measureText(shape.props.text, { if (growY !== shape.props.growY || fontSizeAdjustment !== shape.props.fontSizeAdjustment) {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size],
maxWidth: NOTE_SIZE - PADDING * 2,
})
const nextHeight = nextTextSize.h + PADDING * 2
let growY: number | null = null
if (nextHeight > NOTE_SIZE) {
growY = nextHeight - NOTE_SIZE
} else {
if (prevGrowY) {
growY = 0
}
}
if (growY !== null) {
return { return {
...shape, ...shape,
props: { props: {
...shape.props, ...shape.props,
growY, growY,
fontSizeAdjustment,
}, },
} }
} }
} }
/**
* Get the label size for a note.
*/
function getNoteLabelSize(editor: Editor, shape: TLNoteShape) {
const text = shape.props.text
if (!text) {
const minHeight = LABEL_FONT_SIZES[shape.props.size] * TEXT_PROPS.lineHeight + LABEL_PADDING * 2
return { labelHeight: minHeight, labelWidth: 100, fontSizeAdjustment: 0 }
}
const unadjustedFontSize = LABEL_FONT_SIZES[shape.props.size]
let fontSizeAdjustment = 0
let iterations = 0
let labelHeight = NOTE_SIZE
let labelWidth = NOTE_SIZE
// We slightly make the font smaller if the text is too big for the note, width-wise.
do {
fontSizeAdjustment = Math.min(unadjustedFontSize, unadjustedFontSize - iterations)
const nextTextSize = editor.textMeasure.measureText(text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: fontSizeAdjustment,
maxWidth: NOTE_SIZE - LABEL_PADDING * 2,
disableOverflowWrapBreaking: true,
})
labelHeight = nextTextSize.h + LABEL_PADDING * 2
labelWidth = nextTextSize.w + LABEL_PADDING * 2
if (fontSizeAdjustment <= 14) {
// Too small, just rely now on CSS `overflow-wrap: break-word`
// We need to recalculate the text measurement here with break-word enabled.
const nextTextSizeWithOverflowBreak = editor.textMeasure.measureText(text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: fontSizeAdjustment,
maxWidth: NOTE_SIZE - LABEL_PADDING * 2,
})
labelHeight = nextTextSizeWithOverflowBreak.h + LABEL_PADDING * 2
labelWidth = nextTextSizeWithOverflowBreak.w + LABEL_PADDING * 2
break
}
if (nextTextSize.scrollWidth.toFixed(0) === nextTextSize.w.toFixed(0)) {
break
}
} while (iterations++ < 50)
return {
labelHeight,
labelWidth,
fontSizeAdjustment,
}
}
const labelSizesForNote = new WeakMapCache<TLShape, ReturnType<typeof getNoteLabelSize>>()
function getLabelSize(editor: Editor, shape: TLNoteShape) {
return labelSizesForNote.get(shape, () => getNoteLabelSize(editor, shape))
}
function useNoteKeydownHandler(id: TLShapeId) {
const editor = useEditor()
const translation = useCurrentTranslation()
return useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const shape = editor.getShape<TLNoteShape>(id)
if (!shape) return
const isTab = e.key === 'Tab'
const isCmdEnter = (e.metaKey || e.ctrlKey) && e.key === 'Enter'
if (isTab || isCmdEnter) {
e.preventDefault()
const pageTransform = editor.getShapePageTransform(id)
const pageRotation = pageTransform.rotation()
// Based on the inputs, calculate the offset to the next note
// tab controls x axis (shift inverts direction set by RTL)
// cmd enter is the y axis (shift inverts direction)
const isRTL = !!(translation.dir === 'rtl' || isRightToLeftLanguage(shape.props.text))
const offsetLength =
NOTE_SIZE +
ADJACENT_NOTE_MARGIN +
// If we're growing down, we need to account for the current shape's growY
(isCmdEnter && !e.shiftKey ? shape.props.growY : 0)
const adjacentCenter = new Vec(
isTab ? (e.shiftKey != isRTL ? -1 : 1) : 0,
isCmdEnter ? (e.shiftKey ? -1 : 1) : 0
)
.mul(offsetLength)
.add(NOTE_CENTER_OFFSET)
.rot(pageRotation)
.add(pageTransform.point())
const newNote = getNoteShapeForAdjacentPosition(editor, shape, adjacentCenter, pageRotation)
if (newNote) {
editor.mark('editing adjacent shape')
startEditingShapeWithLabel(editor, newNote, true /* selectAll */)
}
}
},
[id, editor, translation.dir]
)
}
function getNoteHeight(shape: TLNoteShape) {
return NOTE_SIZE + shape.props.growY
}
function getNoteShadow(id: string, rotation: number) {
const random = rng(id) // seeded based on id
const lift = Math.abs(random()) + 0.5 // 0 to 1.5
const oy = Math.cos(rotation)
return `0px ${5 - lift}px 5px -5px rgba(15, 23, 31, .6),
0px ${(4 + lift * 7) * Math.max(0, oy)}px ${6 + lift * 7}px -${4 + lift * 6}px rgba(15, 23, 31, ${(0.3 + lift * 0.1).toFixed(2)}),
0px 48px 10px -10px inset rgba(15, 23, 44, ${((0.022 + random() * 0.005) * ((1 + oy) / 2)).toFixed(2)})`
}

View file

@ -0,0 +1,295 @@
import { Box, Vec } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
// We don't want the camera to move when the shape gets created off screen
editor.updateViewportScreenBounds(new Box(0, 0, 2000, 2000))
})
afterEach(() => {
editor?.dispose()
})
function testCloneHandles(x: number, y: number, rotation: number) {
editor.createShape({ type: 'note', x, y, rotation })
const shape = editor.getLastCreatedShape()!
editor.select(shape.id)
const handles = editor.getShapeHandles(shape.id)!
const positions = [new Vec(0, -220), new Vec(220, 0), new Vec(0, 220), new Vec(-220, 0)].map(
(v) => v.rot(rotation).addXY(x, y)
)
handles.forEach((handle, i) => {
const handleInPageSpace = editor.getShapePageTransform(shape).applyToPoint(handle)
editor.select(shape.id)
editor.pointerMove(handleInPageSpace.x, handleInPageSpace.y)
expect(editor.inputs.currentPagePoint).toMatchObject({
x: handleInPageSpace.x,
y: handleInPageSpace.y,
})
editor.pointerDown(handleInPageSpace.x, handleInPageSpace.y, {
target: 'handle',
shape,
handle,
})
editor.expectToBeIn('select.pointing_handle')
editor.pointerUp()
const newShape = editor.getLastCreatedShape()
expect(newShape.id).not.toBe(shape.id)
const expectedPosition = positions[i]
editor.expectShapeToMatch({
id: newShape.id,
type: 'note',
x: expectedPosition.x,
y: expectedPosition.y,
})
editor.expectToBeIn('select.editing_shape')
editor.cancel().undo().forceTick()
})
}
describe('Note clone handles', () => {
it('Creates a new sticky note using handles', () => {
testCloneHandles(1000, 1000, 0)
})
it('Creates a new sticky when rotated', () => {
testCloneHandles(1000, 1000, Math.PI / 2)
})
it('Creates a new sticky when translated and rotated', () => {
testCloneHandles(1000, 1000, Math.PI / 2)
})
})
function testDragCloneHandles(x: number, y: number, rotation: number) {
editor.createShape({ type: 'note', x, y, rotation })
const shape = editor.getLastCreatedShape()!
editor.select(shape.id)
const handles = editor.getShapeHandles(shape.id)!
handles.forEach((handle) => {
const handleInPageSpace = editor.getShapePageTransform(shape).applyToPoint(handle)
editor.select(shape.id)
editor.pointerMove(handleInPageSpace.x, handleInPageSpace.y)
editor.pointerDown(handleInPageSpace.x, handleInPageSpace.y, {
target: 'handle',
shape,
handle,
})
editor.expectToBeIn('select.pointing_handle')
editor.pointerMove(handleInPageSpace.x + 30, handleInPageSpace.y + 30)
editor.expectToBeIn('select.translating')
const newShape = editor.getLastCreatedShape()
expect(newShape.id).not.toBe(shape.id)
const offset = new Vec(100, 100).rot(rotation)
editor.expectShapeToMatch({
id: newShape.id,
type: 'note',
x: handleInPageSpace.x + 30 - offset.x,
y: handleInPageSpace.y + 30 - offset.y,
})
editor.pointerUp()
editor.expectToBeIn('select.editing_shape')
editor.cancel().undo()
})
}
describe('Dragging clone handles', () => {
it('Creates a new sticky note using handles', () => {
testDragCloneHandles(1000, 1000, 0)
})
it('Creates a new sticky when rotated', () => {
testDragCloneHandles(1000, 1000, Math.PI / 2)
})
it('Creates a new sticky when translated and rotated', () => {
testDragCloneHandles(1000, 1000, Math.PI / 2)
})
})
it('Selects an adjacent note when clicking the clone handle', () => {
editor.createShape({ type: 'note', x: 1220, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeB = editor.getLastCreatedShape()!
editor.select(shapeB.id)
const handles = editor.getShapeHandles(shapeB.id)!
const handle = handles[1]
editor.select(shapeB.id)
editor.pointerDown(handle.x, handle.y, {
target: 'handle',
shape: shapeB,
handle,
})
editor.expectToBeIn('select.pointing_handle')
editor.pointerUp()
// Because there's a shape already in that direction...
// We didn't create a new shape; newShape is still shapeB
expect(editor.getLastCreatedShape().id).toBe(shapeB.id)
// the first shape is selected and we're editing it
expect(editor.getSelectedShapeIds()).toEqual([shapeA.id])
editor.expectToBeIn('select.editing_shape')
})
it('Creates an adjacent note when dragging the clone handle', () => {
editor.createShape({ type: 'note', x: 1220, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeB = editor.getLastCreatedShape()!
editor.select(shapeB.id)
const handles = editor.getShapeHandles(shapeB.id)!
const handle = handles[0]
editor.select(shapeB.id)
editor.pointerDown(handle.x, handle.y, {
target: 'handle',
shape: shapeB,
handle,
})
editor.expectToBeIn('select.pointing_handle')
editor.pointerMove(handle.x + 30, handle.y + 30)
const newShape = editor.getLastCreatedShape()
expect(newShape.id).not.toBe(shapeB.id)
expect(newShape.id).not.toBe(shapeA.id)
const offset = new Vec(100, 100).rot(0)
editor.expectShapeToMatch({
id: newShape.id,
type: 'note',
x: handle.x + 30 - offset.x,
y: handle.y + 30 - offset.y,
})
editor.pointerUp()
editor.expectToBeIn('select.editing_shape')
})
it('Does not put the new shape into a frame if its center is not in the frame', () => {
editor.createShape({ type: 'frame', x: 1321, y: 1000 }) // one pixel too far...
const frameA = editor.getLastCreatedShape()!
// center no longer in the frame
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
// to the right
const handle = editor.getShapeHandles(shapeA.id)![1]
editor
.select(shapeA.id)
.pointerDown(handle.x, handle.y, {
target: 'handle',
shape: shapeA,
handle,
})
.expectToBeIn('select.pointing_handle')
.pointerUp()
const newShape = editor.getLastCreatedShape()
// Should be a child of the frame
expect(newShape.parentId).not.toBe(frameA.id)
})
it('Puts the new shape into a frame based on its center', () => {
editor.createShape({ type: 'frame', x: 1320, y: 1100 })
const frameA = editor.getLastCreatedShape()!
// top left won't be in the frame, but the center will (barely but yes)
editor.createShape({ type: 'note', x: 1000, y: 1000 })
const shapeA = editor.getLastCreatedShape()!
// to the right
const handle = editor.getShapeHandles(shapeA.id)![1]
editor
.select(shapeA.id)
.pointerDown(handle.x, handle.y, {
target: 'handle',
shape: shapeA,
handle,
})
.expectToBeIn('select.pointing_handle')
.pointerUp()
const newShape = editor.getLastCreatedShape()
// Should be a child of the frame
expect(newShape.parentId).toBe(frameA.id)
})
function testNoteShapeFrameRotations(sourceRotation: number, rotation: number) {
editor.createShape({ type: 'frame', x: 1220, y: 1000, rotation: rotation })
const frameA = editor.getLastCreatedShape()!
// top left won't be in the frame, but the center will (barely but yes)
editor.createShape({ type: 'note', x: 1000, y: 1000, rotation: sourceRotation })
const shapeA = editor.getLastCreatedShape()!
// to the right
const handle = editor.getShapeHandles(shapeA.id)![1]
editor
.select(shapeA.id)
.pointerDown(handle.x, handle.y, {
target: 'handle',
shape: shapeA,
handle,
})
.expectToBeIn('select.pointing_handle')
.pointerUp()
const newShape = editor.getLastCreatedShape()
// Should be a child of the frame
expect(newShape.parentId).toBe(frameA.id)
expect(editor.getShapePageTransform(newShape).rotation()).toBeCloseTo(sourceRotation)
editor.cancel().undo()
}
it('Puts the new shape into a rotated frame and keeps the source page rotation', () => {
testNoteShapeFrameRotations(0, 0.01)
testNoteShapeFrameRotations(0.01, 0)
testNoteShapeFrameRotations(0.01, 0.01)
})

View file

@ -0,0 +1,199 @@
import { Editor, TLNoteShape, TLShape, Vec, compact, createShapeId } from '@tldraw/editor'
import { zoomToShapeIfOffscreen } from '../../tools/SelectTool/selectHelpers'
/** @internal */
export const ADJACENT_NOTE_MARGIN = 20
/** @internal */
export const CLONE_HANDLE_MARGIN = 0
/** @internal */
export const NOTE_SIZE = 200
/** @internal */
export const NOTE_CENTER_OFFSET = { x: NOTE_SIZE / 2, y: NOTE_SIZE / 2 }
/** @internal */
export const NOTE_PIT_RADIUS = 10
const DEFAULT_PITS = [
new Vec(NOTE_SIZE * 0.5, NOTE_SIZE * -0.5 - ADJACENT_NOTE_MARGIN), // t
new Vec(NOTE_SIZE * 1.5 + ADJACENT_NOTE_MARGIN, NOTE_SIZE * 0.5), // r
new Vec(NOTE_SIZE * 0.5, NOTE_SIZE * 1.5 + ADJACENT_NOTE_MARGIN), // b
new Vec(NOTE_SIZE * -0.5 - ADJACENT_NOTE_MARGIN, NOTE_SIZE * 0.5), // l
]
/**
* Get the adjacent positions for a particular note shape.
*
* @param pagePoint - The point of the note shape on the page.
* @param pageRotation - The rotation of the note shape on the page.
* @param growY - The growY of the note shape.
* @param extraHeight - The extra height to add to the top position above the note shape (ie the growY of the dragging shape).
*
* @internal */
export function getNoteAdjacentPositions(
pagePoint: Vec,
pageRotation: number,
growY: number,
extraHeight: number
) {
return DEFAULT_PITS.map((v, i) => {
const point = v.clone()
if (i === 0 && extraHeight) {
// apply top margin (the growY of the moving note shape)
point.y -= extraHeight
} else if (i === 2 && growY) {
// apply bottom margin (the growY of this note shape)
point.y += growY
}
return point.rot(pageRotation).add(pagePoint)
})
}
/**
* Get all of the available note adjacent positions, excluding the selected shapes.
*
* @param editor - The editor instance.
* @param rotation - The rotation of the note shape.
* @param extraHeight - The extra height to add to the top position above the note shape (ie the growY of the dragging shape).
*
* @internal */
export function getAvailableNoteAdjacentPositions(
editor: Editor,
rotation: number,
extraHeight: number
) {
const selectedShapeIds = new Set(editor.getSelectedShapeIds())
const minSize = (NOTE_SIZE + ADJACENT_NOTE_MARGIN + extraHeight) ** 2
const allCenters = new Map<TLNoteShape, Vec>()
const positions: (Vec | undefined)[] = []
// Get all the positions that are adjacent to the selected note shapes
for (const shape of editor.getCurrentPageShapes()) {
if (!editor.isShapeOfType<TLNoteShape>(shape, 'note') || selectedShapeIds.has(shape.id)) {
continue
}
const transform = editor.getShapePageTransform(shape.id)!
// If the note has a different rotation, we can't use its adjacent positions
if (rotation !== transform.rotation()) continue
// Save the unselected note shape's center
allCenters.set(shape, editor.getShapePageBounds(shape)!.center)
// And push its position to the positions array
positions.push(
...getNoteAdjacentPositions(transform.point(), rotation, shape.props.growY, extraHeight)
)
}
// Remove positions that are inside of another note shape
const len = positions.length
let position: Vec | undefined
for (const [shape, center] of allCenters) {
for (let i = 0; i < len; i++) {
position = positions[i]
if (!position) continue
if (Vec.Dist2(center, position) > minSize) continue
if (editor.isPointInShape(shape, position)) {
positions[i] = undefined
}
}
}
return compact(positions)
}
/**
* For a particular adjacent note position, get the shape in that position or create a new one.
*
* @param editor - The editor instance.
* @param shape - The note shape to create or select.
* @param center - The center of the note shape.
* @param pageRotation - The rotation of the note shape on the page.
* @param forceNew - Whether to force the creation of a new note shape.
*
* @internal */
export function getNoteShapeForAdjacentPosition(
editor: Editor,
shape: TLNoteShape,
center: Vec,
pageRotation: number,
forceNew = false
) {
// There might already be a note in that position! If there is, we'll
// select the next note and switch focus to it. If there's not, then
// we'll create a new note in that position.
let nextNote: TLShape | undefined
// Check the center of where a new note would be
// Start from the top of the stack, and work our way down
const allShapesOnPage = editor.getCurrentPageShapesSorted()
const minDistance = NOTE_SIZE + ADJACENT_NOTE_MARGIN ** 2
for (let i = allShapesOnPage.length - 1; i >= 0; i--) {
const otherNote = allShapesOnPage[i]
if (otherNote.type === 'note' && otherNote.id !== shape.id) {
const otherBounds = editor.getShapePageBounds(otherNote)
if (
otherBounds &&
Vec.Dist2(otherBounds.center, center) < minDistance &&
editor.isPointInShape(otherNote, center)
) {
nextNote = otherNote
break
}
}
}
editor.complete()
// If we didn't find any in that position, then create a new one
if (!nextNote || forceNew) {
editor.mark('creating note shape')
const id = createShapeId()
// We create it at the center first, so that it becomes
// the child of whatever parent was at that center
editor.createShape({
id,
type: 'note',
x: center.x,
y: center.y,
rotation: pageRotation,
opacity: shape.opacity,
props: {
// Use the props of the shape we're cloning
...shape.props,
// ...except for these values, which should reset to their defaults
text: '',
growY: 0,
fontSizeAdjustment: 0,
url: '',
},
})
// Now we need to correct its location within its new parent
const createdShape = editor.getShape(id)!
// We need to put the page point in the same coordinate
// space as the newly created shape (i.e its parent's space)
const topLeft = editor.getPointInParentSpace(
createdShape,
Vec.Sub(center, Vec.Rot(NOTE_CENTER_OFFSET, pageRotation))
)
editor.updateShape({
id,
type: 'note',
x: topLeft.x,
y: topLeft.y,
})
nextNote = editor.getShape(id)!
}
zoomToShapeIfOffscreen(editor)
return nextNote
}

View file

@ -1,11 +1,15 @@
import { import {
Editor,
StateNode, StateNode,
TLEventHandlers, TLEventHandlers,
TLInterruptEvent, TLInterruptEvent,
TLNoteShape, TLNoteShape,
TLPointerEventInfo, TLPointerEventInfo,
TLShapeId,
Vec,
createShapeId, createShapeId,
} from '@tldraw/editor' } from '@tldraw/editor'
import { NOTE_PIT_RADIUS, getAvailableNoteAdjacentPositions } from '../noteHelpers'
export class Pointing extends StateNode { export class Pointing extends StateNode {
static override id = 'pointing' static override id = 'pointing'
@ -21,16 +25,35 @@ export class Pointing extends StateNode {
shape = {} as TLNoteShape shape = {} as TLNoteShape
override onEnter = () => { override onEnter = () => {
this.wasFocusedOnEnter = !this.editor.getIsMenuOpen() const { editor } = this
this.wasFocusedOnEnter = !editor.getIsMenuOpen()
if (this.wasFocusedOnEnter) { if (this.wasFocusedOnEnter) {
this.shape = this.createShape() const id = createShapeId()
this.markId = `creating:${id}`
editor.mark(this.markId)
// Check for note pits; if the pointer is close to one, place the note centered on the pit
const center = this.editor.inputs.originPagePoint.clone()
const offset = getNotePitOffset(this.editor, center)
if (offset) {
center.sub(offset)
}
this.shape = createSticky(this.editor, id, center)
} }
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) { if (this.editor.inputs.isDragging) {
if (!this.wasFocusedOnEnter) { if (!this.wasFocusedOnEnter) {
this.shape = this.createShape() const id = createShapeId()
const center = this.editor.inputs.originPagePoint.clone()
const offset = getNotePitOffset(this.editor, center)
if (offset) {
center.sub(offset)
}
this.shape = createSticky(this.editor, id, center)
} }
this.editor.setCurrentTool('select.translating', { this.editor.setCurrentTool('select.translating', {
@ -82,32 +105,38 @@ export class Pointing extends StateNode {
this.editor.bailToMark(this.markId) this.editor.bailToMark(this.markId)
this.parent.transition('idle', this.info) this.parent.transition('idle', this.info)
} }
}
private createShape() { export function getNotePitOffset(editor: Editor, center: Vec) {
const { let min = NOTE_PIT_RADIUS / editor.getZoomLevel() // in screen space
inputs: { originPagePoint }, let offset: Vec | undefined
} = this.editor for (const pit of getAvailableNoteAdjacentPositions(editor, 0, 0)) {
// only check page rotations of zero
const deltaToPit = Vec.Sub(center, pit)
const dist = deltaToPit.len()
if (dist < min) {
min = dist
offset = deltaToPit
}
}
return offset
}
const id = createShapeId() export function createSticky(editor: Editor, id: TLShapeId, center: Vec) {
this.markId = `creating:${id}` editor
this.editor.mark(this.markId) .createShape({
this.editor
.createShapes([
{
id, id,
type: 'note', type: 'note',
x: originPagePoint.x, x: center.x,
y: originPagePoint.y, y: center.y,
}, })
])
.select(id) .select(id)
const shape = this.editor.getShape<TLNoteShape>(id)! const shape = editor.getShape<TLNoteShape>(id)!
const bounds = this.editor.getShapeGeometry(shape).bounds const bounds = editor.getShapeGeometry(shape).bounds
// Center the text around the created point // Center the text around the created point
this.editor.updateShapes([ editor.updateShapes([
{ {
id, id,
type: 'note', type: 'note',
@ -116,6 +145,5 @@ export class Pointing extends StateNode {
}, },
]) ])
return this.editor.getShape<TLNoteShape>(id)! return editor.getShape<TLNoteShape>(id)!
}
} }

View file

@ -1,7 +1,6 @@
import { import {
Box, Box,
DefaultFontFamilies, DefaultFontFamilies,
TLDefaultColorStyle,
TLDefaultFontStyle, TLDefaultFontStyle,
TLDefaultHorizontalAlignStyle, TLDefaultHorizontalAlignStyle,
TLDefaultVerticalAlignStyle, TLDefaultVerticalAlignStyle,
@ -30,7 +29,7 @@ export function SvgTextLabel({
verticalAlign: TLDefaultVerticalAlignStyle verticalAlign: TLDefaultVerticalAlignStyle
wrap?: boolean wrap?: boolean
text: string text: string
labelColor: TLDefaultColorStyle labelColor: string
bounds: Box bounds: Box
padding?: number padding?: number
stroke?: boolean stroke?: boolean
@ -52,7 +51,7 @@ export function SvgTextLabel({
overflow: 'wrap' as const, overflow: 'wrap' as const,
offsetX: 0, offsetX: 0,
offsetY: 0, offsetY: 0,
fill: theme[labelColor].solid, fill: labelColor,
stroke: undefined as string | undefined, stroke: undefined as string | undefined,
strokeWidth: undefined as number | undefined, strokeWidth: undefined as number | undefined,
} }

View file

@ -1,15 +1,12 @@
import { import {
Box, Box,
TLDefaultColorStyle,
TLDefaultFillStyle, TLDefaultFillStyle,
TLDefaultFontStyle, TLDefaultFontStyle,
TLDefaultHorizontalAlignStyle, TLDefaultHorizontalAlignStyle,
TLDefaultVerticalAlignStyle, TLDefaultVerticalAlignStyle,
TLShapeId, TLShapeId,
getDefaultColorTheme,
useIsDarkMode,
} from '@tldraw/editor' } from '@tldraw/editor'
import React from 'react' import React, { useEffect, useState } from 'react'
import { TextArea } from '../text/TextArea' import { TextArea } from '../text/TextArea'
import { TextHelpers } from './TextHelpers' import { TextHelpers } from './TextHelpers'
import { isLegacyAlign } from './legacyProps' import { isLegacyAlign } from './legacyProps'
@ -26,8 +23,12 @@ type TextLabelProps = {
verticalAlign: TLDefaultVerticalAlignStyle verticalAlign: TLDefaultVerticalAlignStyle
wrap?: boolean wrap?: boolean
text: string text: string
labelColor: TLDefaultColorStyle labelColor: string
bounds?: Box bounds?: Box
isNote?: boolean
isSelected: boolean
disableTab?: boolean
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
classNamePrefix?: string classNamePrefix?: string
style?: React.CSSProperties style?: React.CSSProperties
textWidth?: number textWidth?: number
@ -46,19 +47,31 @@ export const TextLabel = React.memo(function TextLabel({
align, align,
verticalAlign, verticalAlign,
wrap, wrap,
bounds, isSelected,
onKeyDown: handleKeyDownCustom,
classNamePrefix, classNamePrefix,
style, style,
disableTab = false,
textWidth, textWidth,
textHeight, textHeight,
}: TextLabelProps) { }: TextLabelProps) {
const { rInput, isEmpty, isEditing, ...editableTextRest } = useEditableText(id, type, text) const { rInput, isEmpty, isEditing, isEditingAnything, ...editableTextRest } = useEditableText(
id,
type,
text,
{ disableTab }
)
const [initialText, setInitialText] = useState(text)
useEffect(() => {
if (!isEditing) setInitialText(text)
}, [isEditing, text])
const finalText = TextHelpers.normalizeTextForDom(text) const finalText = TextHelpers.normalizeTextForDom(text)
const hasText = finalText.length > 0 const hasText = finalText.length > 0
const legacyAlign = isLegacyAlign(align) const legacyAlign = isLegacyAlign(align)
const theme = getDefaultColorTheme({ isDarkMode: useIsDarkMode() })
if (!isEditing && !hasText) { if (!isEditing && !hasText) {
return null return null
@ -73,19 +86,12 @@ export const TextLabel = React.memo(function TextLabel({
data-align={align} data-align={align}
data-hastext={!isEmpty} data-hastext={!isEmpty}
data-isediting={isEditing} data-isediting={isEditing}
data-iseditinganything={isEditingAnything}
data-textwrap={!!wrap} data-textwrap={!!wrap}
data-isselected={isSelected}
style={{ style={{
justifyContent: align === 'middle' || legacyAlign ? 'center' : align, justifyContent: align === 'middle' || legacyAlign ? 'center' : align,
alignItems: verticalAlign === 'middle' ? 'center' : verticalAlign, alignItems: verticalAlign === 'middle' ? 'center' : verticalAlign,
...(bounds
? {
top: bounds.minY,
left: bounds.minX,
width: bounds.width,
height: bounds.height,
position: 'absolute',
}
: {}),
...style, ...style,
}} }}
> >
@ -96,7 +102,7 @@ export const TextLabel = React.memo(function TextLabel({
lineHeight: fontSize * lineHeight + 'px', lineHeight: fontSize * lineHeight + 'px',
minHeight: lineHeight + 32, minHeight: lineHeight + 32,
minWidth: textWidth || 0, minWidth: textWidth || 0,
color: theme[labelColor].solid, color: labelColor,
width: textWidth, width: textWidth,
height: textHeight, height: textHeight,
}} }}
@ -104,7 +110,18 @@ export const TextLabel = React.memo(function TextLabel({
<div className={`${cssPrefix} tl-text tl-text-content`} dir="ltr"> <div className={`${cssPrefix} tl-text tl-text-content`} dir="ltr">
{finalText} {finalText}
</div> </div>
{isEditing && <TextArea ref={rInput} text={text} {...editableTextRest} />} {(isEditingAnything || isSelected) && (
<TextArea
ref={rInput}
// We need to add the initial value as the key here because we need this component to
// 'reset' when this state changes and grab the latest defaultValue.
key={initialText}
text={text}
isEditing={isEditing}
{...editableTextRest}
handleKeyDown={handleKeyDownCustom ?? editableTextRest.handleKeyDown}
/>
)}
</div> </div>
</div> </div>
) )

View file

@ -1,11 +1,9 @@
/* eslint-disable no-inner-declarations */
import { import {
TLShape,
TLShapeId, TLShapeId,
TLUnknownShape, TLUnknownShape,
getPointerInfo, getPointerInfo,
preventDefault, preventDefault,
setPointerCapture,
stopEventPropagation, stopEventPropagation,
useEditor, useEditor,
useValue, useValue,
@ -14,45 +12,86 @@ import React, { useCallback, useEffect, useRef } from 'react'
import { INDENT, TextHelpers } from './TextHelpers' import { INDENT, TextHelpers } from './TextHelpers'
/** @public */ /** @public */
export function useEditableText(id: TLShapeId, type: string, text: string) { export function useEditableText(
id: TLShapeId,
type: string,
text: string,
opts = { disableTab: false } as { disableTab: boolean }
) {
const editor = useEditor() const editor = useEditor()
const rInput = useRef<HTMLTextAreaElement>(null) const rInput = useRef<HTMLTextAreaElement>(null)
const rSkipSelectOnFocus = useRef(false)
const rSelectionRanges = useRef<Range[] | null>()
const isEditing = useValue('isEditing', () => editor.getEditingShapeId() === id, [editor, id]) const isEditing = useValue(
'isEditing',
() => {
return editor.getEditingShapeId() === id
},
[editor]
)
const isEditingAnything = useValue(
'isEditingAnything',
() => {
return editor.getEditingShapeId() !== null
},
[editor]
)
// If the shape is editing but the input element not focused, focus the element
useEffect(() => { useEffect(() => {
function selectAllIfEditing({ shapeId }: { shapeId: TLShapeId }) {
if (shapeId === id) {
const elm = rInput.current const elm = rInput.current
if (elm && isEditing && document.activeElement !== elm) { if (elm) {
if (document.activeElement !== elm) {
elm.focus() elm.focus()
} }
}, [isEditing])
// When the label receives focus, set the value to the most recent text value and select all of the text
const handleFocus = useCallback(() => {
// Store and turn off the skipSelectOnFocus flag
const skipSelect = rSkipSelectOnFocus.current
rSkipSelectOnFocus.current = false
// On the next frame, if we're not skipping select AND we have text in the element, then focus the text
requestAnimationFrame(() => {
const elm = rInput.current
if (!elm) return
const shape = editor.getShape<TLShape & { props: { text: string } }>(id)
if (shape) {
elm.value = shape.props.text
if (elm.value.length && !skipSelect) {
elm.select() elm.select()
} }
} }
}) }
editor.on('select-all-text', selectAllIfEditing)
return () => {
editor.off('select-all-text', selectAllIfEditing)
}
}, [editor, id]) }, [editor, id])
const rSelectionRanges = useRef<Range[] | null>()
useEffect(() => {
if (!isEditing) return
const elm = rInput.current
if (!elm) return
// Focus if we're not already focused
if (document.activeElement !== elm) {
elm.focus()
// On mobile etc, just select all the text when we start focusing
if (editor.getInstanceState().isCoarsePointer) {
elm.select()
}
}
// When the selection changes, save the selection ranges
function updateSelection() {
const selection = window.getSelection?.()
if (selection && selection.type !== 'None') {
const ranges: Range[] = []
for (let i = 0; i < selection.rangeCount; i++) {
ranges.push(selection.getRangeAt?.(i))
}
rSelectionRanges.current = ranges
}
}
document.addEventListener('selectionchange', updateSelection)
return () => {
document.removeEventListener('selectionchange', updateSelection)
}
}, [editor, isEditing])
// 2. Restore the selection changes (and focus) if the element blurs
// When the label blurs, deselect all of the text and complete. // When the label blurs, deselect all of the text and complete.
// This makes it so that the canvas does not have to be focused // This makes it so that the canvas does not have to be focused
// in order to exit the editing state and complete the editing state // in order to exit the editing state and complete the editing state
@ -63,36 +102,28 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
const elm = rInput.current const elm = rInput.current
const editingShapeId = editor.getEditingShapeId() const editingShapeId = editor.getEditingShapeId()
// Did we move to a different shape? // Did we move to a different shape?
if (editingShapeId) {
// important! these ^v are two different things // important! these ^v are two different things
// is that shape OUR shape? // is that shape OUR shape?
if (elm && editingShapeId === id) { if (elm && editingShapeId === id) {
if (ranges) {
if (!ranges.length) {
// If we don't have any ranges, restore selection
// and select all of the text
elm.focus()
} else {
// Otherwise, skip the select-all-on-focus behavior
// and restore the selection
rSkipSelectOnFocus.current = true
elm.focus() elm.focus()
if (ranges && ranges.length) {
const selection = window.getSelection() const selection = window.getSelection()
if (selection) { if (selection) {
ranges.forEach((range) => selection.addRange(range)) ranges.forEach((range) => selection.addRange(range))
} }
} }
} else {
elm.focus()
} }
} else {
window.getSelection()?.removeAllRanges()
} }
}) })
}, [editor, id]) }, [editor, id])
// When the user presses ctrl / meta enter, complete the editing state. // When the user presses ctrl / meta enter, complete the editing state.
// When the user presses tab, indent or unindent the text.
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!isEditing) return if (editor.getEditingShapeId() !== id) return
switch (e.key) { switch (e.key) {
case 'Enter': { case 'Enter': {
@ -102,23 +133,25 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
break break
} }
case 'Tab': { case 'Tab': {
if (!opts.disableTab) {
preventDefault(e) preventDefault(e)
if (e.shiftKey) { if (e.shiftKey) {
TextHelpers.unindent(e.currentTarget) TextHelpers.unindent(e.currentTarget)
} else { } else {
TextHelpers.indent(e.currentTarget) TextHelpers.indent(e.currentTarget)
} }
}
break break
} }
} }
}, },
[editor, isEditing] [editor, id, opts.disableTab]
) )
// When the text changes, update the text value. // When the text changes, update the text value.
const handleChange = useCallback( const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => { (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!isEditing) return if (editor.getEditingShapeId() !== id) return
let text = TextHelpers.normalizeText(e.currentTarget.value) let text = TextHelpers.normalizeText(e.currentTarget.value)
@ -134,43 +167,15 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
} }
// ---------------------------- // ----------------------------
editor.updateShapes<TLUnknownShape & { props: { text: string } }>([ editor.updateShape<TLUnknownShape & { props: { text: string } }>({
{ id, type, props: { text } }, id,
]) type,
props: { text },
})
}, },
[editor, id, type, isEditing] [editor, id, type]
) )
const isEmpty = text.trim().length === 0
useEffect(() => {
if (!isEditing) return
const elm = rInput.current
if (elm) {
function updateSelection() {
const selection = window.getSelection?.()
if (selection && selection.type !== 'None') {
const ranges: Range[] = []
if (selection) {
for (let i = 0; i < selection.rangeCount; i++) {
ranges.push(selection.getRangeAt?.(i))
}
}
rSelectionRanges.current = ranges
}
}
document.addEventListener('selectionchange', updateSelection)
return () => {
document.removeEventListener('selectionchange', updateSelection)
}
}
}, [isEditing])
const handleInputPointerDown = useCallback( const handleInputPointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
editor.dispatch({ editor.dispatch({
@ -182,6 +187,12 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
}) })
stopEventPropagation(e) // we need to prevent blurring the input stopEventPropagation(e) // we need to prevent blurring the input
// This is important so that when dragging a shape using the text label,
// the shape continues to be dragged, even if the cursor is over the UI.
if (editor.getEditingShapeId() !== id) {
setPointerCapture(e.currentTarget, e)
}
}, },
[editor, id] [editor, id]
) )
@ -190,13 +201,18 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
return { return {
rInput, rInput,
isEditing, handleFocus: noop,
handleFocus,
handleBlur, handleBlur,
handleKeyDown, handleKeyDown,
handleChange, handleChange,
handleInputPointerDown, handleInputPointerDown,
handleDoubleClick, handleDoubleClick,
isEmpty, isEmpty: text.trim().length === 0,
isEditing,
isEditingAnything,
} }
} }
function noop() {
return
}

View file

@ -1,7 +1,8 @@
import { stopEventPropagation } from '@tldraw/editor' import { preventDefault, stopEventPropagation } from '@tldraw/editor'
import { forwardRef } from 'react' import { forwardRef } from 'react'
type TextAreaProps = { type TextAreaProps = {
isEditing: boolean
text: string text: string
handleFocus: () => void handleFocus: () => void
handleBlur: () => void handleBlur: () => void
@ -13,6 +14,7 @@ type TextAreaProps = {
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea( export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
{ {
isEditing,
text, text,
handleFocus, handleFocus,
handleChange, handleChange,
@ -29,11 +31,12 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
className="tl-text tl-text-input" className="tl-text tl-text-input"
name="text" name="text"
tabIndex={-1} tabIndex={-1}
readOnly={!isEditing}
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
autoSave="off" autoSave="off"
autoFocus // autoFocus
placeholder="" placeholder=""
spellCheck="true" spellCheck="true"
wrap="off" wrap="off"
@ -45,9 +48,14 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleBlur}
onTouchEnd={stopEventPropagation} onTouchEnd={stopEventPropagation}
onContextMenu={stopEventPropagation} onContextMenu={isEditing ? stopEventPropagation : undefined}
onPointerDown={handleInputPointerDown} onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
// On FF, there's a behavior where dragging a selection will grab that selection into
// the drag event. However, once the drag is over, and you select away from the textarea,
// starting a drag over the textarea will restart a selection drag instead of a shape drag.
// This prevents that default behavior in FF.
onDragStart={preventDefault}
/> />
) )
}) })

View file

@ -2,7 +2,6 @@
import { import {
Box, Box,
Editor, Editor,
HTMLContainer,
Rectangle2d, Rectangle2d,
ShapeUtil, ShapeUtil,
SvgExportContext, SvgExportContext,
@ -12,11 +11,13 @@ import {
TLTextShape, TLTextShape,
Vec, Vec,
WeakMapCache, WeakMapCache,
getDefaultColorTheme,
textShapeMigrations, textShapeMigrations,
textShapeProps, textShapeProps,
toDomPrecision, toDomPrecision,
useEditor, useEditor,
} from '@tldraw/editor' } from '@tldraw/editor'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel' import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel' import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants' import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
@ -55,6 +56,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
width: width * scale, width: width * scale,
height: height * scale, height: height * scale,
isFilled: true, isFilled: true,
isLabel: true,
}) })
} }
@ -69,9 +71,10 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
} = shape } = shape
const { width, height } = this.getMinDimensions(shape) const { width, height } = this.getMinDimensions(shape)
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const theme = useDefaultColorTheme()
return ( return (
<HTMLContainer id={shape.id}>
<TextLabel <TextLabel
id={id} id={id}
classNamePrefix="tl-text-shape" classNamePrefix="tl-text-shape"
@ -82,7 +85,8 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
align={align} align={align}
verticalAlign="middle" verticalAlign="middle"
text={text} text={text}
labelColor={color} labelColor={theme[color].solid}
isSelected={isSelected}
textWidth={width} textWidth={width}
textHeight={height} textHeight={height}
style={{ style={{
@ -91,7 +95,6 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
}} }}
wrap wrap
/> />
</HTMLContainer>
) )
} }
@ -110,6 +113,8 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
const width = bounds.width / (shape.props.scale ?? 1) const width = bounds.width / (shape.props.scale ?? 1)
const height = bounds.height / (shape.props.scale ?? 1) const height = bounds.height / (shape.props.scale ?? 1)
const theme = getDefaultColorTheme(ctx)
return ( return (
<SvgTextLabel <SvgTextLabel
fontSize={FONT_SIZES[shape.props.size]} fontSize={FONT_SIZES[shape.props.size]}
@ -117,7 +122,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
align={shape.props.align} align={shape.props.align}
verticalAlign="middle" verticalAlign="middle"
text={shape.props.text} text={shape.props.text}
labelColor={shape.props.color} labelColor={theme[shape.props.color].solid}
bounds={new Box(0, 0, width, height)} bounds={new Box(0, 0, width, height)}
padding={0} padding={0}
/> />

View file

@ -1,6 +1,8 @@
import { Editor, TLShape, TLShapeId, Vec, compact } from '@tldraw/editor' import { Editor, TLShape, TLShapeId, Vec, compact } from '@tldraw/editor'
import { getOccludedChildren } from './selectHelpers'
const LAG_DURATION = 100 const INITIAL_POINTER_LAG_DURATION = 20
const FAST_POINTER_LAG_DURATION = 100
/** @public */ /** @public */
export class DragAndDropManager { export class DragAndDropManager {
@ -16,6 +18,12 @@ export class DragAndDropManager {
updateDroppingNode(movingShapes: TLShape[], cb: () => void) { updateDroppingNode(movingShapes: TLShape[], cb: () => void) {
if (this.first) { if (this.first) {
this.editor.setHintingShapes(
movingShapes
.map((s) => this.editor.findShapeAncestor(s, (v) => v.type !== 'group'))
.filter((s) => s) as TLShape[]
)
this.prevDroppingShapeId = this.prevDroppingShapeId =
this.editor.getDroppingOverShape(this.editor.inputs.originPagePoint, movingShapes)?.id ?? this.editor.getDroppingOverShape(this.editor.inputs.originPagePoint, movingShapes)?.id ??
null null
@ -23,10 +31,10 @@ export class DragAndDropManager {
} }
if (this.droppingNodeTimer === null) { if (this.droppingNodeTimer === null) {
this.setDragTimer(movingShapes, LAG_DURATION * 10, cb) this.setDragTimer(movingShapes, INITIAL_POINTER_LAG_DURATION, cb)
} else if (this.editor.inputs.pointerVelocity.len() > 0.5) { } else if (this.editor.inputs.pointerVelocity.len() > 0.5) {
clearInterval(this.droppingNodeTimer) clearInterval(this.droppingNodeTimer)
this.setDragTimer(movingShapes, LAG_DURATION, cb) this.setDragTimer(movingShapes, FAST_POINTER_LAG_DURATION, cb)
} }
} }
@ -46,6 +54,7 @@ export class DragAndDropManager {
// is the next dropping shape id different than the last one? // is the next dropping shape id different than the last one?
if (nextDroppingShapeId === this.prevDroppingShapeId) { if (nextDroppingShapeId === this.prevDroppingShapeId) {
this.hintParents(movingShapes)
return return
} }
@ -64,24 +73,46 @@ export class DragAndDropManager {
} }
if (nextDroppingShape) { if (nextDroppingShape) {
const res = this.editor this.editor
.getShapeUtil(nextDroppingShape) .getShapeUtil(nextDroppingShape)
.onDragShapesOver?.(nextDroppingShape, movingShapes) .onDragShapesOver?.(nextDroppingShape, movingShapes)
if (res && res.shouldHint) {
this.editor.setHintingShapes([nextDroppingShape.id])
}
} else {
// If we're dropping onto the page, then clear hinting ids
this.editor.setHintingShapes([])
} }
this.hintParents(movingShapes)
cb?.() cb?.()
// next -> curr // next -> curr
this.prevDroppingShapeId = nextDroppingShapeId this.prevDroppingShapeId = nextDroppingShapeId
} }
hintParents(movingShapes: TLShape[]) {
// Group moving shapes by their ancestor
const shapesGroupedByAncestor = new Map<TLShapeId, TLShapeId[]>()
for (const shape of movingShapes) {
const ancestor = this.editor.findShapeAncestor(shape, (v) => v.type !== 'group')
if (!ancestor) continue
if (!shapesGroupedByAncestor.has(ancestor.id)) {
shapesGroupedByAncestor.set(ancestor.id, [])
}
shapesGroupedByAncestor.get(ancestor.id)!.push(shape.id)
}
// Only hint an ancestor if some shapes will drop into it on pointer up
const hintingShapes = []
for (const [ancestorId, shapeIds] of shapesGroupedByAncestor) {
const ancestor = this.editor.getShape(ancestorId)
if (!ancestor) continue
// If all of the ancestor's children would be occluded, then don't hint it
// 1. get the number of fully occluded children
// 2. if that number is less than the number of moving shapes, hint the ancestor
if (getOccludedChildren(this.editor, ancestor).length < shapeIds.length) {
hintingShapes.push(ancestor.id)
}
}
this.editor.setHintingShapes(hintingShapes)
}
dropShapes(shapes: TLShape[]) { dropShapes(shapes: TLShape[]) {
const { prevDroppingShapeId } = this const { prevDroppingShapeId } = this

View file

@ -11,6 +11,7 @@ import {
Vec, Vec,
structuredClone, structuredClone,
} from '@tldraw/editor' } from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
import { MIN_CROP_SIZE } from './Crop/crop-constants' import { MIN_CROP_SIZE } from './Crop/crop-constants'
import { CursorTypeMap } from './PointingResizeHandle' import { CursorTypeMap } from './PointingResizeHandle'
@ -206,6 +207,7 @@ export class Cropping extends StateNode {
private complete() { private complete() {
this.updateShapes() this.updateShapes()
kickoutOccludedShapes(this.editor, [this.snapshot.shape.id])
if (this.info.onInteractionEnd) { if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {

View file

@ -16,6 +16,7 @@ import {
sortByIndex, sortByIndex,
structuredClone, structuredClone,
} from '@tldraw/editor' } from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
export class DraggingHandle extends StateNode { export class DraggingHandle extends StateNode {
static override id = 'dragging_handle' static override id = 'dragging_handle'
@ -203,6 +204,7 @@ export class DraggingHandle extends StateNode {
private complete() { private complete() {
this.editor.snaps.clearIndicators() this.editor.snaps.clearIndicators()
kickoutOccludedShapes(this.editor, [this.shapeId])
const { onInteractionEnd } = this.info const { onInteractionEnd } = this.info
if (this.editor.getInstanceState().isToolLocked && onInteractionEnd) { if (this.editor.getInstanceState().isToolLocked && onInteractionEnd) {

View file

@ -1,20 +1,17 @@
import { import { StateNode, TLEventHandlers, TLFrameShape, TLShape, TLTextShape } from '@tldraw/editor'
Group2d, import { getTextLabels } from '../../../utils/shapes/shapes'
StateNode,
TLArrowShape,
TLEventHandlers,
TLFrameShape,
TLGeoShape,
} from '@tldraw/editor'
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown' import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
import { updateHoveredId } from '../../selection-logic/updateHoveredId' import { updateHoveredId } from '../../selection-logic/updateHoveredId'
export class EditingShape extends StateNode { export class EditingShape extends StateNode {
static override id = 'editing_shape' static override id = 'editing_shape'
hitShapeForPointerUp: TLShape | null = null
override onEnter = () => { override onEnter = () => {
const editingShape = this.editor.getEditingShape() const editingShape = this.editor.getEditingShape()
if (!editingShape) throw Error('Entered editing state without an editing shape') if (!editingShape) throw Error('Entered editing state without an editing shape')
this.hitShapeForPointerUp = null
updateHoveredId(this.editor) updateHoveredId(this.editor)
this.editor.select(editingShape) this.editor.select(editingShape)
} }
@ -34,6 +31,16 @@ export class EditingShape extends StateNode {
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
// In the case where on pointer down we hit a shape's label, we need to check if the user is dragging.
// and if they are, we need to transition to translating instead.
if (this.hitShapeForPointerUp && this.editor.inputs.isDragging) {
if (this.editor.getInstanceState().isReadonly) return
this.editor.select(this.hitShapeForPointerUp)
this.parent.transition('translating', info)
this.hitShapeForPointerUp = null
return
}
switch (info.target) { switch (info.target) {
case 'shape': case 'shape':
case 'canvas': { case 'canvas': {
@ -42,7 +49,10 @@ export class EditingShape extends StateNode {
} }
} }
} }
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => { override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
this.hitShapeForPointerUp = null
switch (info.target) { switch (info.target) {
case 'canvas': { case 'canvas': {
const hitShape = getHitShapeOnCanvasPointerDown(this.editor) const hitShape = getHitShapeOnCanvasPointerDown(this.editor)
@ -57,42 +67,50 @@ export class EditingShape extends StateNode {
break break
} }
case 'shape': { case 'shape': {
const { shape } = info const { shape: selectingShape } = info
const editingShape = this.editor.getEditingShape() const editingShape = this.editor.getEditingShape()
if (!editingShape) { if (!editingShape) {
throw Error('Expected an editing shape!') throw Error('Expected an editing shape!')
} }
if (shape.type === editingShape.type) {
// clicked a shape of the same type as the editing shape
if (
this.editor.isShapeOfType<TLGeoShape>(shape, 'geo') ||
this.editor.isShapeOfType<TLArrowShape>(shape, 'arrow')
) {
// for shapes with labels, check to see if the click was inside of the shape's label // for shapes with labels, check to see if the click was inside of the shape's label
const geometry = this.editor.getShapeUtil(shape).getGeometry(shape) as Group2d const geometry = this.editor.getShapeUtil(selectingShape).getGeometry(selectingShape)
const labelGeometry = geometry.children[1] const textLabels = getTextLabels(geometry)
if (labelGeometry) { const textLabel = textLabels.length === 1 ? textLabels[0] : undefined
// N.B. One nuance here is that we want empty text fields to be removed from the canvas when the user clicks away from them.
const isEmptyTextShape =
this.editor.isShapeOfType<TLTextShape>(editingShape, 'text') &&
editingShape.props.text.trim() === ''
if (textLabel && !isEmptyTextShape) {
const pointInShapeSpace = this.editor.getPointInShapeSpace( const pointInShapeSpace = this.editor.getPointInShapeSpace(
shape, selectingShape,
this.editor.inputs.currentPagePoint this.editor.inputs.currentPagePoint
) )
if (labelGeometry.bounds.containsPoint(pointInShapeSpace)) { if (
textLabel.bounds.containsPoint(pointInShapeSpace, 0) &&
textLabel.hitTestPoint(pointInShapeSpace)
) {
// it's a hit to the label! // it's a hit to the label!
if (shape.id === editingShape.id) { if (selectingShape.id === editingShape.id) {
// If we clicked on the editing geo / arrow shape's label, do nothing // If we clicked on the editing geo / arrow shape's label, do nothing
return return
} else { } else {
this.parent.transition('pointing_shape', info) this.hitShapeForPointerUp = selectingShape
this.editor.mark('editing on pointer up')
this.editor.select(selectingShape.id)
// When clicking on a different shape's label, we need to clear the other selection
// proactively until the pointer up happens.
requestAnimationFrame(() => window.getSelection()?.removeAllRanges())
return return
} }
} }
}
} else { } else {
if (shape.id === editingShape.id) { if (selectingShape.id === editingShape.id) {
// If we clicked on a frame, while editing its heading, cancel editing // If we clicked on a frame, while editing its heading, cancel editing
if (this.editor.isShapeOfType<TLFrameShape>(shape, 'frame')) { if (this.editor.isShapeOfType<TLFrameShape>(selectingShape, 'frame')) {
this.editor.setEditingShape(null) this.editor.setEditingShape(null)
} }
// If we clicked on the editing shape (which isn't a shape with a label), do nothing // If we clicked on the editing shape (which isn't a shape with a label), do nothing
@ -103,9 +121,6 @@ export class EditingShape extends StateNode {
} }
return return
} }
} else {
// clicked a different kind of shape
}
break break
} }
} }
@ -116,6 +131,32 @@ export class EditingShape extends StateNode {
this.editor.root.handleEvent(info) this.editor.root.handleEvent(info)
} }
override onPointerUp: TLEventHandlers['onPointerUp'] = (info) => {
// If we're not dragging, and it's a hit to the label, begin editing the shape.
const hitShape = this.hitShapeForPointerUp
if (!hitShape) return
this.hitShapeForPointerUp = null
// Stay in edit mode to maintain flow of editing.
const util = this.editor.getShapeUtil(hitShape)
if (this.editor.getInstanceState().isReadonly) {
if (!util.canEditInReadOnly(hitShape)) {
this.parent.transition('pointing_shape', info)
return
}
}
this.editor.select(hitShape.id)
if (this.editor.getInstanceState().isCoarsePointer) {
this.editor.setEditingShape(null)
this.editor.setCurrentTool('select.idle')
} else {
this.editor.setEditingShape(hitShape.id)
updateHoveredId(this.editor)
}
}
override onComplete: TLEventHandlers['onComplete'] = (info) => { override onComplete: TLEventHandlers['onComplete'] = (info) => {
this.parent.transition('idle', info) this.parent.transition('idle', info)
} }

View file

@ -13,12 +13,25 @@ import {
Vec, Vec,
VecLike, VecLike,
createShapeId, createShapeId,
debugFlags,
pointInPolygon, pointInPolygon,
} from '@tldraw/editor' } from '@tldraw/editor'
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown' import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
import { getShouldEnterCropMode } from '../../selection-logic/getShouldEnterCropModeOnPointerDown' import { getShouldEnterCropMode } from '../../selection-logic/getShouldEnterCropModeOnPointerDown'
import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp' import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp'
import { updateHoveredId } from '../../selection-logic/updateHoveredId' import { updateHoveredId } from '../../selection-logic/updateHoveredId'
import { kickoutOccludedShapes, startEditingShapeWithLabel } from '../selectHelpers'
const SKIPPED_KEYS_FOR_AUTO_EDITING = [
'Delete',
'Backspace',
'[',
']',
'Enter',
' ',
'Shift',
'Tab',
]
export class Idle extends StateNode { export class Idle extends StateNode {
static override id = 'idle' static override id = 'idle'
@ -257,6 +270,7 @@ export class Idle extends StateNode {
if (change) { if (change) {
this.editor.mark('double click edge') this.editor.mark('double click edge')
this.editor.updateShapes([change]) this.editor.updateShapes([change])
kickoutOccludedShapes(this.editor, [onlySelectedShape.id])
return return
} }
} }
@ -271,7 +285,7 @@ export class Idle extends StateNode {
} }
if (this.shouldStartEditingShape(onlySelectedShape)) { if (this.shouldStartEditingShape(onlySelectedShape)) {
this.startEditingShape(onlySelectedShape, info) this.startEditingShape(onlySelectedShape, info, true /* select all */)
} }
} }
break break
@ -305,7 +319,7 @@ export class Idle extends StateNode {
// If the shape can edit, then begin editing // If the shape can edit, then begin editing
if (this.shouldStartEditingShape(shape)) { if (this.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info) this.startEditingShape(shape, info, true /* select all */)
} else { } else {
// If the shape's double click handler has not created a change, // If the shape's double click handler has not created a change,
// and if the shape cannot edit, then create a text shape and // and if the shape cannot edit, then create a text shape and
@ -327,7 +341,7 @@ export class Idle extends StateNode {
// If the shape's double click handler has not created a change, // If the shape's double click handler has not created a change,
// and if the shape can edit, then begin editing the shape. // and if the shape can edit, then begin editing the shape.
if (this.shouldStartEditingShape(shape)) { if (this.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info) this.startEditingShape(shape, info, true /* select all */)
} }
} }
} }
@ -418,7 +432,35 @@ export class Idle extends StateNode {
case 'ArrowUp': case 'ArrowUp':
case 'ArrowDown': { case 'ArrowDown': {
this.nudgeSelectedShapes(false) this.nudgeSelectedShapes(false)
break return
}
}
if (debugFlags['editOnType'].get()) {
// This feature flag lets us start editing a note shape's label when a key is pressed.
// We exclude certain keys to avoid conflicting with modifiers, but there are conflicts
// with other action kbds, hence why this is kept behind a feature flag.
if (!SKIPPED_KEYS_FOR_AUTO_EDITING.includes(info.key) && !info.altKey && !info.ctrlKey) {
// If the only selected shape is editable, then begin editing it
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (
onlySelectedShape &&
// If it's a note shape, then edit on type
this.editor.isShapeOfType(onlySelectedShape, 'note') &&
// If it's not locked or anything
this.shouldStartEditingShape(onlySelectedShape)
) {
this.startEditingShape(
onlySelectedShape,
{
...info,
target: 'shape',
shape: onlySelectedShape,
},
true /* select all */
)
return
}
} }
} }
} }
@ -453,11 +495,15 @@ export class Idle extends StateNode {
// If the only selected shape is editable, then begin editing it // If the only selected shape is editable, then begin editing it
const onlySelectedShape = this.editor.getOnlySelectedShape() const onlySelectedShape = this.editor.getOnlySelectedShape()
if (onlySelectedShape && this.shouldStartEditingShape(onlySelectedShape)) { if (onlySelectedShape && this.shouldStartEditingShape(onlySelectedShape)) {
this.startEditingShape(onlySelectedShape, { this.startEditingShape(
onlySelectedShape,
{
...info, ...info,
target: 'shape', target: 'shape',
shape: onlySelectedShape, shape: onlySelectedShape,
}) },
true /* select all */
)
return return
} }
@ -479,10 +525,14 @@ export class Idle extends StateNode {
return this.editor.getShapeUtil(shape).canEdit(shape) return this.editor.getShapeUtil(shape).canEdit(shape)
} }
private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) { private startEditingShape(
shape: TLShape,
info: TLClickEventInfo | TLKeyboardEventInfo,
shouldSelectAll?: boolean
) {
if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return
this.editor.mark('editing shape') this.editor.mark('editing shape')
this.editor.setEditingShape(shape.id) startEditingShapeWithLabel(this.editor, shape, shouldSelectAll)
this.parent.transition('editing_shape', info) this.parent.transition('editing_shape', info)
} }
@ -581,7 +631,9 @@ export class Idle extends StateNode {
? MAJOR_NUDGE_FACTOR ? MAJOR_NUDGE_FACTOR
: MINOR_NUDGE_FACTOR : MINOR_NUDGE_FACTOR
this.editor.nudgeShapes(this.editor.getSelectedShapeIds(), delta.mul(step)) const selectedShapeIds = this.editor.getSelectedShapeIds()
this.editor.nudgeShapes(selectedShapeIds, delta.mul(step))
kickoutOccludedShapes(this.editor, selectedShapeIds)
} }
private canInteractWithShapeInReadOnly(shape: TLShape) { private canInteractWithShapeInReadOnly(shape: TLShape) {

View file

@ -16,6 +16,8 @@ export class PointingArrowLabel extends StateNode {
shapeId = '' as TLShapeId shapeId = '' as TLShapeId
markId = '' markId = ''
wasAlreadySelected = false
didDrag = false
private info = {} as TLPointerEventInfo & { private info = {} as TLPointerEventInfo & {
shape: TLArrowShape shape: TLArrowShape
@ -38,6 +40,8 @@ export class PointingArrowLabel extends StateNode {
this.parent.setCurrentToolIdMask(info.onInteractionEnd) this.parent.setCurrentToolIdMask(info.onInteractionEnd)
this.info = info this.info = info
this.shapeId = shape.id this.shapeId = shape.id
this.didDrag = false
this.wasAlreadySelected = this.editor.getOnlySelectedShapeId() === shape.id
this.updateCursor() this.updateCursor()
const geometry = this.editor.getShapeGeometry<Group2d>(shape) const geometry = this.editor.getShapeGeometry<Group2d>(shape)
@ -100,6 +104,7 @@ export class PointingArrowLabel extends StateNode {
nextLabelPosition = 0.5 nextLabelPosition = 0.5
} }
this.didDrag = true
this.editor.updateShape<TLArrowShape>( this.editor.updateShape<TLArrowShape>(
{ id: shape.id, type: shape.type, props: { labelPosition: nextLabelPosition } }, { id: shape.id, type: shape.type, props: { labelPosition: nextLabelPosition } },
{ squashing: true } { squashing: true }
@ -107,7 +112,16 @@ export class PointingArrowLabel extends StateNode {
} }
override onPointerUp = () => { override onPointerUp = () => {
const shape = this.editor.getShape<TLArrowShape>(this.shapeId)
if (!shape) return
if (this.didDrag || !this.wasAlreadySelected) {
this.complete() this.complete()
} else {
// Go into edit mode.
this.editor.setEditingShape(shape.id)
this.editor.setCurrentTool('select.editing_shape')
}
} }
override onCancel: TLEventHandlers['onCancel'] = () => { override onCancel: TLEventHandlers['onCancel'] = () => {

View file

@ -1,4 +1,19 @@
import { StateNode, TLArrowShape, TLEventHandlers, TLPointerEventInfo } from '@tldraw/editor' import {
Editor,
StateNode,
TLArrowShape,
TLEventHandlers,
TLHandle,
TLNoteShape,
TLPointerEventInfo,
Vec,
} from '@tldraw/editor'
import {
NOTE_CENTER_OFFSET,
getNoteAdjacentPositions,
getNoteShapeForAdjacentPosition,
} from '../../../shapes/note/noteHelpers'
import { startEditingShapeWithLabel } from '../selectHelpers'
export class PointingHandle extends StateNode { export class PointingHandle extends StateNode {
static override id = 'pointing_handle' static override id = 'pointing_handle'
@ -32,11 +47,23 @@ export class PointingHandle extends StateNode {
} }
override onPointerUp: TLEventHandlers['onPointerUp'] = () => { override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
const { shape, handle } = this.info
if (this.editor.isShapeOfType<TLNoteShape>(shape, 'note')) {
const { editor } = this
const nextNote = getNoteForPit(editor, shape, handle, false)
if (nextNote) {
startEditingShapeWithLabel(editor, nextNote, true /* selectAll */)
return
}
}
this.parent.transition('idle', this.info) this.parent.transition('idle', this.info)
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
if (this.editor.inputs.isDragging) { const { editor } = this
if (editor.inputs.isDragging) {
this.startDraggingHandle() this.startDraggingHandle()
} }
} }
@ -46,7 +73,38 @@ export class PointingHandle extends StateNode {
} }
private startDraggingHandle() { private startDraggingHandle() {
if (this.editor.getInstanceState().isReadonly) return const { editor } = this
if (editor.getInstanceState().isReadonly) return
const { shape, handle } = this.info
if (editor.isShapeOfType<TLNoteShape>(shape, 'note')) {
const nextNote = getNoteForPit(editor, shape, handle, true)
if (nextNote) {
// Center the shape on the current pointer
const centeredOnPointer = editor
.getPointInParentSpace(nextNote, editor.inputs.originPagePoint)
.sub(Vec.Rot(NOTE_CENTER_OFFSET, nextNote.rotation))
editor.updateShape({ ...nextNote, x: centeredOnPointer.x, y: centeredOnPointer.y })
// Then select and begin translating the shape
editor
.setHoveredShape(nextNote.id) // important!
.select(nextNote.id)
.setCurrentTool('select.translating', {
...this.info,
target: 'shape',
shape: editor.getShape(nextNote),
onInteractionEnd: 'note',
isCreating: true,
onCreate: () => {
// When we're done, start editing it
startEditingShapeWithLabel(editor, nextNote, true /* selectAll */)
},
})
return
}
}
this.parent.transition('dragging_handle', this.info) this.parent.transition('dragging_handle', this.info)
} }
@ -66,3 +124,15 @@ export class PointingHandle extends StateNode {
this.parent.transition('idle') this.parent.transition('idle')
} }
} }
function getNoteForPit(editor: Editor, shape: TLNoteShape, handle: TLHandle, forceNew: boolean) {
const pageTransform = editor.getShapePageTransform(shape.id)!
const pagePoint = pageTransform.point()
const pageRotation = pageTransform.rotation()
const pits = getNoteAdjacentPositions(pagePoint, pageRotation, shape.props.growY, 0)
const index = editor.getShapeHandles(shape.id)!.findIndex((h) => h.id === handle.id)
if (pits[index]) {
const pit = pits[index]
return getNoteShapeForAdjacentPosition(editor, shape, pit, pageRotation, forceNew)
}
}

View file

@ -1,19 +1,18 @@
import { import {
Group2d,
HIT_TEST_MARGIN, HIT_TEST_MARGIN,
StateNode, StateNode,
TLArrowShape,
TLEventHandlers, TLEventHandlers,
TLGeoShape,
TLPointerEventInfo, TLPointerEventInfo,
TLShape, TLShape,
} from '@tldraw/editor' } from '@tldraw/editor'
import { getTextLabels } from '../../../utils/shapes/shapes'
export class PointingShape extends StateNode { export class PointingShape extends StateNode {
static override id = 'pointing_shape' static override id = 'pointing_shape'
hitShape = {} as TLShape hitShape = {} as TLShape
hitShapeForPointerUp = {} as TLShape hitShapeForPointerUp = {} as TLShape
isDoubleClick = false
didSelectOnEnter = false didSelectOnEnter = false
@ -26,7 +25,11 @@ export class PointingShape extends StateNode {
} = this.editor } = this.editor
this.hitShape = info.shape this.hitShape = info.shape
this.isDoubleClick = false
const outermostSelectingShape = this.editor.getOutermostSelectableShape(info.shape) const outermostSelectingShape = this.editor.getOutermostSelectableShape(info.shape)
const selectedAncestor = this.editor.findShapeAncestor(outermostSelectingShape, (parent) =>
selectedShapeIds.includes(parent.id)
)
if ( if (
// If the shape has an onClick handler // If the shape has an onClick handler
@ -35,7 +38,8 @@ export class PointingShape extends StateNode {
outermostSelectingShape.id === focusedGroupId || outermostSelectingShape.id === focusedGroupId ||
// ...or if the shape is within the selection // ...or if the shape is within the selection
selectedShapeIds.includes(outermostSelectingShape.id) || selectedShapeIds.includes(outermostSelectingShape.id) ||
this.editor.isAncestorSelected(outermostSelectingShape.id) || // ...or if an ancestor of the shape is selected
selectedAncestor ||
// ...or if the current point is NOT within the selection bounds // ...or if the current point is NOT within the selection bounds
(selectedShapeIds.length > 1 && selectionBounds?.containsPoint(currentPagePoint)) (selectedShapeIds.length > 1 && selectionBounds?.containsPoint(currentPagePoint))
) { ) {
@ -127,24 +131,22 @@ export class PointingShape extends StateNode {
// then we would want to begin editing the shape. At the moment we're relying on the shape label's onPointerUp // then we would want to begin editing the shape. At the moment we're relying on the shape label's onPointerUp
// handler to do this logic, and prevent the regular pointer up event, so we won't be here in that case. // handler to do this logic, and prevent the regular pointer up event, so we won't be here in that case.
// ! tldraw hack // if the shape has a text label, and we're inside of the label, then we want to begin editing the label.
// if the shape is a geo shape, and we're inside of the label, then we want to begin editing the label if (selectedShapeIds.length === 1) {
if ( const geometry = this.editor.getShapeUtil(selectingShape).getGeometry(selectingShape)
selectedShapeIds.length === 1 && const textLabels = getTextLabels(geometry)
(this.editor.isShapeOfType<TLGeoShape>(selectingShape, 'geo') || const textLabel = textLabels.length === 1 ? textLabels[0] : undefined
this.editor.isShapeOfType<TLArrowShape>(selectingShape, 'arrow')) // N.B. we're only interested if there is exactly one text label. We don't handle the
) { // case if there's potentially more than one text label at the moment.
const geometry = this.editor.getShapeGeometry(selectingShape) if (textLabel) {
const labelGeometry = (geometry as Group2d).children[1]
if (labelGeometry) {
const pointInShapeSpace = this.editor.getPointInShapeSpace( const pointInShapeSpace = this.editor.getPointInShapeSpace(
selectingShape, selectingShape,
currentPagePoint currentPagePoint
) )
if ( if (
labelGeometry.bounds.containsPoint(pointInShapeSpace, 0) && textLabel.bounds.containsPoint(pointInShapeSpace, 0) &&
labelGeometry.hitTestPoint(pointInShapeSpace) textLabel.hitTestPoint(pointInShapeSpace)
) { ) {
this.editor.batch(() => { this.editor.batch(() => {
this.editor.mark('editing on pointer up') this.editor.mark('editing on pointer up')
@ -159,6 +161,10 @@ export class PointingShape extends StateNode {
this.editor.setEditingShape(selectingShape.id) this.editor.setEditingShape(selectingShape.id)
this.editor.setCurrentTool('select.editing_shape') this.editor.setCurrentTool('select.editing_shape')
if (this.isDoubleClick) {
this.editor.emit('select-all-text', { shapeId: selectingShape.id })
}
}) })
return return
} }
@ -193,6 +199,10 @@ export class PointingShape extends StateNode {
this.parent.transition('idle', info) this.parent.transition('idle', info)
} }
override onDoubleClick: TLEventHandlers['onDoubleClick'] = () => {
this.isDoubleClick = true
}
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) { if (this.editor.inputs.isDragging) {
this.startTranslating(info) this.startTranslating(info)

View file

@ -19,6 +19,7 @@ import {
compact, compact,
moveCameraWhenCloseToEdge, moveCameraWhenCloseToEdge,
} from '@tldraw/editor' } from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
type ResizingInfo = TLPointerEventInfo & { type ResizingInfo = TLPointerEventInfo & {
target: 'selection' target: 'selection'
@ -111,6 +112,8 @@ export class Resizing extends StateNode {
} }
private complete() { private complete() {
kickoutOccludedShapes(this.editor, this.snapshot.selectedShapeIds)
this.handleResizeEnd() this.handleResizeEnd()
if (this.info.isCreating && this.info.onCreate) { if (this.info.isCreating && this.info.onCreate) {

View file

@ -10,6 +10,7 @@ import {
shortAngleDist, shortAngleDist,
snapAngle, snapAngle,
} from '@tldraw/editor' } from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
import { CursorTypeMap } from './PointingResizeHandle' import { CursorTypeMap } from './PointingResizeHandle'
const ONE_DEGREE = Math.PI / 180 const ONE_DEGREE = Math.PI / 180
@ -128,6 +129,10 @@ export class Rotating extends StateNode {
snapshot: this.snapshot, snapshot: this.snapshot,
stage: 'end', stage: 'end',
}) })
kickoutOccludedShapes(
this.editor,
this.snapshot.shapeSnapshots.map((s) => s.shape.id)
)
if (this.info.onInteractionEnd) { if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {

View file

@ -1,12 +1,12 @@
import { import {
BoundsSnapPoint, BoundsSnapPoint,
Box,
Editor, Editor,
Mat, Mat,
MatModel, MatModel,
PageRecordType, PageRecordType,
StateNode, StateNode,
TLEventHandlers, TLEventHandlers,
TLNoteShape,
TLPointerEventInfo, TLPointerEventInfo,
TLShape, TLShape,
TLShapePartial, TLShapePartial,
@ -15,7 +15,13 @@ import {
isPageId, isPageId,
moveCameraWhenCloseToEdge, moveCameraWhenCloseToEdge,
} from '@tldraw/editor' } from '@tldraw/editor'
import {
NOTE_PIT_RADIUS,
NOTE_SIZE,
getAvailableNoteAdjacentPositions,
} from '../../../shapes/note/noteHelpers'
import { DragAndDropManager } from '../DragAndDropManager' import { DragAndDropManager } from '../DragAndDropManager'
import { kickoutOccludedShapes } from '../selectHelpers'
export class Translating extends StateNode { export class Translating extends StateNode {
static override id = 'translating' static override id = 'translating'
@ -24,6 +30,7 @@ export class Translating extends StateNode {
target: 'shape' target: 'shape'
isCreating?: boolean isCreating?: boolean
onCreate?: () => void onCreate?: () => void
didStartInPit?: boolean
onInteractionEnd?: string onInteractionEnd?: string
} }
@ -85,10 +92,7 @@ export class Translating extends StateNode {
this.selectionSnapshot = {} as any this.selectionSnapshot = {} as any
this.snapshot = {} as any this.snapshot = {} as any
this.editor.snaps.clearIndicators() this.editor.snaps.clearIndicators()
this.editor.updateInstanceState( this.editor.setCursor({ type: 'default', rotation: 0 })
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.dragAndDropManager.clear() this.dragAndDropManager.clear()
} }
@ -167,6 +171,10 @@ export class Translating extends StateNode {
protected complete() { protected complete() {
this.updateShapes() this.updateShapes()
this.dragAndDropManager.dropShapes(this.snapshot.movingShapes) this.dragAndDropManager.dropShapes(this.snapshot.movingShapes)
kickoutOccludedShapes(
this.editor,
this.snapshot.movingShapes.map((s) => s.id)
)
this.handleEnd() this.handleEnd()
if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) { if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) {
@ -268,10 +276,7 @@ export class Translating extends StateNode {
moveShapesToPoint({ moveShapesToPoint({
editor: this.editor, editor: this.editor,
shapeSnapshots: snapshot.shapeSnapshots, snapshot,
averagePagePoint: snapshot.averagePagePoint,
initialSelectionPageBounds: snapshot.initialPageBounds,
initialSelectionSnapPoints: snapshot.initialSnapPoints,
}) })
this.handleChange() this.handleChange()
@ -302,14 +307,17 @@ function getTranslatingSnapshot(editor: Editor) {
const movingShapes: TLShape[] = [] const movingShapes: TLShape[] = []
const pagePoints: Vec[] = [] const pagePoints: Vec[] = []
const selectedShapeIds = editor.getSelectedShapeIds()
const shapeSnapshots = compact( const shapeSnapshots = compact(
editor.getSelectedShapeIds().map((id): null | MovingShapeSnapshot => { selectedShapeIds.map((id): null | MovingShapeSnapshot => {
const shape = editor.getShape(id) const shape = editor.getShape(id)
if (!shape) return null if (!shape) return null
movingShapes.push(shape) movingShapes.push(shape)
const pagePoint = editor.getShapePageTransform(id)!.point() const pageTransform = editor.getShapePageTransform(id)
if (!pagePoint) return null const pagePoint = pageTransform.point()
const pageRotation = pageTransform.rotation()
pagePoints.push(pagePoint) pagePoints.push(pagePoint)
const parentTransform = PageRecordType.isId(shape.parentId) const parentTransform = PageRecordType.isId(shape.parentId)
@ -319,14 +327,18 @@ function getTranslatingSnapshot(editor: Editor) {
return { return {
shape, shape,
pagePoint, pagePoint,
pageRotation,
parentTransform, parentTransform,
} }
}) })
) )
const onlySelectedShape = editor.getOnlySelectedShape()
let initialSnapPoints: BoundsSnapPoint[] = [] let initialSnapPoints: BoundsSnapPoint[] = []
if (editor.getSelectedShapeIds().length === 1) {
initialSnapPoints = editor.snaps.shapeBounds.getSnapPoints(editor.getSelectedShapeIds()[0])! if (onlySelectedShape) {
initialSnapPoints = editor.snaps.shapeBounds.getSnapPoints(onlySelectedShape.id)!
} else { } else {
const selectionPageBounds = editor.getSelectionPageBounds() const selectionPageBounds = editor.getSelectionPageBounds()
if (selectionPageBounds) { if (selectionPageBounds) {
@ -338,12 +350,49 @@ function getTranslatingSnapshot(editor: Editor) {
} }
} }
let noteAdjacentPositions: Vec[] | undefined
let noteSnapshot: MovingShapeSnapshot | undefined
const { originPagePoint } = editor.inputs
const allHoveredNotes = shapeSnapshots.filter(
(s) =>
editor.isShapeOfType<TLNoteShape>(s.shape, 'note') &&
editor.isPointInShape(s.shape, originPagePoint)
)
if (allHoveredNotes.length === 0) {
// noop
} else if (allHoveredNotes.length === 1) {
// just one, easy
noteSnapshot = allHoveredNotes[0]
} else {
// More than one under the cursor, so we need to find the highest shape in z-order
const allShapesSorted = editor.getCurrentPageShapesSorted()
noteSnapshot = allHoveredNotes
.map((s) => ({
snapshot: s,
index: allShapesSorted.findIndex((shape) => shape.id === s.shape.id),
}))
.sort((a, b) => b.index - a.index)[0]?.snapshot // highest up first
}
if (noteSnapshot) {
noteAdjacentPositions = getAvailableNoteAdjacentPositions(
editor,
noteSnapshot.pageRotation,
(noteSnapshot.shape as TLNoteShape).props.growY ?? 0
)
}
return { return {
averagePagePoint: Vec.Average(pagePoints), averagePagePoint: Vec.Average(pagePoints),
movingShapes, movingShapes,
shapeSnapshots, shapeSnapshots,
initialPageBounds: editor.getSelectionPageBounds()!, initialPageBounds: editor.getSelectionPageBounds()!,
initialSnapPoints, initialSnapPoints,
noteAdjacentPositions,
noteSnapshot,
} }
} }
@ -352,24 +401,28 @@ export type TranslatingSnapshot = ReturnType<typeof getTranslatingSnapshot>
export interface MovingShapeSnapshot { export interface MovingShapeSnapshot {
shape: TLShape shape: TLShape
pagePoint: Vec pagePoint: Vec
pageRotation: number
parentTransform: MatModel | null parentTransform: MatModel | null
} }
export function moveShapesToPoint({ export function moveShapesToPoint({
editor, editor,
shapeSnapshots: snapshots, snapshot,
averagePagePoint,
initialSelectionPageBounds,
initialSelectionSnapPoints,
}: { }: {
editor: Editor editor: Editor
shapeSnapshots: MovingShapeSnapshot[] snapshot: TranslatingSnapshot
averagePagePoint: Vec
initialSelectionPageBounds: Box
initialSelectionSnapPoints: BoundsSnapPoint[]
}) { }) {
const { inputs } = editor const { inputs } = editor
const {
noteSnapshot,
noteAdjacentPositions,
initialPageBounds,
initialSnapPoints,
shapeSnapshots,
averagePagePoint,
} = snapshot
const isGridMode = editor.getInstanceState().isGridMode const isGridMode = editor.getInstanceState().isGridMode
const gridSize = editor.getDocumentSettings().gridSize const gridSize = editor.getDocumentSettings().gridSize
@ -391,19 +444,41 @@ export function moveShapesToPoint({
// Provisional snapping // Provisional snapping
editor.snaps.clearIndicators() editor.snaps.clearIndicators()
const shouldSnap = // If the user isn't moving super quick
(editor.user.getIsSnapMode() ? !inputs.ctrlKey : inputs.ctrlKey) && const isSnapping = editor.user.getIsSnapMode() ? !inputs.ctrlKey : inputs.ctrlKey
editor.inputs.pointerVelocity.len() < 0.5 // ...and if the user is not dragging fast if (isSnapping && editor.inputs.pointerVelocity.len() < 0.5) {
// snapping
if (shouldSnap) {
const { nudge } = editor.snaps.shapeBounds.snapTranslateShapes({ const { nudge } = editor.snaps.shapeBounds.snapTranslateShapes({
dragDelta: delta, dragDelta: delta,
initialSelectionPageBounds, initialSelectionPageBounds: initialPageBounds,
lockedAxis: flatten, lockedAxis: flatten,
initialSelectionSnapPoints, initialSelectionSnapPoints: initialSnapPoints,
}) })
delta.add(nudge) delta.add(nudge)
} else {
// for sticky notes, snap to grid position next to other notes
if (noteSnapshot && noteAdjacentPositions) {
let min = NOTE_PIT_RADIUS / editor.getZoomLevel() // in screen space
let offset = new Vec(0, 0)
const pageCenter = Vec.Add(
Vec.Add(noteSnapshot.pagePoint, delta),
new Vec(NOTE_SIZE / 2, NOTE_SIZE / 2).rot(noteSnapshot.pageRotation)
)
for (const pit of noteAdjacentPositions) {
// We've already filtered pits with the same page rotation
const deltaToPit = Vec.Sub(pageCenter, pit)
const dist = deltaToPit.len()
if (dist < min) {
min = dist
offset = deltaToPit
}
}
delta.sub(offset)
}
} }
const averageSnappedPoint = Vec.Add(averagePagePoint, delta) const averageSnappedPoint = Vec.Add(averagePagePoint, delta)
@ -416,8 +491,9 @@ export function moveShapesToPoint({
editor.updateShapes( editor.updateShapes(
compact( compact(
snapshots.map(({ shape, pagePoint, parentTransform }): TLShapePartial | null => { shapeSnapshots.map(({ shape, pagePoint, parentTransform }): TLShapePartial | null => {
const newPagePoint = Vec.Add(pagePoint, averageSnap) const newPagePoint = Vec.Add(pagePoint, averageSnap)
const newLocalPoint = parentTransform const newLocalPoint = parentTransform
? Mat.applyToPoint(parentTransform, newPagePoint) ? Mat.applyToPoint(parentTransform, newPagePoint)
: newPagePoint : newPagePoint

View file

@ -0,0 +1,155 @@
import {
ANIMATION_MEDIUM_MS,
Editor,
Geometry2d,
Mat,
TLShape,
TLShapeId,
Vec,
compact,
pointInPolygon,
polygonIntersectsPolyline,
polygonsIntersect,
} from '@tldraw/editor'
/** @internal */
export function kickoutOccludedShapes(editor: Editor, shapeIds: TLShapeId[]) {
// const shapes = shapeIds.map((id) => editor.getShape(id)).filter((s) => s) as TLShape[]
const parentsToCheck = new Set<TLShape>()
for (const id of shapeIds) {
// If the shape exists and the shape has an onDragShapesOut
// function, add it to the set
const shape = editor.getShape(id)
if (!shape) continue
if (editor.getShapeUtil(shape).onDragShapesOut) {
parentsToCheck.add(shape)
}
// If the shape's parent is a shape and the shape's parent
// has an onDragShapesOut function, add it to the set
const parent = editor.getShape(shape.parentId)
if (!parent) continue
if (editor.getShapeUtil(parent).onDragShapesOut) {
parentsToCheck.add(parent)
}
}
const parentsWithKickedOutChildren = new Map<TLShape, TLShapeId[]>()
for (const parent of parentsToCheck) {
const occludedChildren = getOccludedChildren(editor, parent)
if (occludedChildren.length) {
parentsWithKickedOutChildren.set(parent, occludedChildren)
}
}
// now call onDragShapesOut for each parent
for (const [parent, kickedOutChildrenIds] of parentsWithKickedOutChildren) {
const shapeUtil = editor.getShapeUtil(parent)
const kickedOutChildren = compact(kickedOutChildrenIds.map((id) => editor.getShape(id)))
shapeUtil.onDragShapesOut?.(parent, kickedOutChildren)
}
}
/** @public */
export function getOccludedChildren(editor: Editor, parent: TLShape) {
const childIds = editor.getSortedChildIdsForParent(parent.id)
if (childIds.length === 0) return []
const parentPageBounds = editor.getShapePageBounds(parent)
if (!parentPageBounds) return []
let parentGeometry: Geometry2d | undefined
let parentPageTransform: Mat | undefined
let parentPageCorners: Vec[] | undefined
const results: TLShapeId[] = []
for (const childId of childIds) {
const shapePageBounds = editor.getShapePageBounds(childId)
if (!shapePageBounds) {
// Not occluded, shape doesn't exist
continue
}
if (!parentPageBounds.includes(shapePageBounds)) {
// Not in shape's bounds, shape is occluded
results.push(childId)
continue
}
// There might be a lot of children; we don't want to do this for all of them,
// but we also don't want to do it at all if we don't have to. ??= to the rescue!
parentGeometry ??= editor.getShapeGeometry(parent)
parentPageTransform ??= editor.getShapePageTransform(parent)
parentPageCorners ??= parentPageTransform.applyToPoints(parentGeometry.vertices)
const parentCornersInShapeSpace = editor
.getShapePageTransform(childId)
.clone()
.invert()
.applyToPoints(parentPageCorners)
// If any of the shape's vertices are inside the occluder, it's not occluded
const { vertices, isClosed } = editor.getShapeGeometry(childId)
if (vertices.some((v) => pointInPolygon(v, parentCornersInShapeSpace))) {
// not occluded, vertices are in the occluder's corners
continue
}
// If any the shape's vertices intersect the edge of the occluder, it's not occluded
if (isClosed) {
if (polygonsIntersect(parentCornersInShapeSpace, vertices)) {
// not occluded, vertices intersect parent's corners
continue
}
} else if (polygonIntersectsPolyline(parentCornersInShapeSpace, vertices)) {
// not occluded, vertices intersect parent's corners
continue
}
// Passed all checks, shape is occluded
results.push(childId)
}
return results
}
/** @internal */
export function startEditingShapeWithLabel(editor: Editor, shape: TLShape, selectAll = false) {
// Finish this shape and start editing the next one
editor.select(shape)
editor.setEditingShape(shape)
editor.setCurrentTool('select.editing_shape', {
target: 'shape',
shape: shape,
})
if (selectAll) {
editor.emit('select-all-text', { shapeId: shape.id })
}
zoomToShapeIfOffscreen(editor)
}
const ZOOM_TO_SHAPE_PADDING = 16
export function zoomToShapeIfOffscreen(editor: Editor) {
const selectionPageBounds = editor.getSelectionPageBounds()
const viewportPageBounds = editor.getViewportPageBounds()
if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) {
const eb = selectionPageBounds
.clone()
// Expand the bounds by the padding
.expandBy(ZOOM_TO_SHAPE_PADDING / editor.getZoomLevel())
// then expand the bounds to include the viewport bounds
.expand(viewportPageBounds)
// then use the difference between the centers to calculate the offset
const nextBounds = viewportPageBounds.clone().translate({
x: (eb.center.x - viewportPageBounds.center.x) * 2,
y: (eb.center.y - viewportPageBounds.center.y) * 2,
})
editor.zoomToBounds(nextBounds, {
duration: ANIMATION_MEDIUM_MS,
inset: 0,
})
}
}

View file

@ -22,6 +22,7 @@ import {
} from '@tldraw/editor' } from '@tldraw/editor'
import React from 'react' import React from 'react'
import { STYLES } from '../../../styles' import { STYLES } from '../../../styles'
import { kickoutOccludedShapes } from '../../../tools/SelectTool/selectHelpers'
import { useUiEvents } from '../../context/events' import { useUiEvents } from '../../context/events'
import { useRelevantStyles } from '../../hooks/useRelevantStyles' import { useRelevantStyles } from '../../hooks/useRelevantStyles'
import { useTranslation } from '../../hooks/useTranslation/useTranslation' import { useTranslation } from '../../hooks/useTranslation/useTranslation'
@ -101,6 +102,7 @@ export function CommonStylePickerSet({
theme: TLDefaultColorTheme theme: TLDefaultColorTheme
}) { }) {
const msg = useTranslation() const msg = useTranslation()
const editor = useEditor()
const handleValueChange = useStyleChangeCallback() const handleValueChange = useStyleChangeCallback()
@ -163,7 +165,13 @@ export function CommonStylePickerSet({
style={DefaultSizeStyle} style={DefaultSizeStyle}
items={STYLES.size} items={STYLES.size}
value={size} value={size}
onValueChange={handleValueChange} onValueChange={(style, value, squashing) => {
handleValueChange(style, value, squashing)
const selectedShapeIds = editor.getSelectedShapeIds()
if (selectedShapeIds.length > 0) {
kickoutOccludedShapes(editor, selectedShapeIds)
}
}}
theme={theme} theme={theme}
/> />
)} )}

View file

@ -20,6 +20,7 @@ import {
useEditor, useEditor,
} from '@tldraw/editor' } from '@tldraw/editor'
import * as React from 'react' import * as React from 'react'
import { kickoutOccludedShapes } from '../../tools/SelectTool/selectHelpers'
import { getEmbedInfo } from '../../utils/embeds/embeds' import { getEmbedInfo } from '../../utils/embeds/embeds'
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames' import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
import { EditLinkDialog } from '../components/EditLinkDialog' import { EditLinkDialog } from '../components/EditLinkDialog'
@ -321,14 +322,14 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('toggle-auto-size', { source }) trackEvent('toggle-auto-size', { source })
editor.mark('toggling auto size') editor.mark('toggling auto size')
editor.updateShapes( const shapes = editor
editor
.getSelectedShapes() .getSelectedShapes()
.filter( .filter(
(shape): shape is TLTextShape => (shape): shape is TLTextShape =>
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
) )
.map((shape) => { editor.updateShapes(
shapes.map((shape) => {
return { return {
id: shape.id, id: shape.id,
type: shape.type, type: shape.type,
@ -340,6 +341,10 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
} }
}) })
) )
kickoutOccludedShapes(
editor,
shapes.map((shape) => shape.id)
)
}, },
}, },
{ {
@ -499,17 +504,19 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
const commonBounds = Box.Common(compact(ids.map((id) => editor.getShapePageBounds(id)))) const commonBounds = Box.Common(compact(ids.map((id) => editor.getShapePageBounds(id))))
offset = instanceState.canMoveCamera offset = instanceState.canMoveCamera
? { ? {
x: commonBounds.width + 10, x: commonBounds.width + 20,
y: 0, y: 0,
} }
: { : {
x: 16 / editor.getZoomLevel(), // same as the adjacent note margin
y: 16 / editor.getZoomLevel(), x: 20,
y: 20,
} }
} }
editor.mark('duplicate shapes') editor.mark('duplicate shapes')
editor.duplicateShapes(ids, offset) editor.duplicateShapes(ids, offset)
if (instanceState.duplicateProps) { if (instanceState.duplicateProps) {
// If we are using duplicate props then we update the shape ids to the // If we are using duplicate props then we update the shape ids to the
// ids of the newly created shapes to keep the duplication going // ids of the newly created shapes to keep the duplication going
@ -602,7 +609,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'left', source }) trackEvent('align-shapes', { operation: 'left', source })
editor.mark('align left') editor.mark('align left')
editor.alignShapes(editor.getSelectedShapeIds(), 'left') const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'left')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -619,7 +628,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'center-horizontal', source }) trackEvent('align-shapes', { operation: 'center-horizontal', source })
editor.mark('align center horizontal') editor.mark('align center horizontal')
editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal') const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'center-horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -633,7 +644,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'right', source }) trackEvent('align-shapes', { operation: 'right', source })
editor.mark('align right') editor.mark('align right')
editor.alignShapes(editor.getSelectedShapeIds(), 'right') const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'right')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -650,7 +663,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'center-vertical', source }) trackEvent('align-shapes', { operation: 'center-vertical', source })
editor.mark('align center vertical') editor.mark('align center vertical')
editor.alignShapes(editor.getSelectedShapeIds(), 'center-vertical') const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'center-vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -664,7 +679,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'top', source }) trackEvent('align-shapes', { operation: 'top', source })
editor.mark('align top') editor.mark('align top')
editor.alignShapes(editor.getSelectedShapeIds(), 'top') const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'top')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -678,7 +695,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'bottom', source }) trackEvent('align-shapes', { operation: 'bottom', source })
editor.mark('align bottom') editor.mark('align bottom')
editor.alignShapes(editor.getSelectedShapeIds(), 'bottom') const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'bottom')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -695,7 +714,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('distribute-shapes', { operation: 'horizontal', source }) trackEvent('distribute-shapes', { operation: 'horizontal', source })
editor.mark('distribute horizontal') editor.mark('distribute horizontal')
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal') const selectedShapeIds = editor.getSelectedShapeIds()
editor.distributeShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -712,7 +733,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('distribute-shapes', { operation: 'vertical', source }) trackEvent('distribute-shapes', { operation: 'vertical', source })
editor.mark('distribute vertical') editor.mark('distribute vertical')
editor.distributeShapes(editor.getSelectedShapeIds(), 'vertical') const selectedShapeIds = editor.getSelectedShapeIds()
editor.distributeShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -728,7 +751,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stretch-shapes', { operation: 'horizontal', source }) trackEvent('stretch-shapes', { operation: 'horizontal', source })
editor.mark('stretch horizontal') editor.mark('stretch horizontal')
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal') const selectedShapeIds = editor.getSelectedShapeIds()
editor.stretchShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -744,7 +769,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stretch-shapes', { operation: 'vertical', source }) trackEvent('stretch-shapes', { operation: 'vertical', source })
editor.mark('stretch vertical') editor.mark('stretch vertical')
editor.stretchShapes(editor.getSelectedShapeIds(), 'vertical') const selectedShapeIds = editor.getSelectedShapeIds()
editor.stretchShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -760,7 +787,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('flip-shapes', { operation: 'horizontal', source }) trackEvent('flip-shapes', { operation: 'horizontal', source })
editor.mark('flip horizontal') editor.mark('flip horizontal')
editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') const selectedShapeIds = editor.getSelectedShapeIds()
editor.flipShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -773,7 +802,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('flip-shapes', { operation: 'vertical', source }) trackEvent('flip-shapes', { operation: 'vertical', source })
editor.mark('flip vertical') editor.mark('flip vertical')
editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') const selectedShapeIds = editor.getSelectedShapeIds()
editor.flipShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -786,7 +817,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('pack-shapes', { source }) trackEvent('pack-shapes', { source })
editor.mark('pack') editor.mark('pack')
editor.packShapes(editor.getSelectedShapeIds(), 16) const selectedShapeIds = editor.getSelectedShapeIds()
editor.packShapes(selectedShapeIds, 16)
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -802,7 +835,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stack-shapes', { operation: 'vertical', source }) trackEvent('stack-shapes', { operation: 'vertical', source })
editor.mark('stack-vertical') editor.mark('stack-vertical')
editor.stackShapes(editor.getSelectedShapeIds(), 'vertical', 16) const selectedShapeIds = editor.getSelectedShapeIds()
editor.stackShapes(selectedShapeIds, 'vertical', 16)
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -818,7 +853,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stack-shapes', { operation: 'horizontal', source }) trackEvent('stack-shapes', { operation: 'horizontal', source })
editor.mark('stack-horizontal') editor.mark('stack-horizontal')
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 16) const selectedShapeIds = editor.getSelectedShapeIds()
editor.stackShapes(selectedShapeIds, 'horizontal', 16)
kickoutOccludedShapes(editor, selectedShapeIds)
}, },
}, },
{ {
@ -970,10 +1007,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.mark('rotate-cw') editor.mark('rotate-cw')
const offset = editor.getSelectionRotation() % (HALF_PI / 2) const offset = editor.getSelectionRotation() % (HALF_PI / 2)
const dontUseOffset = approximately(offset, 0) || approximately(offset, HALF_PI / 2) const dontUseOffset = approximately(offset, 0) || approximately(offset, HALF_PI / 2)
editor.rotateShapesBy( const selectedShapeIds = editor.getSelectedShapeIds()
editor.getSelectedShapeIds(), editor.rotateShapesBy(selectedShapeIds, HALF_PI / 2 - (dontUseOffset ? 0 : offset))
HALF_PI / 2 - (dontUseOffset ? 0 : offset) kickoutOccludedShapes(editor, selectedShapeIds)
)
}, },
}, },
{ {
@ -988,10 +1024,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.mark('rotate-ccw') editor.mark('rotate-ccw')
const offset = editor.getSelectionRotation() % (HALF_PI / 2) const offset = editor.getSelectionRotation() % (HALF_PI / 2)
const offsetCloseToZero = approximately(offset, 0) const offsetCloseToZero = approximately(offset, 0)
editor.rotateShapesBy( const selectedShapeIds = editor.getSelectedShapeIds()
editor.getSelectedShapeIds(), editor.rotateShapesBy(selectedShapeIds, offsetCloseToZero ? -(HALF_PI / 2) : -offset)
offsetCloseToZero ? -(HALF_PI / 2) : -offset kickoutOccludedShapes(editor, selectedShapeIds)
)
}, },
}, },
{ {

View file

@ -10,17 +10,21 @@ import { DEFAULT_TRANSLATION } from './defaultTranslation'
/* ----------------- (do not change) ---------------- */ /* ----------------- (do not change) ---------------- */
export const RTL_LANGUAGES = new Set(['ar', 'fa', 'he', 'ur', 'ku'])
/** @public */ /** @public */
export type TLUiTranslation = { export type TLUiTranslation = {
readonly locale: string readonly locale: string
readonly label: string readonly label: string
readonly messages: Record<TLUiTranslationKey, string> readonly messages: Record<TLUiTranslationKey, string>
readonly dir: 'rtl' | 'ltr'
} }
const EN_TRANSLATION: TLUiTranslation = { const EN_TRANSLATION: TLUiTranslation = {
locale: 'en', locale: 'en',
label: 'English', label: 'English',
messages: DEFAULT_TRANSLATION as TLUiTranslation['messages'], messages: DEFAULT_TRANSLATION as TLUiTranslation['messages'],
dir: 'ltr',
} }
/** @internal */ /** @internal */
@ -69,6 +73,7 @@ export async function fetchTranslation(
return { return {
locale, locale,
label: language.label, label: language.label,
dir: RTL_LANGUAGES.has(language.locale) ? 'rtl' : 'ltr',
messages: { ...EN_TRANSLATION.messages, ...messages }, messages: { ...EN_TRANSLATION.messages, ...messages },
} }
} }

View file

@ -27,7 +27,8 @@ const TranslationsContext = React.createContext<TLUiTranslationContextType>(
{} as TLUiTranslationContextType {} as TLUiTranslationContextType
) )
const useCurrentTranslation = () => React.useContext(TranslationsContext) /** @public */
export const useCurrentTranslation = () => React.useContext(TranslationsContext)
/** /**
* Provides a translation context to the editor. * Provides a translation context to the editor.
@ -47,6 +48,7 @@ export const TranslationProvider = track(function TranslationProvider({
return { return {
locale: 'en', locale: 'en',
label: 'English', label: 'English',
dir: 'ltr',
messages: { ...DEFAULT_TRANSLATION, ...overrides['en'] }, messages: { ...DEFAULT_TRANSLATION, ...overrides['en'] },
} }
} }
@ -54,6 +56,7 @@ export const TranslationProvider = track(function TranslationProvider({
return { return {
locale: 'en', locale: 'en',
label: 'English', label: 'English',
dir: 'ltr',
messages: DEFAULT_TRANSLATION, messages: DEFAULT_TRANSLATION,
} }
}) })

View file

@ -3,7 +3,7 @@ import { Box, Editor, TLFrameShape, TLShapeId, TLShapePartial, Vec, compact } fr
/** /**
* Remove a frame. * Remove a frame.
* *
* @param editor - tlraw editor instance. * @param editor - tldraw editor instance.
* @param ids - Ids of the frames you wish to remove. * @param ids - Ids of the frames you wish to remove.
* *
* @public * @public

View file

@ -0,0 +1,20 @@
import { Geometry2d, Group2d } from '@tldraw/editor'
/**
* Return all the text labels in a geometry.
*
* @param geometry - The geometry to get the text labels from.
*
* @public
*/
export function getTextLabels(geometry: Geometry2d) {
if (geometry.isLabel) {
return [geometry]
}
if (geometry instanceof Group2d) {
return geometry.children.filter((child) => child.isLabel)
}
return []
}

View file

@ -20,6 +20,74 @@ beforeEach(() => {
.createShapes([{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }]) .createShapes([{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
}) })
describe('TLSelectTool.Idle', () => {
it('Updates hovered ID on pointer move', () => {
editor.pointerMove(100, 100)
expect(editor.getHoveredShapeId()).not.toBeNull()
})
it('Transitions to pointing_shape on shape pointer down', () => {
const shape = editor.getShape(ids.box1)!
editor.pointerDown(shape.x + 10, shape.y + 10, { target: 'shape', shape })
editor.expectToBeIn('select.pointing_shape')
})
it('Transitions to pointing_canvas on canvas pointer down', () => {
editor.pointerDown(10, 10, { target: 'canvas' })
editor.expectToBeIn('select.pointing_canvas')
})
it('Nudges selected shapes on arrow key down', () => {
const shape = editor.getShape(ids.box1)!
editor.select(shape.id)
editor.keyDown('ArrowRight')
// Assuming nudgeSelectedShapes moves the shape by 1 unit to the right
const nudgedShape = editor.getShape(shape.id)
expect(nudgedShape).toBeDefined()
expect(nudgedShape?.x).toBe(101)
})
})
// todo: turn on feature flag for these tests or remove them
describe.skip('Edit on type', () => {
it('Starts editing shape on key down if shape does auto-edit on key stroke', () => {
const id = createShapeId()
editor.createShapes([{ id, type: 'note', x: 100, y: 100, props: { text: 'hello' } }])!
const shape = editor.getShape(id)!
editor.select(shape.id)
editor.keyDown('a') // Press a key that would start editing
expect(editor.getEditingShapeId()).toBe(shape.id)
})
it('Does not start editing if shape does not auto-edit on key stroke', () => {
const shape = editor.getShape(ids.box1)!
editor.select(shape.id)
editor.keyDown('a') // Press a key that would not start editing for non-editable shapes
expect(editor.getEditingShapeId()).not.toBe(shape.id)
})
it('Does not start editing on excluded keys', () => {
const id = createShapeId()
editor.createShapes([{ id, type: 'note', x: 100, y: 100, props: { text: 'hello' } }])!
const shape = editor.getShape(id)!
editor.select(shape.id)
editor.keyDown('Enter') // Press an excluded key
expect(editor.getEditingShapeId()).not.toBe(shape.id)
})
it('Ignores key down if altKey or ctrlKey is pressed', () => {
const id = createShapeId()
editor.createShapes([{ id, type: 'note', x: 100, y: 100, props: { text: 'hello' } }])!
const shape = editor.getShape(id)!
editor.select(shape.id)
// Simulate altKey being pressed
editor.keyDown('a', { altKey: true })
// Simulate ctrlKey being pressed
editor.keyDown('a', { ctrlKey: true })
expect(editor.getEditingShapeId()).not.toBe(shape.id)
})
})
describe('TLSelectTool.Translating', () => { describe('TLSelectTool.Translating', () => {
it('Enters from pointing and exits to idle', () => { it('Enters from pointing and exits to idle', () => {
const shape = editor.getShape(ids.box1) const shape = editor.getShape(ids.box1)
@ -369,11 +437,11 @@ describe('When editing shapes', () => {
// start editing the geo shape // start editing the geo shape
editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(ids.geo1) }) editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(ids.geo1) })
expect(editor.getEditingShapeId()).toBe(ids.geo1) expect(editor.getEditingShapeId()).toBe(ids.geo1)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.geo1) expect(editor.getOnlySelectedShapeId()).toBe(ids.geo1)
// point the text shape // point the text shape
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) }) editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) })
expect(editor.getEditingShapeId()).toBe(null) expect(editor.getEditingShapeId()).toBe(null)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.text1) expect(editor.getOnlySelectedShapeId()).toBe(ids.text1)
}) })
// The behavior described here will only work end to end, not with the library, // The behavior described here will only work end to end, not with the library,
@ -385,12 +453,12 @@ describe('When editing shapes', () => {
// start editing the geo shape // start editing the geo shape
editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(ids.geo1) }) editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(ids.geo1) })
expect(editor.getEditingShapeId()).toBe(ids.geo1) expect(editor.getEditingShapeId()).toBe(ids.geo1)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.geo1) expect(editor.getOnlySelectedShapeId()).toBe(ids.geo1)
// point the other geo shape // point the other geo shape
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.geo2) }) editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.geo2) })
// that other shape should now be editing and selected! // that other shape should now be editing and selected!
expect(editor.getEditingShapeId()).toBe(ids.geo2) expect(editor.getEditingShapeId()).toBe(ids.geo2)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.geo2) expect(editor.getOnlySelectedShapeId()).toBe(ids.geo2)
}) })
// This works but only end to end — the logic had to move to React // This works but only end to end — the logic had to move to React
@ -403,7 +471,7 @@ describe('When editing shapes', () => {
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text2) }) editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text2) })
// that other shape should now be editing and selected! // that other shape should now be editing and selected!
expect(editor.getEditingShapeId()).toBe(ids.text2) expect(editor.getEditingShapeId()).toBe(ids.text2)
expect(editor.getOnlySelectedShape()?.id).toBe(ids.text2) expect(editor.getOnlySelectedShapeId()).toBe(ids.text2)
}) })
it('Double clicking the canvas creates a new text shape', () => { it('Double clicking the canvas creates a new text shape', () => {
@ -433,7 +501,7 @@ describe('When editing shapes', () => {
expect(editor.getShape(shapeId)).toBe(undefined) expect(editor.getShape(shapeId)).toBe(undefined)
}) })
it('It deletes an empty text shape when your click another text shape', () => { it('It deletes an empty text shape when you click another text shape', () => {
expect(editor.getEditingShapeId()).toBe(null) expect(editor.getEditingShapeId()).toBe(null)
expect(editor.getSelectedShapeIds().length).toBe(0) expect(editor.getSelectedShapeIds().length).toBe(0)
expect(editor.getCurrentPageShapes().length).toBe(5) expect(editor.getCurrentPageShapes().length).toBe(5)

View file

@ -85,7 +85,7 @@ export class TestEditor extends Editor {
lineHeight: number lineHeight: number
maxWidth: null | number maxWidth: null | number
} }
): BoxModel => { ): BoxModel & { scrollWidth: number } => {
const breaks = textToMeasure.split('\n') const breaks = textToMeasure.split('\n')
const longest = breaks.reduce((acc, curr) => { const longest = breaks.reduce((acc, curr) => {
return curr.length > acc.length ? curr : acc return curr.length > acc.length ? curr : acc
@ -100,6 +100,7 @@ export class TestEditor extends Editor {
h: h:
(opts.maxWidth === null ? breaks.length : Math.ceil(w % opts.maxWidth) + breaks.length) * (opts.maxWidth === null ? breaks.length : Math.ceil(w % opts.maxWidth) + breaks.length) *
opts.fontSize, opts.fontSize,
scrollWidth: opts.maxWidth === null ? w : Math.max(w, opts.maxWidth),
} }
} }
@ -114,6 +115,29 @@ export class TestEditor extends Editor {
// Turn off edge scrolling for tests. Tests that require this can turn it back on. // Turn off edge scrolling for tests. Tests that require this can turn it back on.
this.user.updateUserPreferences({ edgeScrollSpeed: 0 }) this.user.updateUserPreferences({ edgeScrollSpeed: 0 })
this.sideEffects.registerAfterCreateHandler('shape', (record) => {
this._lastCreatedShapes.push(record)
})
}
private _lastCreatedShapes: TLShape[] = []
/**
* Get the last created shapes.
*
* @param count - The number of shapes to get.
*/
getLastCreatedShapes(count = 1) {
return this._lastCreatedShapes.slice(-count).map((s) => this.getShape(s)!)
}
/**
* Get the last created shape.
*/
getLastCreatedShape<T extends TLShape>() {
const lastShape = this._lastCreatedShapes[this._lastCreatedShapes.length - 1] as T
return this.getShape<T>(lastShape)!
} }
elm: HTMLDivElement elm: HTMLDivElement

View file

@ -1,5 +1,6 @@
import { import {
DefaultFillStyle, DefaultFillStyle,
GeoShapeGeoStyle,
TLArrowShape, TLArrowShape,
TLFrameShape, TLFrameShape,
TLShapeId, TLShapeId,
@ -275,8 +276,8 @@ describe('frame shapes', () => {
expect(parentBefore).toBe(frameId) expect(parentBefore).toBe(frameId)
// resize the frame so the shape is partially out of bounds // resize the frame so the shape is partially out of bounds
editor.pointerDown(100, 50, { target: 'selection', handle: 'right' }) editor.pointerDown(100, 50, { target: 'selection', handle: 'right' })
editor.pointerMove(70, 50) editor.pointerMove(80, 50)
editor.pointerUp(70, 50) editor.pointerUp(80, 50)
const parentAfter = editor.getShape(rectId)?.parentId const parentAfter = editor.getShape(rectId)?.parentId
expect(parentAfter).toBe(frameId) expect(parentAfter).toBe(frameId)
}) })
@ -405,7 +406,7 @@ describe('frame shapes', () => {
expect(editor.getOnlySelectedShape()!.id).toBe(boxAid) expect(editor.getOnlySelectedShape()!.id).toBe(boxAid)
expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId) expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
expect(editor.getHintingShapeIds()).toHaveLength(0) expect(editor.getHintingShapeIds()).toHaveLength(1)
// box A should still be beneath box B // box A should still be beneath box B
expect(editor.getShape(boxAid)!.index.localeCompare(editor.getShape(boxBid)!.index)).toBe(-1) expect(editor.getShape(boxAid)!.index.localeCompare(editor.getShape(boxBid)!.index)).toBe(-1)
}) })
@ -926,7 +927,7 @@ describe('When dragging a shape inside a group inside a frame', () => {
editor.pointerMove(100, 100).click().click() editor.pointerMove(100, 100).click().click()
expect(editor.getOnlySelectedShape()?.id).toBe(ids.box1) expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
editor.pointerMove(150, 150).pointerDown().pointerMove(140, 140) editor.pointerMove(150, 150).pointerDown().pointerMove(140, 140)
@ -946,7 +947,7 @@ describe('When dragging a shape inside a group inside a frame', () => {
editor.pointerMove(100, 100).click().click() editor.pointerMove(100, 100).click().click()
expect(editor.getOnlySelectedShape()?.id).toBe(ids.box1) expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
expect(editor.getFocusedGroupId()).toBe(ids.group1) expect(editor.getFocusedGroupId()).toBe(ids.group1)
editor editor
@ -1024,6 +1025,65 @@ function dragCreateFrame({
return frameId return frameId
} }
function dragCreateRect({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('geo')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
const rectId = shapes[0].id
return rectId
}
function dragCreateTriangle({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('geo')
const originalStyle = editor.getStyleForNextShape(GeoShapeGeoStyle)
editor.setStyleForNextShapes(GeoShapeGeoStyle, 'triangle')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
editor.selectNone()
editor.setStyleForNextShapes(GeoShapeGeoStyle, originalStyle)
const rectId = shapes[0].id
editor.select(shapes[0].id)
return rectId
}
function dragCreateLine({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('line')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
const lineId = shapes[0].id
return lineId
}
function createRect({ pos, size }: { pos: [number, number]; size: [number, number] }) { function createRect({ pos, size }: { pos: [number, number]; size: [number, number] }) {
const rectId: TLShapeId = createShapeId() const rectId: TLShapeId = createShapeId()
editor.createShapes([ editor.createShapes([
@ -1037,3 +1097,117 @@ function createRect({ pos, size }: { pos: [number, number]; size: [number, numbe
]) ])
return rectId return rectId
} }
describe('Unparenting behavior', () => {
it("unparents a shape when it's completely dragged out of a frame, even when the pointer doesn't move across the edge of the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(110, 50)
editor.pointerMove(140, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(140, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("doesn't unparent a shape when it's partially dragged out of a frame, when the pointer doesn't move across the edge of the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(110, 50)
editor.pointerMove(120, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(120, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
})
it('unparents a shape when the pointer drags across the edge of a frame, even if its geometry overlaps with the frame', () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(90, 50)
editor.pointerMove(110, 50)
jest.advanceTimersByTime(200)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerUp(110, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents a shape when it's rotated out of a frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [95, 10], move: [200, 20], up: [200, 20] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(200, 20, {
target: 'selection',
handle: 'top_right_rotate',
})
editor.pointerMove(200, 200)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(200, 200)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents shapes if they're resized out of a frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [10, 10], move: [20, 20], up: [20, 20] })
dragCreateRect({ down: [80, 80], move: [90, 90], up: [90, 90] })
const [frame, rect1, rect2] = editor.getLastCreatedShapes(3)
editor.select(rect1.id, rect2.id)
editor.pointerDown(90, 90, { target: 'selection', handle: 'top_right' })
expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
editor.pointerMove(200, 200)
expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
editor.pointerUp(200, 200)
expect(editor.getShape(rect2.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents a shape if its geometry doesn't overlap with the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateTriangle({ down: [80, 80], move: [120, 120], up: [120, 120] })
const [frame, triangle] = editor.getLastCreatedShapes(2)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
editor.pointerDown(85, 85)
editor.pointerMove(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
editor.pointerUp(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("only parents on pointer up if the shape's geometry overlaps with the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateTriangle({ down: [120, 120], move: [160, 160], up: [160, 160] })
const [frame, triangle] = editor.getLastCreatedShapes(2)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerDown(125, 125)
editor.pointerMove(95, 95)
jest.advanceTimersByTime(200)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
expect(editor.getHintingShapeIds()).toHaveLength(0)
editor.pointerUp(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
})
it('unparents an occluded shape after dragging a handle out of a frame', () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateLine({ down: [90, 90], move: [120, 120], up: [120, 120] })
const [frame, line] = editor.getLastCreatedShapes(2)
expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
editor.pointerDown(90, 90)
editor.pointerMove(110, 110)
expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
editor.pointerUp(110, 110)
expect(editor.getShape(line.id)!.parentId).toBe(editor.getCurrentPageId())
})
})

View file

@ -15,7 +15,7 @@ describe(SelectTool, () => {
describe('pointer down while shape is being edited', () => { describe('pointer down while shape is being edited', () => {
it('captures the pointer down event if it is on the shape', () => { it('captures the pointer down event if it is on the shape', () => {
editor.setCurrentTool('geo').pointerDown(0, 0).pointerMove(100, 100).pointerUp(100, 100) editor.setCurrentTool('geo').pointerDown(0, 0).pointerMove(100, 100).pointerUp(100, 100)
const shapeId = editor.getOnlySelectedShape()?.id const shapeId = editor.getLastCreatedShape().id
editor._transformPointerDownSpy.mockRestore() editor._transformPointerDownSpy.mockRestore()
editor._transformPointerUpSpy.mockRestore() editor._transformPointerUpSpy.mockRestore()
editor.setCurrentTool('select') editor.setCurrentTool('select')
@ -42,7 +42,7 @@ describe(SelectTool, () => {
}) })
it('does not allow pressing undo to end up in the editing state', () => { it('does not allow pressing undo to end up in the editing state', () => {
editor.setCurrentTool('geo').pointerDown(0, 0).pointerMove(100, 100).pointerUp(100, 100) editor.setCurrentTool('geo').pointerDown(0, 0).pointerMove(100, 100).pointerUp(100, 100)
const shapeId = editor.getOnlySelectedShape()?.id const shapeId = editor.getLastCreatedShape().id
editor._transformPointerDownSpy.mockRestore() editor._transformPointerDownSpy.mockRestore()
editor._transformPointerUpSpy.mockRestore() editor._transformPointerUpSpy.mockRestore()
editor.setCurrentTool('select') editor.setCurrentTool('select')

View file

@ -253,7 +253,7 @@ describe('when shape is hollow', () => {
}) })
it('misses on pointer down over shape, misses on pointer up', () => { it('misses on pointer down over shape, misses on pointer up', () => {
editor.pointerMove(75, 75) editor.pointerMove(10, 10)
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.pointerDown() editor.pointerDown()
expect(editor.getSelectedShapeIds()).toEqual([]) expect(editor.getSelectedShapeIds()).toEqual([])
@ -548,7 +548,7 @@ describe('when shape is inside of a frame', () => {
}) })
it('misses on pointer down over shape, misses on pointer up', () => { it('misses on pointer down over shape, misses on pointer up', () => {
editor.pointerMove(50, 50) editor.pointerMove(35, 35)
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.pointerDown() // inside of box1 (which is empty) editor.pointerDown() // inside of box1 (which is empty)
expect(editor.getSelectedShapeIds()).toEqual([]) expect(editor.getSelectedShapeIds()).toEqual([])
@ -1235,7 +1235,7 @@ describe('when shift+selecting', () => {
it('does not add hollow shape to selection on pointer up when in empty space', () => { it('does not add hollow shape to selection on pointer up when in empty space', () => {
expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
editor.keyDown('Shift') editor.keyDown('Shift')
editor.pointerMove(275, 75) // above box 2 editor.pointerMove(215, 75) // above box 2
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.pointerDown() editor.pointerDown()
expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
@ -1256,7 +1256,7 @@ describe('when shift+selecting', () => {
it('does not add and remove hollow shape from selection on pointer up (without causing an edit by double clicks)', () => { it('does not add and remove hollow shape from selection on pointer up (without causing an edit by double clicks)', () => {
editor.keyDown('Shift') editor.keyDown('Shift')
editor.pointerMove(275, 75) // above box 2, empty space editor.pointerMove(215, 75) // above box 2, empty space
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.pointerDown() editor.pointerDown()
expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
@ -1270,7 +1270,7 @@ describe('when shift+selecting', () => {
it('does not add and remove hollow shape from selection on double clicks (without causing an edit by double clicks)', () => { it('does not add and remove hollow shape from selection on double clicks (without causing an edit by double clicks)', () => {
editor.keyDown('Shift') editor.keyDown('Shift')
editor.pointerMove(275, 75) // above box 2, empty space editor.pointerMove(215, 75) // above box 2, empty space
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.doubleClick() editor.doubleClick()
expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
@ -1304,7 +1304,7 @@ describe('when shift+selecting a group', () => {
it('does not add to selection on shift + on pointer up when clicking in hollow shape', () => { it('does not add to selection on shift + on pointer up when clicking in hollow shape', () => {
editor.keyDown('Shift') editor.keyDown('Shift')
editor.pointerMove(275, 75) editor.pointerMove(215, 75)
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.pointerDown() // inside of box 2, inside of group 1 editor.pointerDown() // inside of box 2, inside of group 1
expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
@ -1333,7 +1333,7 @@ describe('when shift+selecting a group', () => {
}) })
it('does not select when shift+clicking into hollow shape inside of a group', () => { it('does not select when shift+clicking into hollow shape inside of a group', () => {
editor.pointerMove(275, 75) editor.pointerMove(215, 75)
editor.keyDown('Shift') editor.keyDown('Shift')
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.pointerDown() // inside of box 2, empty space, inside of group 1 editor.pointerDown() // inside of box 2, empty space, inside of group 1
@ -1344,7 +1344,7 @@ describe('when shift+selecting a group', () => {
it('does not deselect on pointer up when clicking into empty space in hollow shape', () => { it('does not deselect on pointer up when clicking into empty space in hollow shape', () => {
editor.keyDown('Shift') editor.keyDown('Shift')
editor.pointerMove(275, 75) editor.pointerMove(215, 75)
expect(editor.getHoveredShapeId()).toBe(null) expect(editor.getHoveredShapeId()).toBe(null)
editor.pointerDown() // inside of box 2, empty space, inside of group 1 editor.pointerDown() // inside of box 2, empty space, inside of group 1
expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1])

View file

@ -4,6 +4,7 @@ import {
SnapIndicator, SnapIndicator,
TLArrowShape, TLArrowShape,
TLGeoShape, TLGeoShape,
TLNoteShape,
TLShapeId, TLShapeId,
TLShapePartial, TLShapePartial,
Vec, Vec,
@ -1943,3 +1944,189 @@ describe('Moving the camera while panning', () => {
.expectScreenBoundsToBe(ids.box1, { x: 10, y: 10 }) .expectScreenBoundsToBe(ids.box1, { x: 10, y: 10 })
}) })
}) })
const defaultPitLocations = [
{ x: 100, y: -120 },
{ x: 320, y: 100 },
{ x: 100, y: 320 },
{ x: -120, y: 100 },
]
describe('Note shape grid helper positions / pits', () => {
it('Snaps to pits', () => {
editor
.createShape({ type: 'note' })
.createShape({ type: 'note', x: 500, y: 500 })
.pointerMove(600, 600)
// start translating
.pointerDown()
const shape = editor.getLastCreatedShape<TLNoteShape>()
for (const pit of defaultPitLocations) {
editor
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
.expectShapeToMatch({ ...shape, x: pit.x - 100, y: pit.y - 100 }) // but it's in the pit!
}
})
it('Does not snap to pit if shape has a different rotation', () => {
editor
.createShape({ type: 'note', rotation: 0.001 })
.createShape({ type: 'note', x: 500, y: 500 })
.pointerMove(600, 600)
// start translating
.pointerDown()
const shape = editor.getLastCreatedShape<TLNoteShape>()
for (const pit of defaultPitLocations) {
const rotatedPit = new Vec(pit.x, pit.y).rot(0.001)
editor
.pointerMove(rotatedPit.x - 4, rotatedPit.y - 4) // not exactly in the pit...
.expectShapeToMatch({ ...shape, x: rotatedPit.x - 104, y: rotatedPit.y - 104 }) // and NOT in the pit
}
})
it('Snaps to pit if shape has the same rotation', () => {
editor
.createShape({ type: 'note', rotation: 0.001 })
.createShape({ type: 'note', x: 500, y: 500, rotation: 0.001 })
.pointerMove(600, 600)
// start translating
.pointerDown()
const shape = editor.getLastCreatedShape<TLNoteShape>()
for (const pit of defaultPitLocations) {
const rotatedPit = new Vec(pit.x, pit.y).rot(0.001)
const rotatedPointPosition = new Vec(pit.x - 100, pit.y - 100).rot(0.001)
editor
.pointerMove(rotatedPit.x - 4, rotatedPit.y - 4) // not exactly in the pit...
.expectShapeToMatch({ ...shape, x: rotatedPointPosition.x, y: rotatedPointPosition.y }) // and in the pit
}
})
it('Snaps correctly to the top when the translating shape has growY', () => {
editor
.createShape({ type: 'note' })
.createShape({ type: 'note', x: 500, y: 500 })
.updateShape({ ...editor.getLastCreatedShape(), props: { growY: 100 } })
.pointerMove(600, 600)
// start translating
.pointerDown()
const shape = editor.getLastCreatedShape<TLNoteShape>()
expect(shape.props.growY).toBe(100)
const pit = defaultPitLocations[0] // top
editor
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
.expectShapeToMatch({ ...shape, x: pit.x - 104, y: pit.y - 104 }) // not in the pit — the pit is further up!
.pointerMove(pit.x - 4, pit.y - 4 - 100) // account for the translating shape's growY
.expectShapeToMatch({ ...shape, x: pit.x - 100, y: pit.y - 200 }) // and we're in the pit
})
it('Snaps correctly to the bottom when the not-translating shape has growY', () => {
editor
.createShape({ type: 'note' })
.updateShape({ ...editor.getLastCreatedShape(), props: { growY: 100 } })
.createShape({ type: 'note', x: 500, y: 500 })
.pointerMove(600, 600)
// start translating
.pointerDown()
const shape = editor.getLastCreatedShape<TLNoteShape>()
editor
.pointerMove(104, 324) // not exactly in the pit...
.expectShapeToMatch({ ...shape, x: 4, y: 224 }) // not in the pit — the pit is further down!
.pointerMove(104, 424) // account for the shape's growY
.expectShapeToMatch({ ...shape, x: 0, y: 320 }) // and we're in the pit (420 - 100 = 320)
})
it('Snaps multiple notes to the pit using the note under the cursor', () => {
editor.createShape({ type: 'note' })
editor.createShape({ type: 'note', x: 500, y: 500 })
editor.createShape({ type: 'note', x: 700, y: 500, parentId: editor.getCurrentPageId() })
const [shapeB, shapeC] = editor.getLastCreatedShapes(2)
const pit = { x: 320, y: 100 } // right of shapeA
editor.select(shapeB, shapeC)
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 500, y: 500, w: 400, h: 200 })
editor
.pointerMove(600, 600) // center of b
.pointerDown()
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
// B snaps the selection to the pit
// (index is manually set because the sticky gets brought to front)
editor.expectShapeToMatch({ ...shapeB, x: 220, y: 0 })
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 220, y: 0, w: 400, h: 200 })
editor.cancel()
editor
.pointerMove(800, 600) // center of c
.pointerDown()
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
// C snaps the selection to the pit
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 20, y: 0, w: 400, h: 200 })
editor.cancel()
editor
.pointerMove(800, 600) // center of c
.pointerDown()
.pointerMove(pit.x - 4 + 200, pit.y - 4) // B is almost in the pit...
// Even though B is in the same place as it was when it snapped (while dragging over B),
// because our cursor is over C it won't fall into the pit—because it's not hovered
// (index is manually set because the sticky gets brought to front)
editor.expectShapeToMatch({ ...shapeB, x: 216, y: -4 })
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 216, y: -4, w: 400, h: 200 })
})
it('When multiple notes are under the cursor, uses the top-most one', () => {
editor.createShape({ type: 'note' })
editor.createShape({ type: 'note', x: 500, y: 500 })
editor.createShape({ type: 'note', x: 501, y: 501 })
const [shapeB, shapeC] = editor.getLastCreatedShapes(2)
// For the purposes of this test, let's leave the stickies unparented
editor.reparentShapes([shapeC], editor.getCurrentPageId())
const pit = { x: 320, y: 100 } // right of shapeA
editor.select(shapeB, shapeC)
expect(editor.getSelectionPageBounds()).toMatchObject({ x: 500, y: 500, w: 201, h: 201 })
// First we do it with C in front
editor.bringToFront([shapeC])
editor
.pointerMove(600, 600) // center of b but overlapping C
.pointerDown()
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
// B snaps the selection to the pit
editor.expectShapeToMatch({ id: shapeB.id, x: 219, y: -1 }) // not snapped
editor.expectShapeToMatch({ id: shapeC.id, x: 220, y: 0 }) // snapped
editor.cancel()
// Now let's do it with B in front
editor.bringToFront([shapeB])
editor
.pointerMove(600, 600) // center of b but overlapping C
.pointerDown()
.pointerMove(pit.x - 4, pit.y - 4) // not exactly in the pit...
// B snaps the selection to the pit
editor.expectShapeToMatch({ id: shapeB.id, x: 220, y: 0 }) // snapped
editor.expectShapeToMatch({ id: shapeC.id, x: 221, y: 1 }) // not snapped
})
})

View file

@ -683,6 +683,7 @@ export const noteShapeMigrations: Migrations;
export const noteShapeProps: { export const noteShapeProps: {
color: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">; color: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">;
size: EnumStyleProp<"l" | "m" | "s" | "xl">; size: EnumStyleProp<"l" | "m" | "s" | "xl">;
fontSizeAdjustment: T.Validator<number>;
font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">; font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">;
align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">; align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">;
verticalAlign: EnumStyleProp<"end" | "middle" | "start">; verticalAlign: EnumStyleProp<"end" | "middle" | "start">;
@ -727,6 +728,11 @@ export type ShapeProps<Shape extends TLBaseShape<any, any>> = {
[K in keyof Shape['props']]: T.Validatable<Shape['props'][K]>; [K in keyof Shape['props']]: T.Validatable<Shape['props'][K]>;
}; };
// @public (undocumented)
export type ShapePropsType<Config extends Record<string, T.Validatable<any>>> = Expand<{
[K in keyof Config]: T.TypeOf<Config[K]>;
}>;
// @public // @public
export class StyleProp<Type> implements T.Validatable<Type> { export class StyleProp<Type> implements T.Validatable<Type> {
// @internal // @internal
@ -895,6 +901,10 @@ export type TLDefaultColorThemeColor = {
solid: string; solid: string;
semi: string; semi: string;
pattern: string; pattern: string;
note: {
fill: string;
text: string;
};
highlight: { highlight: {
srgb: string; srgb: string;
p3: string; p3: string;

Some files were not shown because too many files have changed in this diff Show more