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>
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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']) {
|
||||||
|
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
151
apps/examples/src/examples/drag-and-drop/DragAndDropExample.tsx
Normal 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.
|
||||||
|
|
||||||
|
*/
|
12
apps/examples/src/examples/drag-and-drop/README.md
Normal 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.
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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([
|
||||||
|
|
|
@ -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 |
|
@ -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': [];
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 (
|
||||||
|
<g className={classNames(`tl-handle tl-handle__${handle.type}`, className)}>
|
||||||
|
<circle className="tl-handle__bg" r={br} />
|
||||||
|
{/* Half circle */}
|
||||||
|
<path className="tl-handle__fg" d={path} transform={`rotate(${-90 + 90 * index})`} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fr = (handle.type === 'create' && isCoarse ? 3 : 4) / Math.max(zoom, 0.35)
|
||||||
return (
|
return (
|
||||||
<g
|
<g className={classNames(`tl-handle tl-handle__${handle.type}`, className)}>
|
||||||
className={classNames(
|
<circle className="tl-handle__bg" r={br} />
|
||||||
'tl-handle',
|
<circle className="tl-handle__fg" r={fr} />
|
||||||
{
|
|
||||||
'tl-handle__virtual': handle.type === 'virtual',
|
|
||||||
'tl-handle__create': handle.type === 'create',
|
|
||||||
},
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<circle className="tl-handle__bg" r={bgRadius} />
|
|
||||||
<circle className="tl-handle__fg" r={fgRadius} />
|
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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)`,
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
|
||||||
/>
|
/>
|
||||||
{shape.props.url && (
|
|
||||||
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
|
|
||||||
)}
|
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
)}
|
)}
|
||||||
|
{shape.props.url && (
|
||||||
|
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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,60 +64,135 @@ 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
|
||||||
|
id={id}
|
||||||
|
className="tl-note__container"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
|
||||||
width: NOTE_SIZE,
|
width: NOTE_SIZE,
|
||||||
height: this.getHeight(shape),
|
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
|
<TextLabel
|
||||||
className="tl-note__container"
|
id={id}
|
||||||
style={{
|
type={type}
|
||||||
color: theme[adjustedColor].solid,
|
font={font}
|
||||||
backgroundColor: theme[adjustedColor].solid,
|
fontSize={fontSizeAdjustment || LABEL_FONT_SIZES[size]}
|
||||||
}}
|
lineHeight={TEXT_PROPS.lineHeight}
|
||||||
>
|
align={align}
|
||||||
<div className="tl-note__scrim" />
|
verticalAlign={verticalAlign}
|
||||||
<TextLabel
|
text={text}
|
||||||
id={id}
|
isNote
|
||||||
type={type}
|
isSelected={isSelected}
|
||||||
font={font}
|
labelColor={theme[color].note.text}
|
||||||
fontSize={LABEL_FONT_SIZES[size]}
|
disableTab
|
||||||
lineHeight={TEXT_PROPS.lineHeight}
|
wrap
|
||||||
align={align}
|
onKeyDown={handleKeyDown}
|
||||||
verticalAlign={verticalAlign}
|
/>
|
||||||
text={text}
|
|
||||||
labelColor="black"
|
|
||||||
wrap
|
|
||||||
/>
|
|
||||||
</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)})`
|
||||||
|
}
|
||||||
|
|
295
packages/tldraw/src/lib/shapes/note/noteCloning.test.ts
Normal 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)
|
||||||
|
})
|
199
packages/tldraw/src/lib/shapes/note/noteHelpers.ts
Normal 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
|
||||||
|
}
|
|
@ -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,40 +105,45 @@ 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() {
|
|
||||||
const {
|
export function getNotePitOffset(editor: Editor, center: Vec) {
|
||||||
inputs: { originPagePoint },
|
let min = NOTE_PIT_RADIUS / editor.getZoomLevel() // in screen space
|
||||||
} = this.editor
|
let offset: Vec | undefined
|
||||||
|
for (const pit of getAvailableNoteAdjacentPositions(editor, 0, 0)) {
|
||||||
const id = createShapeId()
|
// only check page rotations of zero
|
||||||
this.markId = `creating:${id}`
|
const deltaToPit = Vec.Sub(center, pit)
|
||||||
this.editor.mark(this.markId)
|
const dist = deltaToPit.len()
|
||||||
|
if (dist < min) {
|
||||||
this.editor
|
min = dist
|
||||||
.createShapes([
|
offset = deltaToPit
|
||||||
{
|
}
|
||||||
id,
|
}
|
||||||
type: 'note',
|
return offset
|
||||||
x: originPagePoint.x,
|
}
|
||||||
y: originPagePoint.y,
|
|
||||||
},
|
export function createSticky(editor: Editor, id: TLShapeId, center: Vec) {
|
||||||
])
|
editor
|
||||||
.select(id)
|
.createShape({
|
||||||
|
id,
|
||||||
const shape = this.editor.getShape<TLNoteShape>(id)!
|
type: 'note',
|
||||||
const bounds = this.editor.getShapeGeometry(shape).bounds
|
x: center.x,
|
||||||
|
y: center.y,
|
||||||
// Center the text around the created point
|
})
|
||||||
this.editor.updateShapes([
|
.select(id)
|
||||||
{
|
|
||||||
id,
|
const shape = editor.getShape<TLNoteShape>(id)!
|
||||||
type: 'note',
|
const bounds = editor.getShapeGeometry(shape).bounds
|
||||||
x: shape.x - bounds.width / 2,
|
|
||||||
y: shape.y - bounds.height / 2,
|
// Center the text around the created point
|
||||||
},
|
editor.updateShapes([
|
||||||
])
|
{
|
||||||
|
id,
|
||||||
return this.editor.getShape<TLNoteShape>(id)!
|
type: 'note',
|
||||||
}
|
x: shape.x - bounds.width / 2,
|
||||||
|
y: shape.y - bounds.height / 2,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
return editor.getShape<TLNoteShape>(id)!
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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(() => {
|
||||||
const elm = rInput.current
|
function selectAllIfEditing({ shapeId }: { shapeId: TLShapeId }) {
|
||||||
if (elm && isEditing && document.activeElement !== elm) {
|
if (shapeId === id) {
|
||||||
elm.focus()
|
const elm = rInput.current
|
||||||
}
|
if (elm) {
|
||||||
}, [isEditing])
|
if (document.activeElement !== elm) {
|
||||||
|
elm.focus()
|
||||||
// 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?
|
||||||
// important! these ^v are two different things
|
if (editingShapeId) {
|
||||||
// is that shape OUR shape?
|
// important! these ^v are two different things
|
||||||
if (elm && editingShapeId === id) {
|
// is that shape OUR shape?
|
||||||
if (ranges) {
|
if (elm && editingShapeId === id) {
|
||||||
if (!ranges.length) {
|
elm.focus()
|
||||||
// If we don't have any ranges, restore selection
|
if (ranges && ranges.length) {
|
||||||
// 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()
|
|
||||||
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': {
|
||||||
preventDefault(e)
|
if (!opts.disableTab) {
|
||||||
if (e.shiftKey) {
|
preventDefault(e)
|
||||||
TextHelpers.unindent(e.currentTarget)
|
if (e.shiftKey) {
|
||||||
} else {
|
TextHelpers.unindent(e.currentTarget)
|
||||||
TextHelpers.indent(e.currentTarget)
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,29 +71,30 @@ 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"
|
type="text"
|
||||||
type="text"
|
font={font}
|
||||||
font={font}
|
fontSize={FONT_SIZES[size]}
|
||||||
fontSize={FONT_SIZES[size]}
|
lineHeight={TEXT_PROPS.lineHeight}
|
||||||
lineHeight={TEXT_PROPS.lineHeight}
|
align={align}
|
||||||
align={align}
|
verticalAlign="middle"
|
||||||
verticalAlign="middle"
|
text={text}
|
||||||
text={text}
|
labelColor={theme[color].solid}
|
||||||
labelColor={color}
|
isSelected={isSelected}
|
||||||
textWidth={width}
|
textWidth={width}
|
||||||
textHeight={height}
|
textHeight={height}
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
}}
|
}}
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,54 +67,59 @@ 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) {
|
// for shapes with labels, check to see if the click was inside of the shape's label
|
||||||
// clicked a shape of the same type as the editing shape
|
const geometry = this.editor.getShapeUtil(selectingShape).getGeometry(selectingShape)
|
||||||
|
const textLabels = getTextLabels(geometry)
|
||||||
|
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(
|
||||||
|
selectingShape,
|
||||||
|
this.editor.inputs.currentPagePoint
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
this.editor.isShapeOfType<TLGeoShape>(shape, 'geo') ||
|
textLabel.bounds.containsPoint(pointInShapeSpace, 0) &&
|
||||||
this.editor.isShapeOfType<TLArrowShape>(shape, 'arrow')
|
textLabel.hitTestPoint(pointInShapeSpace)
|
||||||
) {
|
) {
|
||||||
// for shapes with labels, check to see if the click was inside of the shape's label
|
// it's a hit to the label!
|
||||||
const geometry = this.editor.getShapeUtil(shape).getGeometry(shape) as Group2d
|
if (selectingShape.id === editingShape.id) {
|
||||||
const labelGeometry = geometry.children[1]
|
// If we clicked on the editing geo / arrow shape's label, do nothing
|
||||||
if (labelGeometry) {
|
return
|
||||||
const pointInShapeSpace = this.editor.getPointInShapeSpace(
|
|
||||||
shape,
|
|
||||||
this.editor.inputs.currentPagePoint
|
|
||||||
)
|
|
||||||
if (labelGeometry.bounds.containsPoint(pointInShapeSpace)) {
|
|
||||||
// it's a hit to the label!
|
|
||||||
if (shape.id === editingShape.id) {
|
|
||||||
// If we clicked on the editing geo / arrow shape's label, do nothing
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
this.parent.transition('pointing_shape', info)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (shape.id === editingShape.id) {
|
|
||||||
// If we clicked on a frame, while editing its heading, cancel editing
|
|
||||||
if (this.editor.isShapeOfType<TLFrameShape>(shape, 'frame')) {
|
|
||||||
this.editor.setEditingShape(null)
|
|
||||||
}
|
|
||||||
// If we clicked on the editing shape (which isn't a shape with a label), do nothing
|
|
||||||
} else {
|
} else {
|
||||||
// But if we clicked on a different shape of the same type, transition to pointing_shape instead
|
this.hitShapeForPointerUp = selectingShape
|
||||||
this.parent.transition('pointing_shape', info)
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// clicked a different kind of shape
|
if (selectingShape.id === editingShape.id) {
|
||||||
|
// If we clicked on a frame, while editing its heading, cancel editing
|
||||||
|
if (this.editor.isShapeOfType<TLFrameShape>(selectingShape, 'frame')) {
|
||||||
|
this.editor.setEditingShape(null)
|
||||||
|
}
|
||||||
|
// If we clicked on the editing shape (which isn't a shape with a label), do nothing
|
||||||
|
} else {
|
||||||
|
// But if we clicked on a different shape of the same type, transition to pointing_shape instead
|
||||||
|
this.parent.transition('pointing_shape', info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
...info,
|
onlySelectedShape,
|
||||||
target: 'shape',
|
{
|
||||||
shape: onlySelectedShape,
|
...info,
|
||||||
})
|
target: 'shape',
|
||||||
|
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) {
|
||||||
|
|
|
@ -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 = () => {
|
||||||
this.complete()
|
const shape = this.editor.getShape<TLArrowShape>(this.shapeId)
|
||||||
|
if (!shape) return
|
||||||
|
|
||||||
|
if (this.didDrag || !this.wasAlreadySelected) {
|
||||||
|
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'] = () => {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
155
packages/tldraw/src/lib/tools/SelectTool/selectHelpers.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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,24 +322,28 @@ 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')
|
||||||
|
const shapes = editor
|
||||||
|
.getSelectedShapes()
|
||||||
|
.filter(
|
||||||
|
(shape): shape is TLTextShape =>
|
||||||
|
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
|
||||||
|
)
|
||||||
editor.updateShapes(
|
editor.updateShapes(
|
||||||
editor
|
shapes.map((shape) => {
|
||||||
.getSelectedShapes()
|
return {
|
||||||
.filter(
|
id: shape.id,
|
||||||
(shape): shape is TLTextShape =>
|
type: shape.type,
|
||||||
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
|
props: {
|
||||||
)
|
...shape.props,
|
||||||
.map((shape) => {
|
w: 8,
|
||||||
return {
|
autoSize: true,
|
||||||
id: shape.id,
|
},
|
||||||
type: shape.type,
|
}
|
||||||
props: {
|
})
|
||||||
...shape.props,
|
)
|
||||||
w: 8,
|
kickoutOccludedShapes(
|
||||||
autoSize: true,
|
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)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
20
packages/tldraw/src/lib/utils/shapes/shapes.ts
Normal 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 []
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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;
|
||||||
|
|