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() {
|
||||
return (
|
||||
<TldrawUiMenuGroup id="release">
|
||||
<TldrawUiMenuItem
|
||||
id="release-info"
|
||||
label={`Version ${RELEASE_INFO}`}
|
||||
onSelect={() => {
|
||||
window.alert(`${RELEASE_INFO}`)
|
||||
}}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="v1"
|
||||
label="Test v1 content"
|
||||
|
@ -22,6 +15,13 @@ export function DebugMenuItems() {
|
|||
window.location.reload()
|
||||
}}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="release-info"
|
||||
label={'Release info'}
|
||||
onSelect={() => {
|
||||
window.alert(`${RELEASE_INFO}`)
|
||||
}}
|
||||
/>
|
||||
</TldrawUiMenuGroup>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -41,9 +41,9 @@ export async function setupPageWithShapes(page: PlaywrightTestArgs['page']) {
|
|||
await page.mouse.click(200, 250)
|
||||
await page.keyboard.press('r')
|
||||
await page.mouse.click(250, 300)
|
||||
|
||||
// deselect everything
|
||||
await page.evaluate(() => editor.selectNone())
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
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 { BoxModel, Editor } from 'tldraw'
|
||||
import { BoxModel, Editor, TLNoteShape, TLShapeId } from 'tldraw'
|
||||
import { setupPage } from '../shared-e2e'
|
||||
|
||||
export function sleep(ms: number) {
|
||||
|
@ -242,4 +242,92 @@ test.describe('text measurement', () => {
|
|||
|
||||
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 {
|
||||
DefaultColorStyle,
|
||||
DefaultFontStyle,
|
||||
|
@ -9,6 +8,7 @@ import {
|
|||
Geometry2d,
|
||||
LABEL_FONT_SIZES,
|
||||
Polygon2d,
|
||||
ShapePropsType,
|
||||
ShapeUtil,
|
||||
T,
|
||||
TEXT_PROPS,
|
||||
|
@ -20,11 +20,11 @@ import {
|
|||
TextLabel,
|
||||
Vec,
|
||||
ZERO_INDEX_KEY,
|
||||
getDefaultColorTheme,
|
||||
resizeBox,
|
||||
structuredClone,
|
||||
vecModelValidator,
|
||||
} from 'tldraw'
|
||||
import { useDefaultColorTheme } from 'tldraw/src/lib/shapes/shared/ShapeFill'
|
||||
import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers'
|
||||
|
||||
// Copied from tldraw/tldraw
|
||||
|
@ -176,11 +176,11 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
|||
type,
|
||||
props: { color, font, size, align, text },
|
||||
} = shape
|
||||
const theme = getDefaultColorTheme({
|
||||
isDarkMode: this.editor.user.getIsDarkMode(),
|
||||
})
|
||||
const vertices = getSpeechBubbleVertices(shape)
|
||||
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 (
|
||||
<>
|
||||
|
@ -192,7 +192,6 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
|||
fill={'none'}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<TextLabel
|
||||
id={id}
|
||||
type={type}
|
||||
|
@ -202,7 +201,9 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
|
|||
align={align}
|
||||
verticalAlign="start"
|
||||
text={text}
|
||||
labelColor={color}
|
||||
labelColor={theme[color].solid}
|
||||
isSelected={isSelected}
|
||||
disableTab
|
||||
wrap
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -21,7 +21,7 @@ setDefaultEditorAssetUrls(assetUrls)
|
|||
setDefaultUiAssetUrls(assetUrls)
|
||||
const gettingStartedExamples = examples.find((e) => e.id === 'Getting started')
|
||||
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')
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
|
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>;
|
||||
|
||||
// @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)
|
||||
export const DEFAULT_ANIMATION_OPTIONS: {
|
||||
|
@ -700,6 +713,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getInstanceState(): TLInstance;
|
||||
getIsMenuOpen(): boolean;
|
||||
getOnlySelectedShape(): null | TLShape;
|
||||
getOnlySelectedShapeId(): null | TLShapeId;
|
||||
getOpenMenus(): string[];
|
||||
getOutermostSelectableShape(shape: TLShape | TLShapeId, filter?: (shape: TLShape) => boolean): TLShape;
|
||||
getPage(page: TLPage | TLPageId): TLPage | undefined;
|
||||
|
@ -812,7 +826,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
pointerVelocity: Vec;
|
||||
};
|
||||
interrupt(): this;
|
||||
isAncestorSelected(shape: TLShape | TLShapeId): boolean;
|
||||
isIn(path: string): boolean;
|
||||
isInAny(...paths: string[]): boolean;
|
||||
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)
|
||||
export function polygonsIntersect(a: VecLike[], b: VecLike[]): boolean;
|
||||
|
||||
|
@ -1650,9 +1666,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
|
|||
onDoubleClickEdge?: TLOnDoubleClickHandler<Shape>;
|
||||
onDoubleClickHandle?: TLOnDoubleClickHandleHandler<Shape>;
|
||||
onDragShapesOut?: TLOnDragHandler<Shape>;
|
||||
onDragShapesOver?: TLOnDragHandler<Shape, {
|
||||
shouldHint: boolean;
|
||||
}>;
|
||||
onDragShapesOver?: TLOnDragHandler<Shape>;
|
||||
onDropShapesOver?: TLOnDragHandler<Shape>;
|
||||
onEditEnd?: TLOnEditEndHandler<Shape>;
|
||||
onHandleDrag?: TLOnHandleDragHandler<Shape>;
|
||||
|
@ -1724,6 +1738,9 @@ export class SideEffectManager<CTX extends {
|
|||
}>): () => void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const SIDES: readonly ["top", "right", "bottom", "left"];
|
||||
|
||||
export { Signal }
|
||||
|
||||
// @public (undocumented)
|
||||
|
@ -2187,6 +2204,10 @@ export interface TLEventMap {
|
|||
count: number;
|
||||
}];
|
||||
// (undocumented)
|
||||
'select-all-text': [{
|
||||
shapeId: TLShapeId;
|
||||
}];
|
||||
// (undocumented)
|
||||
'stop-camera-animation': [];
|
||||
// (undocumented)
|
||||
'stop-following': [];
|
||||
|
|
|
@ -11297,6 +11297,42 @@
|
|||
"isAbstract": false,
|
||||
"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",
|
||||
"canonicalReference": "@tldraw/editor!Editor#getOpenMenus:member(1)",
|
||||
|
@ -14601,64 +14637,6 @@
|
|||
"isAbstract": false,
|
||||
"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",
|
||||
"canonicalReference": "@tldraw/editor!Editor#isIn:member(1)",
|
||||
|
@ -28086,6 +28064,77 @@
|
|||
},
|
||||
"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",
|
||||
"canonicalReference": "@tldraw/editor!polygonsIntersect:function(1)",
|
||||
|
@ -31650,7 +31699,7 @@
|
|||
{
|
||||
"kind": "Property",
|
||||
"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": [
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -31663,7 +31712,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "<Shape, {\n shouldHint: boolean;\n }>"
|
||||
"text": "<Shape>"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -33294,6 +33343,29 @@
|
|||
],
|
||||
"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",
|
||||
"canonicalReference": "@tldraw/editor!SIN:function(1)",
|
||||
|
@ -38849,6 +38921,42 @@
|
|||
"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",
|
||||
"canonicalReference": "@tldraw/editor!TLEventMap#\"stop-camera-animation\":member",
|
||||
|
|
|
@ -30,6 +30,12 @@
|
|||
--layer-overlays: 400;
|
||||
--layer-following-indicator: 1000;
|
||||
--layer-blocker: 10000;
|
||||
|
||||
/* z index for text editors */
|
||||
--layer-text-container: 1;
|
||||
--layer-text-content: 3;
|
||||
--layer-text-editor: 4;
|
||||
|
||||
/* Misc */
|
||||
--tl-zoom: 1;
|
||||
|
||||
|
@ -549,19 +555,16 @@ input,
|
|||
.tl-handle__create {
|
||||
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 {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.tl-handle__bg:hover {
|
||||
cursor: var(--tl-cursor-grab);
|
||||
fill: var(--color-selection-fill);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.tl-handle__bg:active {
|
||||
fill: var(--color-selection-fill);
|
||||
|
@ -790,7 +793,6 @@ input,
|
|||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
pointer-events: all;
|
||||
text-rendering: auto;
|
||||
text-transform: none;
|
||||
text-indent: 0px;
|
||||
|
@ -856,6 +858,12 @@ input,
|
|||
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 {
|
||||
background: var(--color-selected);
|
||||
color: var(--color-selected-contrast);
|
||||
|
@ -967,10 +975,6 @@ input,
|
|||
cursor: var(--tl-cursor-pointer);
|
||||
}
|
||||
|
||||
.tl-bookmark__link:hover {
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
/* ---------------- Hyperlink Button ---------------- */
|
||||
|
||||
.tl-hyperlink-button {
|
||||
|
@ -1009,10 +1013,6 @@ input,
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tl-hyperlink-button:hover {
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.tl-hyperlink-button:focus-visible {
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
@ -1053,6 +1053,11 @@ input,
|
|||
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 {
|
||||
position: relative;
|
||||
top: 0px;
|
||||
|
@ -1062,7 +1067,6 @@ input,
|
|||
width: fit-content;
|
||||
border-radius: var(--radius-1);
|
||||
max-width: 100%;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.tl-text-label__inner > .tl-text-input {
|
||||
|
@ -1071,7 +1075,26 @@ input,
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
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 {
|
||||
|
@ -1125,7 +1148,7 @@ input,
|
|||
position: relative;
|
||||
height: max-content;
|
||||
width: max-content;
|
||||
pointer-events: all;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -1134,13 +1157,11 @@ input,
|
|||
.tl-arrow-label .tl-arrow {
|
||||
position: relative;
|
||||
height: max-content;
|
||||
z-index: 2;
|
||||
padding: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tl-arrow-label textarea {
|
||||
z-index: 3;
|
||||
padding: 4px;
|
||||
/* Don't allow textarea to be zero width */
|
||||
min-width: 4px;
|
||||
|
@ -1152,27 +1173,18 @@ input,
|
|||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
overflow: hidden;
|
||||
border-color: currentColor;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
z-index: var(--layer-text-container);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.tl-note__container .tl-text-label {
|
||||
.tl-note__container > .tl-text-label {
|
||||
text-shadow: none;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.tl-note__scrim {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color-background);
|
||||
opacity: 0.28;
|
||||
}
|
||||
/* --------------------- Loading -------------------- */
|
||||
|
||||
.tl-loading {
|
||||
background-color: var(--color-background);
|
||||
|
@ -1440,18 +1452,12 @@ it from receiving any pointer events or affecting the cursor. */
|
|||
color: inherit;
|
||||
background-color: transparent;
|
||||
}
|
||||
.tl-error-boundary__content button:hover {
|
||||
background-color: var(--color-low);
|
||||
}
|
||||
|
||||
.tl-error-boundary__content a {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.tl-error-boundary__content a:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.tl-error-boundary__content__error {
|
||||
position: relative;
|
||||
|
@ -1486,11 +1492,6 @@ it from receiving any pointer events or affecting the cursor. */
|
|||
background-color: var(--color-primary);
|
||||
color: var(--color-selected-contrast);
|
||||
}
|
||||
.tl-error-boundary__content .tl-error-boundary__refresh:hover {
|
||||
background-color: var(--color-primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* --------------------- Coarse --------------------- */
|
||||
|
||||
.tl-hidden {
|
||||
|
@ -1521,3 +1522,40 @@ it from receiving any pointer events or affecting the cursor. */
|
|||
.tl-hit-test-blocker__hidden {
|
||||
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,
|
||||
MIN_ZOOM,
|
||||
MULTI_CLICK_DURATION,
|
||||
SIDES,
|
||||
SVG_PADDING,
|
||||
ZOOMS,
|
||||
} from './lib/constants'
|
||||
|
@ -296,6 +297,7 @@ export {
|
|||
intersectPolygonBounds,
|
||||
intersectPolygonPolygon,
|
||||
linesIntersect,
|
||||
polygonIntersectsPolyline,
|
||||
polygonsIntersect,
|
||||
} from './lib/primitives/intersect'
|
||||
export {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from 'classnames'
|
||||
import * as React from 'react'
|
||||
|
||||
/** @public */
|
||||
|
@ -6,7 +7,7 @@ export type HTMLContainerProps = React.HTMLAttributes<HTMLDivElement>
|
|||
/** @public */
|
||||
export function HTMLContainer({ children, className = '', ...rest }: HTMLContainerProps) {
|
||||
return (
|
||||
<div {...rest} className={`tl-html-container ${className}`}>
|
||||
<div {...rest} className={classNames('tl-html-container', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from 'classnames'
|
||||
import * as React from 'react'
|
||||
|
||||
/** @public */
|
||||
|
@ -6,7 +7,7 @@ export type SVGContainerProps = React.HTMLAttributes<SVGElement>
|
|||
/** @public */
|
||||
export function SVGContainer({ children, className = '', ...rest }: SVGContainerProps) {
|
||||
return (
|
||||
<svg {...rest} className={`tl-svg-container ${className}`}>
|
||||
<svg {...rest} className={classNames('tl-svg-container', className)}>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
|
|
@ -116,11 +116,17 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
|
|||
const debugGeometry = useValue('debug_geometry', () => debugFlags.debugGeometry.get(), [
|
||||
debugFlags,
|
||||
])
|
||||
const isEditingAnything = useValue(
|
||||
'isEditingAnything',
|
||||
() => editor.getEditingShapeId() !== null,
|
||||
[editor]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rCanvas}
|
||||
draggable={false}
|
||||
data-iseditinganything={isEditingAnything}
|
||||
className={classNames('tl-canvas', className)}
|
||||
data-testid="canvas"
|
||||
{...events}
|
||||
|
@ -559,7 +565,10 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) {
|
|||
|
||||
const isSingleFrame = editor.isShapeOfType(id, 'frame')
|
||||
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], {
|
||||
padding,
|
||||
background: editor.getInstanceState().exportBackground,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { TLHandle, TLShapeId } from '@tldraw/tlschema'
|
||||
import classNames from 'classnames'
|
||||
import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS } from '../../constants'
|
||||
import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS, SIDES } from '../../constants'
|
||||
|
||||
/** @public */
|
||||
export type TLHandleProps = {
|
||||
|
@ -13,22 +13,31 @@ export type TLHandleProps = {
|
|||
|
||||
/** @public */
|
||||
export function DefaultHandle({ handle, isCoarse, className, zoom }: TLHandleProps) {
|
||||
const bgRadius = (isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / zoom
|
||||
const fgRadius = (handle.type === 'create' && isCoarse ? 3 : 4) / zoom
|
||||
const br = (isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / 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 (
|
||||
<g
|
||||
className={classNames(
|
||||
'tl-handle',
|
||||
{
|
||||
'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 className={classNames(`tl-handle tl-handle__${handle.type}`, className)}>
|
||||
<circle className="tl-handle__bg" r={br} />
|
||||
<circle className="tl-handle__fg" r={fr} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -105,6 +105,9 @@ export const COARSE_HANDLE_RADIUS = 20
|
|||
/** @internal */
|
||||
export const HANDLE_RADIUS = 12
|
||||
|
||||
/** @public */
|
||||
export const SIDES = ['top', 'right', 'bottom', 'left'] as const
|
||||
|
||||
/** @internal */
|
||||
export const LONG_PRESS_DURATION = 500
|
||||
|
||||
|
|
|
@ -795,6 +795,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* @public
|
||||
*/
|
||||
undo(): this {
|
||||
this._flushEventsForTick(0)
|
||||
this.history.undo()
|
||||
return this
|
||||
}
|
||||
|
@ -819,6 +820,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* @public
|
||||
*/
|
||||
redo(): this {
|
||||
this._flushEventsForTick(0)
|
||||
this.history.redo()
|
||||
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.
|
||||
*
|
||||
|
@ -1599,11 +1586,22 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
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.
|
||||
*
|
||||
* @returns Null if there is no shape or more than one selected shape, otherwise the selected
|
||||
* shape.
|
||||
* @returns Null if there is no shape or more than one selected shape, otherwise the selected shape.
|
||||
*
|
||||
* @public
|
||||
* @readonly
|
||||
|
@ -3993,7 +3991,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
/** @internal */
|
||||
@computed private _getShapeMaskCache(): ComputedCache<Vec[], TLShape> {
|
||||
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) =>
|
||||
this.isShapeOfType<TLFrameShape>(shape, 'frame')
|
||||
|
@ -4634,7 +4638,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
arg: TLUnknownShape | TLUnknownShape['id'],
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -4993,6 +4998,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
const shape = currentPageShapesSorted[i]
|
||||
|
||||
if (
|
||||
// don't allow dropping on selected shapes
|
||||
this.getSelectedShapeIds().includes(shape.id) ||
|
||||
// only allow shapes that can receive children
|
||||
!this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) ||
|
||||
// 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 sy = info.point.y - screenBounds.y
|
||||
const sz = info.point.z
|
||||
const sz = info.point.z ?? 0.5
|
||||
|
||||
previousScreenPoint.setTo(currentScreenPoint)
|
||||
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
|
||||
// to screenBounds.point. This is confusing!
|
||||
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
|
||||
|
||||
|
|
|
@ -70,10 +70,11 @@ export class TextManager {
|
|||
* space are preserved.
|
||||
*/
|
||||
maxWidth: null | number
|
||||
minWidth?: string
|
||||
minWidth?: null | number
|
||||
padding: string
|
||||
disableOverflowWrapBreaking?: boolean
|
||||
}
|
||||
): BoxModel => {
|
||||
): BoxModel & { scrollWidth: number } => {
|
||||
// Duplicate our base element; we don't need to clone deep
|
||||
const elm = this.baseElm?.cloneNode() as HTMLDivElement
|
||||
this.baseElm.insertAdjacentElement('afterend', elm)
|
||||
|
@ -85,10 +86,15 @@ export class TextManager {
|
|||
elm.style.setProperty('font-size', 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('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(
|
||||
'overflow-wrap',
|
||||
opts.disableOverflowWrapBreaking ? 'normal' : 'break-word'
|
||||
)
|
||||
|
||||
elm.textContent = normalizeTextForDom(textToMeasure)
|
||||
const scrollWidth = elm.scrollWidth
|
||||
const rect = elm.getBoundingClientRect()
|
||||
elm.remove()
|
||||
|
||||
|
@ -97,6 +103,7 @@ export class TextManager {
|
|||
y: 0,
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
scrollWidth,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -320,16 +320,15 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
|
|||
*
|
||||
* ```ts
|
||||
* onDragShapesOver = (shape, shapes) => {
|
||||
* return { shouldHint: true }
|
||||
* this.editor.reparentShapes(shapes, shape.id)
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param shape - The shape.
|
||||
* @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
|
||||
*/
|
||||
onDragShapesOver?: TLOnDragHandler<Shape, { shouldHint: boolean }>
|
||||
onDragShapesOver?: TLOnDragHandler<Shape>
|
||||
|
||||
/**
|
||||
* A callback called when some other shapes are dragged out of this one.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { HistoryEntry } from '@tldraw/store'
|
||||
import { TLPageId, TLRecord } from '@tldraw/tlschema'
|
||||
import { TLPageId, TLRecord, TLShapeId } from '@tldraw/tlschema'
|
||||
import { TLEventInfo } from './event-types'
|
||||
|
||||
/** @public */
|
||||
|
@ -17,6 +17,7 @@ export interface TLEventMap {
|
|||
frame: [number]
|
||||
'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }]
|
||||
'mark-history': [{ id: string }]
|
||||
'select-all-text': [{ shapeId: TLShapeId }]
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
|
|
@ -107,7 +107,15 @@ export function useCanvasEvents() {
|
|||
;(e as any).isKilled = true
|
||||
if (
|
||||
(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)
|
||||
}
|
||||
|
|
|
@ -308,6 +308,7 @@ export class Vec {
|
|||
static Per(A: VecLike): Vec {
|
||||
return new Vec(A.y, -A.x)
|
||||
}
|
||||
|
||||
static Abs(A: VecLike): Vec {
|
||||
return new Vec(Math.abs(A.x), Math.abs(A.y))
|
||||
}
|
||||
|
|
|
@ -316,3 +316,19 @@ export function polygonsIntersect(a: VecLike[], b: VecLike[]) {
|
|||
}
|
||||
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 */
|
||||
export const debugFlags: Record<string, DebugFlag<boolean>> = {
|
||||
export const debugFlags = {
|
||||
// --- DEBUG VALUES ---
|
||||
preventDefaultLogging: createDebugValue('preventDefaultLogging', {
|
||||
logPreventDefaults: createDebugValue('logPreventDefaults', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
pointerCaptureTracking: createDebugValue('pointerCaptureTracking', {
|
||||
logPointerCaptures: createDebugValue('logPointerCaptures', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
elementRemovalLogging: createDebugValue('elementRemovalLogging', {
|
||||
logElementRemoves: createDebugValue('logElementRemoves', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
debugSvg: createDebugValue('debugSvg', {
|
||||
|
@ -44,7 +44,7 @@ export const debugFlags: Record<string, DebugFlag<boolean>> = {
|
|||
throwToBlob: createDebugValue('throwToBlob', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
resetConnectionEveryPing: createDebugValue('resetConnectionEveryPing', {
|
||||
reconnectOnPing: createDebugValue('reconnectOnPing', {
|
||||
defaults: { all: false },
|
||||
}),
|
||||
debugCursors: createDebugValue('debugCursors', {
|
||||
|
@ -53,7 +53,8 @@ export const debugFlags: Record<string, DebugFlag<boolean>> = {
|
|||
forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
|
||||
debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
|
||||
hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }),
|
||||
}
|
||||
editOnType: createDebugValue('editOnType', { defaults: { all: false } }),
|
||||
} as const
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
@ -77,7 +78,7 @@ declare global {
|
|||
if (typeof Element !== 'undefined') {
|
||||
const nativeElementRemoveChild = Element.prototype.removeChild
|
||||
react('element removal logging', () => {
|
||||
if (debugFlags.elementRemovalLogging.get()) {
|
||||
if (debugFlags.logElementRemoves.get()) {
|
||||
Element.prototype.removeChild = function <T extends Node>(this: any, child: Node): T {
|
||||
console.warn('[tldraw] removing child:', child)
|
||||
return nativeElementRemoveChild.call(this, child) as T
|
||||
|
|
|
@ -37,7 +37,7 @@ export function loopToHtmlElement(elm: Element): HTMLElement {
|
|||
*/
|
||||
export function preventDefault(event: React.BaseSyntheticEvent | Event) {
|
||||
event.preventDefault()
|
||||
if (debugFlags.preventDefaultLogging.get()) {
|
||||
if (debugFlags.logPreventDefaults.get()) {
|
||||
console.warn('preventDefault called on event:', event)
|
||||
}
|
||||
}
|
||||
|
@ -48,7 +48,7 @@ export function setPointerCapture(
|
|||
event: React.PointerEvent<Element> | PointerEvent
|
||||
) {
|
||||
element.setPointerCapture(event.pointerId)
|
||||
if (debugFlags.pointerCaptureTracking.get()) {
|
||||
if (debugFlags.logPointerCaptures.get()) {
|
||||
const trackingObj = pointerCaptureTrackingObject.get()
|
||||
trackingObj.set(element, (trackingObj.get(element) ?? 0) + 1)
|
||||
console.warn('setPointerCapture called on element:', element, event)
|
||||
|
@ -65,7 +65,7 @@ export function releasePointerCapture(
|
|||
}
|
||||
|
||||
element.releasePointerCapture(event.pointerId)
|
||||
if (debugFlags.pointerCaptureTracking.get()) {
|
||||
if (debugFlags.logPointerCaptures.get()) {
|
||||
const trackingObj = pointerCaptureTrackingObject.get()
|
||||
if (trackingObj.get(element) === 1) {
|
||||
trackingObj.delete(element)
|
||||
|
|
|
@ -62,7 +62,6 @@ import { TLBookmarkShape } from '@tldraw/editor';
|
|||
import { TLCancelEvent } from '@tldraw/editor';
|
||||
import { TLClickEvent } from '@tldraw/editor';
|
||||
import { TLClickEventInfo } from '@tldraw/editor';
|
||||
import { TLDefaultColorStyle } from '@tldraw/editor';
|
||||
import { TLDefaultColorTheme } from '@tldraw/editor';
|
||||
import { TLDefaultFillStyle } from '@tldraw/editor';
|
||||
import { TLDefaultFontStyle } from '@tldraw/editor';
|
||||
|
@ -93,7 +92,6 @@ import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
|
|||
import { TLOnDoubleClickHandler } from '@tldraw/editor';
|
||||
import { TLOnEditEndHandler } from '@tldraw/editor';
|
||||
import { TLOnHandleDragHandler } from '@tldraw/editor';
|
||||
import { TLOnResizeEndHandler } from '@tldraw/editor';
|
||||
import { TLOnResizeHandler } from '@tldraw/editor';
|
||||
import { TLOnTranslateHandler } from '@tldraw/editor';
|
||||
import { TLOnTranslateStartHandler } from '@tldraw/editor';
|
||||
|
@ -656,14 +654,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
|
|||
// (undocumented)
|
||||
onDragShapesOut: (_shape: TLFrameShape, shapes: TLShape[]) => void;
|
||||
// (undocumented)
|
||||
onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => {
|
||||
shouldHint: boolean;
|
||||
};
|
||||
onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => void;
|
||||
// (undocumented)
|
||||
onResize: TLOnResizeHandler<any>;
|
||||
// (undocumented)
|
||||
onResizeEnd: TLOnResizeEndHandler<TLFrameShape>;
|
||||
// (undocumented)
|
||||
static props: {
|
||||
w: Validator<number>;
|
||||
h: Validator<number>;
|
||||
|
@ -837,6 +831,9 @@ export function GeoStylePickerSet({ styles }: {
|
|||
// @public
|
||||
export function getEmbedInfo(inputUrl: string): TLEmbedResult;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getOccludedChildren(editor: Editor, parent: TLShape): TLShapeId[];
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSvgAsImage(svgString: string, isSafari: boolean, options: {
|
||||
type: 'jpeg' | 'png' | 'webp';
|
||||
|
@ -974,6 +971,9 @@ export function isGifAnimated(file: Blob): Promise<boolean>;
|
|||
// @public (undocumented)
|
||||
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;
|
||||
|
||||
// @internal (undocumented)
|
||||
export function kickoutOccludedShapes(editor: Editor, shapeIds: TLShapeId[]): void;
|
||||
|
||||
// @public (undocumented)
|
||||
export const LABEL_FONT_SIZES: Record<TLDefaultSizeStyle, number>;
|
||||
|
||||
|
@ -1100,9 +1100,9 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
// (undocumented)
|
||||
getDefaultProps(): TLNoteShape['props'];
|
||||
// (undocumented)
|
||||
getGeometry(shape: TLNoteShape): Rectangle2d;
|
||||
getGeometry(shape: TLNoteShape): Group2d;
|
||||
// (undocumented)
|
||||
getHeight(shape: TLNoteShape): number;
|
||||
getHandles(shape: TLNoteShape): TLHandle[];
|
||||
// (undocumented)
|
||||
hideResizeHandles: () => boolean;
|
||||
// (undocumented)
|
||||
|
@ -1115,6 +1115,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
onBeforeCreate: (next: TLNoteShape) => {
|
||||
props: {
|
||||
growY: number;
|
||||
fontSizeAdjustment: number;
|
||||
color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow";
|
||||
size: "l" | "m" | "s" | "xl";
|
||||
font: "draw" | "mono" | "sans" | "serif";
|
||||
|
@ -1139,6 +1140,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
onBeforeUpdate: (prev: TLNoteShape, next: TLNoteShape) => {
|
||||
props: {
|
||||
growY: number;
|
||||
fontSizeAdjustment: number;
|
||||
color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow";
|
||||
size: "l" | "m" | "s" | "xl";
|
||||
font: "draw" | "mono" | "sans" | "serif";
|
||||
|
@ -1165,6 +1167,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
static props: {
|
||||
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">;
|
||||
fontSizeAdjustment: Validator<number>;
|
||||
font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">;
|
||||
align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "start">;
|
||||
verticalAlign: EnumStyleProp<"end" | "middle" | "start">;
|
||||
|
@ -2386,6 +2389,7 @@ export type TLUiTranslation = {
|
|||
readonly locale: string;
|
||||
readonly label: string;
|
||||
readonly messages: Record<TLUiTranslationKey, string>;
|
||||
readonly dir: 'ltr' | 'rtl';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
@ -2477,6 +2481,9 @@ export function useCanUndo(): boolean;
|
|||
// @public (undocumented)
|
||||
export function useCopyAs(): (ids: TLShapeId[], format?: TLCopyType) => void;
|
||||
|
||||
// @public (undocumented)
|
||||
export const useCurrentTranslation: () => TLUiTranslation;
|
||||
|
||||
// @public (undocumented)
|
||||
export function useDefaultHelpers(): {
|
||||
addToast: (toast: Omit<TLUiToast, "id"> & {
|
||||
|
@ -2498,16 +2505,19 @@ export function useDefaultHelpers(): {
|
|||
export function useDialogs(): TLUiDialogsContextType;
|
||||
|
||||
// @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>;
|
||||
isEditing: boolean;
|
||||
handleFocus: () => void;
|
||||
handleFocus: typeof noop;
|
||||
handleBlur: () => void;
|
||||
handleKeyDown: (e: React_2.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
handleInputPointerDown: (e: React_2.PointerEvent) => void;
|
||||
handleDoubleClick: (e: any) => any;
|
||||
isEmpty: boolean;
|
||||
isEditing: boolean;
|
||||
isEditingAnything: boolean;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
|
|
@ -7567,7 +7567,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]) => {\n shouldHint: boolean;\n }"
|
||||
"text": "[]) => void"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -7621,50 +7621,6 @@
|
|||
"isProtected": 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",
|
||||
"canonicalReference": "tldraw!FrameShapeUtil.props:member",
|
||||
|
@ -9178,6 +9134,74 @@
|
|||
],
|
||||
"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",
|
||||
"canonicalReference": "tldraw!getSvgAsImage:function(1)",
|
||||
|
@ -12920,8 +12944,8 @@
|
|||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Rectangle2d",
|
||||
"canonicalReference": "@tldraw/editor!Rectangle2d:class"
|
||||
"text": "Group2d",
|
||||
"canonicalReference": "@tldraw/editor!Group2d:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -12952,12 +12976,12 @@
|
|||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "tldraw!NoteShapeUtil#getHeight:member(1)",
|
||||
"canonicalReference": "tldraw!NoteShapeUtil#getHandles:member(1)",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "getHeight(shape: "
|
||||
"text": "getHandles(shape: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
|
@ -12968,9 +12992,14 @@
|
|||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "TLHandle",
|
||||
"canonicalReference": "@tldraw/tlschema!TLHandle:interface"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "number"
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -12980,7 +13009,7 @@
|
|||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 4
|
||||
"endIndex": 5
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
|
@ -12997,7 +13026,7 @@
|
|||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "getHeight"
|
||||
"name": "getHandles"
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
|
@ -13168,7 +13197,7 @@
|
|||
},
|
||||
{
|
||||
"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",
|
||||
|
@ -13195,7 +13224,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";\n id: import(\"@tldraw/editor\")."
|
||||
"text": ";\n id: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
|
@ -13252,7 +13281,7 @@
|
|||
},
|
||||
{
|
||||
"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",
|
||||
|
@ -13279,7 +13308,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";\n id: import(\"@tldraw/editor\")."
|
||||
"text": ";\n id: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
|
@ -13380,7 +13409,16 @@
|
|||
},
|
||||
{
|
||||
"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",
|
||||
|
@ -13447,7 +13485,7 @@
|
|||
"name": "props",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 18
|
||||
"endIndex": 20
|
||||
},
|
||||
"isStatic": true,
|
||||
"isProtected": false,
|
||||
|
@ -14090,7 +14128,7 @@
|
|||
{
|
||||
"kind": "Function",
|
||||
"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": [
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -26258,7 +26296,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ", string>;\n}"
|
||||
"text": ", string>;\n readonly dir: 'ltr' | 'rtl';\n}"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -27267,6 +27305,31 @@
|
|||
"parameters": [],
|
||||
"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",
|
||||
"canonicalReference": "tldraw!useDefaultHelpers:function(1)",
|
||||
|
@ -27408,6 +27471,14 @@
|
|||
"kind": "Content",
|
||||
"text": "string"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ", opts?: "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "{\n disableTab: boolean;\n}"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
|
@ -27432,7 +27503,16 @@
|
|||
},
|
||||
{
|
||||
"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",
|
||||
|
@ -27477,7 +27557,7 @@
|
|||
},
|
||||
{
|
||||
"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",
|
||||
|
@ -27486,8 +27566,8 @@
|
|||
],
|
||||
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts",
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 7,
|
||||
"endIndex": 22
|
||||
"startIndex": 9,
|
||||
"endIndex": 26
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"overloadIndex": 1,
|
||||
|
@ -27515,6 +27595,14 @@
|
|||
"endIndex": 6
|
||||
},
|
||||
"isOptional": false
|
||||
},
|
||||
{
|
||||
"parameterName": "opts",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 7,
|
||||
"endIndex": 8
|
||||
},
|
||||
"isOptional": true
|
||||
}
|
||||
],
|
||||
"name": "useEditableText"
|
||||
|
|
|
@ -40,6 +40,7 @@ export { EraserTool } from './lib/tools/EraserTool/EraserTool'
|
|||
export { HandTool } from './lib/tools/HandTool/HandTool'
|
||||
export { LaserTool } from './lib/tools/LaserTool/LaserTool'
|
||||
export { SelectTool } from './lib/tools/SelectTool/SelectTool'
|
||||
export { getOccludedChildren, kickoutOccludedShapes } from './lib/tools/SelectTool/selectHelpers'
|
||||
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
|
||||
// UI
|
||||
export { useEditableText } from './lib/shapes/shared/useEditableText'
|
||||
|
@ -97,6 +98,7 @@ export {
|
|||
export { type TLUiTranslationKey } from './lib/ui/hooks/useTranslation/TLUiTranslationKey'
|
||||
export { type TLUiTranslation } from './lib/ui/hooks/useTranslation/translations'
|
||||
export {
|
||||
useCurrentTranslation,
|
||||
useTranslation,
|
||||
type TLUiTranslationContextType,
|
||||
} from './lib/ui/hooks/useTranslation/useTranslation'
|
||||
|
|
|
@ -3,9 +3,21 @@ import { TLHandlesProps, useEditor, useValue } from '@tldraw/editor'
|
|||
/** @public */
|
||||
export function TldrawHandles({ children }: TLHandlesProps) {
|
||||
const editor = useEditor()
|
||||
|
||||
// todo: maybe display note shape handles here?
|
||||
|
||||
const shouldDisplayHandles = useValue(
|
||||
'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]
|
||||
)
|
||||
|
||||
|
|
|
@ -272,6 +272,22 @@ export function registerDefaultExternalContentHandlers(
|
|||
|
||||
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
|
||||
let w: number
|
||||
let h: number
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
arrowShapeMigrations,
|
||||
arrowShapeProps,
|
||||
getArrowTerminalsInArrowSpace,
|
||||
getDefaultColorTheme,
|
||||
mapObjectMapValues,
|
||||
objectMapEntries,
|
||||
structuredClone,
|
||||
|
@ -306,15 +307,20 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
|
|||
// If no bound shapes are in the selection, unbind any bound shapes
|
||||
|
||||
const selectedShapeIds = this.editor.getSelectedShapeIds()
|
||||
|
||||
if (
|
||||
(startBindingId &&
|
||||
(selectedShapeIds.includes(startBindingId) ||
|
||||
this.editor.isAncestorSelected(startBindingId))) ||
|
||||
(endBindingId &&
|
||||
(selectedShapeIds.includes(endBindingId) || this.editor.isAncestorSelected(endBindingId)))
|
||||
) {
|
||||
return
|
||||
const shapesToCheck = new Set<string>()
|
||||
if (startBindingId) {
|
||||
// Add shape and all ancestors to set
|
||||
shapesToCheck.add(startBindingId)
|
||||
this.editor.getShapeAncestors(startBindingId).forEach((a) => shapesToCheck.add(a.id))
|
||||
}
|
||||
if (endBindingId) {
|
||||
// Add shape and all ancestors to set
|
||||
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
|
||||
|
@ -530,6 +536,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
|
|||
if (!info?.isValid) return null
|
||||
|
||||
const labelPosition = getArrowLabelPosition(this.editor, shape)
|
||||
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
|
||||
const isEditing = this.editor.getEditingShapeId() === shape.id
|
||||
const showArrowLabel = isEditing || shape.props.text
|
||||
|
||||
|
@ -549,6 +556,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
|
|||
size={shape.props.size}
|
||||
position={labelPosition.box.center}
|
||||
width={labelPosition.box.w}
|
||||
isSelected={isSelected}
|
||||
labelColor={shape.props.labelColor}
|
||||
/>
|
||||
)}
|
||||
|
@ -692,6 +700,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
|
|||
override toSvg(shape: TLArrowShape, ctx: SvgExportContext) {
|
||||
ctx.addExportDef(getFillDefForExport(shape.props.fill))
|
||||
if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font))
|
||||
const theme = getDefaultColorTheme(ctx)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -702,7 +711,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
|
|||
align="middle"
|
||||
verticalAlign="middle"
|
||||
text={shape.props.text}
|
||||
labelColor={shape.props.labelColor}
|
||||
labelColor={theme[shape.props.labelColor].solid}
|
||||
bounds={getArrowLabelPosition(this.editor, shape).box}
|
||||
padding={4}
|
||||
/>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { TLArrowShape, TLDefaultColorStyle, TLShapeId, VecLike } from '@tldraw/editor'
|
||||
import * as React from 'react'
|
||||
import { useDefaultColorTheme } from '../../shared/ShapeFill'
|
||||
import { TextLabel } from '../../shared/TextLabel'
|
||||
import { ARROW_LABEL_FONT_SIZES, TEXT_PROPS } from '../../shared/default-shape-constants'
|
||||
|
||||
|
@ -10,11 +11,16 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
|
|||
font,
|
||||
position,
|
||||
width,
|
||||
isSelected,
|
||||
labelColor,
|
||||
}: { id: TLShapeId; position: VecLike; width?: number; labelColor: TLDefaultColorStyle } & Pick<
|
||||
TLArrowShape['props'],
|
||||
'text' | 'size' | 'font'
|
||||
>) {
|
||||
}: {
|
||||
id: TLShapeId
|
||||
position: VecLike
|
||||
width?: number
|
||||
labelColor: TLDefaultColorStyle
|
||||
isSelected: boolean
|
||||
} & Pick<TLArrowShape['props'], 'text' | 'size' | 'font'>) {
|
||||
const theme = useDefaultColorTheme()
|
||||
return (
|
||||
<TextLabel
|
||||
id={id}
|
||||
|
@ -26,8 +32,10 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
|
|||
align="middle"
|
||||
verticalAlign="middle"
|
||||
text={text}
|
||||
labelColor={labelColor}
|
||||
labelColor={theme[labelColor].solid}
|
||||
textWidth={width}
|
||||
isSelected={isSelected}
|
||||
disableTab
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
}}
|
||||
|
|
|
@ -89,7 +89,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
|
|||
component(shape: TLDrawShape) {
|
||||
return (
|
||||
<SVGContainer id={shape.id}>
|
||||
<DrawShapSvg shape={shape} forceSolid={useForceSolid()} />
|
||||
<DrawShapeSvg shape={shape} forceSolid={useForceSolid()} />
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
@ -122,7 +122,7 @@ export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
|
|||
|
||||
override toSvg(shape: TLDrawShape, ctx: SvgExportContext) {
|
||||
ctx.addExportDef(getFillDefForExport(shape.props.fill))
|
||||
return <DrawShapSvg shape={shape} forceSolid={false} />
|
||||
return <DrawShapeSvg shape={shape} forceSolid={false} />
|
||||
}
|
||||
|
||||
override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
|
||||
|
@ -171,7 +171,7 @@ function getIsDot(shape: TLDrawShape) {
|
|||
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 strokeWidth = STROKE_SIZES[shape.props.size]
|
||||
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
|
||||
|
|
|
@ -3,16 +3,12 @@ import {
|
|||
Geometry2d,
|
||||
Rectangle2d,
|
||||
SVGContainer,
|
||||
SelectionEdge,
|
||||
SvgExportContext,
|
||||
TLFrameShape,
|
||||
TLGroupShape,
|
||||
TLOnResizeEndHandler,
|
||||
TLOnResizeHandler,
|
||||
TLShape,
|
||||
TLShapeId,
|
||||
canonicalizeRotation,
|
||||
exhaustiveSwitchError,
|
||||
frameShapeMigrations,
|
||||
frameShapeProps,
|
||||
getDefaultColorTheme,
|
||||
|
@ -70,7 +66,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
|
|||
const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } })
|
||||
?.info
|
||||
if (!info) return false
|
||||
return info.isCreating && this.editor.getOnlySelectedShape()?.id === shape.id
|
||||
return info.isCreating && this.editor.getOnlySelectedShapeId() === shape.id
|
||||
},
|
||||
[shape.id]
|
||||
)
|
||||
|
@ -108,28 +104,26 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
|
|||
// rotate right 45 deg
|
||||
const offsetRotation = pageRotation + Math.PI / 4
|
||||
const scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4
|
||||
const labelSide: SelectionEdge = (['top', 'left', 'bottom', 'right'] as const)[
|
||||
Math.floor(scaledRotation)
|
||||
]
|
||||
const labelSide = Math.floor(scaledRotation)
|
||||
|
||||
let labelTranslate: string
|
||||
switch (labelSide) {
|
||||
case 'top':
|
||||
case 0: // top
|
||||
labelTranslate = ``
|
||||
break
|
||||
case 'right':
|
||||
case 3: // right
|
||||
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, 0) rotate(90)`
|
||||
break
|
||||
case 'bottom':
|
||||
case 2: // bottom
|
||||
labelTranslate = `translate(${toDomPrecision(shape.props.w)}, ${toDomPrecision(
|
||||
shape.props.h
|
||||
)}) rotate(180)`
|
||||
break
|
||||
case 'left':
|
||||
case 1: // left
|
||||
labelTranslate = `translate(0, ${toDomPrecision(shape.props.h)}) rotate(270)`
|
||||
break
|
||||
default:
|
||||
exhaustiveSwitchError(labelSide)
|
||||
throw Error('labelSide out of bounds')
|
||||
}
|
||||
|
||||
// Truncate with ellipsis
|
||||
|
@ -211,15 +205,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
|
|||
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)) {
|
||||
this.editor.reparentShapes(
|
||||
shapes.map((shape) => shape.id),
|
||||
frame.id
|
||||
)
|
||||
return { shouldHint: true }
|
||||
this.editor.reparentShapes(shapes, frame.id)
|
||||
}
|
||||
return { shouldHint: false }
|
||||
}
|
||||
|
||||
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) => {
|
||||
return resizeBox(shape, info)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import {
|
||||
BaseBoxShapeUtil,
|
||||
Editor,
|
||||
|
@ -22,10 +23,12 @@ import {
|
|||
exhaustiveSwitchError,
|
||||
geoShapeMigrations,
|
||||
geoShapeProps,
|
||||
getDefaultColorTheme,
|
||||
getPolygonVertices,
|
||||
} from '@tldraw/editor'
|
||||
|
||||
import { HyperlinkButton } from '../shared/HyperlinkButton'
|
||||
import { useDefaultColorTheme } from '../shared/ShapeFill'
|
||||
import { SvgTextLabel } from '../shared/SvgTextLabel'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
import {
|
||||
|
@ -292,8 +295,13 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
|
|||
}
|
||||
|
||||
const labelSize = getLabelSize(this.editor, shape)
|
||||
const labelWidth = Math.min(w, Math.max(labelSize.w, Math.min(32, Math.max(1, w - 8))))
|
||||
const labelHeight = Math.min(h, Math.max(labelSize.h, Math.min(32, Math.max(1, w - 8)))) // not sure if bug
|
||||
const minWidth = Math.min(100, w / 2)
|
||||
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 edges = lines ? lines.map((line) => new Polyline2d({ points: line })) : []
|
||||
|
@ -381,10 +389,11 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
|
|||
|
||||
component(shape: TLGeoShape) {
|
||||
const { id, type, props } = shape
|
||||
const { labelColor, fill, font, align, verticalAlign, size, text } = props
|
||||
|
||||
const isEditing = this.editor.getEditingShapeId() === id
|
||||
const showHtmlContainer = isEditing || shape.props.url || shape.props.text
|
||||
const { fill, font, align, verticalAlign, size, text } = props
|
||||
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
|
||||
const theme = useDefaultColorTheme()
|
||||
const isEditingAnything = this.editor.getEditingShapeId() !== null
|
||||
const showHtmlContainer = isEditingAnything || shape.props.text
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -410,15 +419,16 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
|
|||
align={align}
|
||||
verticalAlign={verticalAlign}
|
||||
text={text}
|
||||
labelColor={labelColor}
|
||||
isSelected={isSelected}
|
||||
labelColor={theme[props.labelColor].solid}
|
||||
disableTab
|
||||
wrap
|
||||
bounds={props.geo === 'cloud' ? this.getGeometry(shape).bounds : undefined}
|
||||
/>
|
||||
{shape.props.url && (
|
||||
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
|
||||
)}
|
||||
</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
|
||||
if (props.text) {
|
||||
ctx.addExportDef(getFontDefForExport(shape.props.font))
|
||||
const theme = getDefaultColorTheme(ctx)
|
||||
|
||||
const bounds = this.editor.getShapeGeometry(shape).bounds
|
||||
textEl = (
|
||||
|
@ -487,7 +498,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
|
|||
align={props.align}
|
||||
verticalAlign={props.verticalAlign}
|
||||
text={props.text}
|
||||
labelColor={props.labelColor}
|
||||
labelColor={theme[props.labelColor].solid}
|
||||
bounds={bounds}
|
||||
/>
|
||||
)
|
||||
|
@ -761,7 +772,7 @@ function getLabelSize(editor: Editor, shape: TLGeoShape) {
|
|||
...TEXT_PROPS,
|
||||
fontFamily: FONT_FAMILIES[shape.props.font],
|
||||
fontSize: LABEL_FONT_SIZES[shape.props.size],
|
||||
minWidth: minSize.w + 'px',
|
||||
minWidth: minSize.w,
|
||||
maxWidth: Math.max(
|
||||
// Guard because a DOM nodes can't be less 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 isSelected = shape.id === this.editor.getOnlySelectedShape()?.id
|
||||
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
|
||||
|
||||
useEffect(() => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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 {
|
||||
Editor,
|
||||
Group2d,
|
||||
IndexKey,
|
||||
Rectangle2d,
|
||||
ShapeUtil,
|
||||
SvgExportContext,
|
||||
TLHandle,
|
||||
TLNoteShape,
|
||||
TLOnEditEndHandler,
|
||||
TLShape,
|
||||
TLShapeId,
|
||||
Vec,
|
||||
WeakMapCache,
|
||||
getDefaultColorTheme,
|
||||
noteShapeMigrations,
|
||||
noteShapeProps,
|
||||
rng,
|
||||
toDomPrecision,
|
||||
useEditor,
|
||||
useValue,
|
||||
} 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 { useDefaultColorTheme } from '../shared/ShapeFill'
|
||||
import { SvgTextLabel } from '../shared/SvgTextLabel'
|
||||
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'
|
||||
|
||||
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 */
|
||||
export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
||||
|
@ -27,7 +53,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
|
||||
override canEdit = () => true
|
||||
override hideResizeHandles = () => true
|
||||
override hideSelectionBoundsFg = () => true
|
||||
override hideSelectionBoundsFg = () => false
|
||||
|
||||
getDefaultProps(): TLNoteShape['props'] {
|
||||
return {
|
||||
|
@ -38,60 +64,135 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
align: 'middle',
|
||||
verticalAlign: 'middle',
|
||||
growY: 0,
|
||||
fontSizeAdjustment: 0,
|
||||
url: '',
|
||||
}
|
||||
}
|
||||
|
||||
getHeight(shape: TLNoteShape) {
|
||||
return NOTE_SIZE + shape.props.growY
|
||||
getGeometry(shape: TLNoteShape) {
|
||||
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) {
|
||||
const height = this.getHeight(shape)
|
||||
return new Rectangle2d({ width: NOTE_SIZE, height, isFilled: true })
|
||||
override getHandles(shape: TLNoteShape): TLHandle[] {
|
||||
const zoom = this.editor.getZoomLevel()
|
||||
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) {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
props: { color, font, size, align, text, verticalAlign },
|
||||
props: { color, font, size, align, text, verticalAlign, fontSizeAdjustment },
|
||||
} = shape
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const handleKeyDown = useNoteKeydownHandler(id)
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
id={id}
|
||||
className="tl-note__container"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
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
|
||||
className="tl-note__container"
|
||||
style={{
|
||||
color: theme[adjustedColor].solid,
|
||||
backgroundColor: theme[adjustedColor].solid,
|
||||
}}
|
||||
>
|
||||
<div className="tl-note__scrim" />
|
||||
<TextLabel
|
||||
id={id}
|
||||
type={type}
|
||||
font={font}
|
||||
fontSize={LABEL_FONT_SIZES[size]}
|
||||
lineHeight={TEXT_PROPS.lineHeight}
|
||||
align={align}
|
||||
verticalAlign={verticalAlign}
|
||||
text={text}
|
||||
labelColor="black"
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
<TextLabel
|
||||
id={id}
|
||||
type={type}
|
||||
font={font}
|
||||
fontSize={fontSizeAdjustment || LABEL_FONT_SIZES[size]}
|
||||
lineHeight={TEXT_PROPS.lineHeight}
|
||||
align={align}
|
||||
verticalAlign={verticalAlign}
|
||||
text={text}
|
||||
isNote
|
||||
isSelected={isSelected}
|
||||
labelColor={theme[color].note.text}
|
||||
disableTab
|
||||
wrap
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{'url' in shape.props && shape.props.url && (
|
||||
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
|
||||
|
@ -103,9 +204,9 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
indicator(shape: TLNoteShape) {
|
||||
return (
|
||||
<rect
|
||||
rx="6"
|
||||
rx="1"
|
||||
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))
|
||||
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode })
|
||||
const bounds = this.editor.getShapeGeometry(shape).bounds
|
||||
const adjustedColor = shape.props.color === 'black' ? 'yellow' : shape.props.color
|
||||
|
||||
return (
|
||||
<>
|
||||
<rect x={5} y={5} rx={1} width={NOTE_SIZE - 10} height={bounds.h} fill="rgba(0,0,0,.1)" />
|
||||
<rect
|
||||
rx={10}
|
||||
rx={1}
|
||||
width={NOTE_SIZE}
|
||||
height={bounds.h}
|
||||
fill={theme[adjustedColor].solid}
|
||||
stroke={theme[adjustedColor].solid}
|
||||
strokeWidth={1}
|
||||
fill={theme[shape.props.color].note.fill}
|
||||
/>
|
||||
<rect rx={10} width={NOTE_SIZE} height={bounds.h} fill={theme.background} opacity={0.28} />
|
||||
<SvgTextLabel
|
||||
fontSize={LABEL_FONT_SIZES[shape.props.size]}
|
||||
fontSize={shape.props.fontSizeAdjustment || LABEL_FONT_SIZES[shape.props.size]}
|
||||
font={shape.props.font}
|
||||
align={shape.props.align}
|
||||
verticalAlign={shape.props.verticalAlign}
|
||||
text={shape.props.text}
|
||||
labelColor="black"
|
||||
labelColor={theme[shape.props.color].note.text}
|
||||
bounds={bounds}
|
||||
stroke={false}
|
||||
/>
|
||||
|
@ -143,7 +240,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
}
|
||||
|
||||
override onBeforeCreate = (next: TLNoteShape) => {
|
||||
return getGrowY(this.editor, next, next.props.growY)
|
||||
return getNoteSizeAdjustments(this.editor, next)
|
||||
}
|
||||
|
||||
override onBeforeUpdate = (prev: TLNoteShape, next: TLNoteShape) => {
|
||||
|
@ -155,7 +252,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
|
|||
return
|
||||
}
|
||||
|
||||
return getGrowY(this.editor, next, prev.props.growY)
|
||||
return getNoteSizeAdjustments(this.editor, next)
|
||||
}
|
||||
|
||||
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, {
|
||||
...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) {
|
||||
if (growY !== shape.props.growY || fontSizeAdjustment !== shape.props.fontSizeAdjustment) {
|
||||
return {
|
||||
...shape,
|
||||
props: {
|
||||
...shape.props,
|
||||
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 {
|
||||
Editor,
|
||||
StateNode,
|
||||
TLEventHandlers,
|
||||
TLInterruptEvent,
|
||||
TLNoteShape,
|
||||
TLPointerEventInfo,
|
||||
TLShapeId,
|
||||
Vec,
|
||||
createShapeId,
|
||||
} from '@tldraw/editor'
|
||||
import { NOTE_PIT_RADIUS, getAvailableNoteAdjacentPositions } from '../noteHelpers'
|
||||
|
||||
export class Pointing extends StateNode {
|
||||
static override id = 'pointing'
|
||||
|
@ -21,16 +25,35 @@ export class Pointing extends StateNode {
|
|||
shape = {} as TLNoteShape
|
||||
|
||||
override onEnter = () => {
|
||||
this.wasFocusedOnEnter = !this.editor.getIsMenuOpen()
|
||||
const { editor } = this
|
||||
|
||||
this.wasFocusedOnEnter = !editor.getIsMenuOpen()
|
||||
|
||||
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) => {
|
||||
if (this.editor.inputs.isDragging) {
|
||||
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', {
|
||||
|
@ -82,40 +105,45 @@ export class Pointing extends StateNode {
|
|||
this.editor.bailToMark(this.markId)
|
||||
this.parent.transition('idle', this.info)
|
||||
}
|
||||
|
||||
private createShape() {
|
||||
const {
|
||||
inputs: { originPagePoint },
|
||||
} = this.editor
|
||||
|
||||
const id = createShapeId()
|
||||
this.markId = `creating:${id}`
|
||||
this.editor.mark(this.markId)
|
||||
|
||||
this.editor
|
||||
.createShapes([
|
||||
{
|
||||
id,
|
||||
type: 'note',
|
||||
x: originPagePoint.x,
|
||||
y: originPagePoint.y,
|
||||
},
|
||||
])
|
||||
.select(id)
|
||||
|
||||
const shape = this.editor.getShape<TLNoteShape>(id)!
|
||||
const bounds = this.editor.getShapeGeometry(shape).bounds
|
||||
|
||||
// Center the text around the created point
|
||||
this.editor.updateShapes([
|
||||
{
|
||||
id,
|
||||
type: 'note',
|
||||
x: shape.x - bounds.width / 2,
|
||||
y: shape.y - bounds.height / 2,
|
||||
},
|
||||
])
|
||||
|
||||
return this.editor.getShape<TLNoteShape>(id)!
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotePitOffset(editor: Editor, center: Vec) {
|
||||
let min = NOTE_PIT_RADIUS / editor.getZoomLevel() // in screen space
|
||||
let offset: Vec | undefined
|
||||
for (const pit of getAvailableNoteAdjacentPositions(editor, 0, 0)) {
|
||||
// only check page rotations of zero
|
||||
const deltaToPit = Vec.Sub(center, pit)
|
||||
const dist = deltaToPit.len()
|
||||
if (dist < min) {
|
||||
min = dist
|
||||
offset = deltaToPit
|
||||
}
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
export function createSticky(editor: Editor, id: TLShapeId, center: Vec) {
|
||||
editor
|
||||
.createShape({
|
||||
id,
|
||||
type: 'note',
|
||||
x: center.x,
|
||||
y: center.y,
|
||||
})
|
||||
.select(id)
|
||||
|
||||
const shape = editor.getShape<TLNoteShape>(id)!
|
||||
const bounds = editor.getShapeGeometry(shape).bounds
|
||||
|
||||
// Center the text around the created point
|
||||
editor.updateShapes([
|
||||
{
|
||||
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 {
|
||||
Box,
|
||||
DefaultFontFamilies,
|
||||
TLDefaultColorStyle,
|
||||
TLDefaultFontStyle,
|
||||
TLDefaultHorizontalAlignStyle,
|
||||
TLDefaultVerticalAlignStyle,
|
||||
|
@ -30,7 +29,7 @@ export function SvgTextLabel({
|
|||
verticalAlign: TLDefaultVerticalAlignStyle
|
||||
wrap?: boolean
|
||||
text: string
|
||||
labelColor: TLDefaultColorStyle
|
||||
labelColor: string
|
||||
bounds: Box
|
||||
padding?: number
|
||||
stroke?: boolean
|
||||
|
@ -52,7 +51,7 @@ export function SvgTextLabel({
|
|||
overflow: 'wrap' as const,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
fill: theme[labelColor].solid,
|
||||
fill: labelColor,
|
||||
stroke: undefined as string | undefined,
|
||||
strokeWidth: undefined as number | undefined,
|
||||
}
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import {
|
||||
Box,
|
||||
TLDefaultColorStyle,
|
||||
TLDefaultFillStyle,
|
||||
TLDefaultFontStyle,
|
||||
TLDefaultHorizontalAlignStyle,
|
||||
TLDefaultVerticalAlignStyle,
|
||||
TLShapeId,
|
||||
getDefaultColorTheme,
|
||||
useIsDarkMode,
|
||||
} from '@tldraw/editor'
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { TextArea } from '../text/TextArea'
|
||||
import { TextHelpers } from './TextHelpers'
|
||||
import { isLegacyAlign } from './legacyProps'
|
||||
|
@ -26,8 +23,12 @@ type TextLabelProps = {
|
|||
verticalAlign: TLDefaultVerticalAlignStyle
|
||||
wrap?: boolean
|
||||
text: string
|
||||
labelColor: TLDefaultColorStyle
|
||||
labelColor: string
|
||||
bounds?: Box
|
||||
isNote?: boolean
|
||||
isSelected: boolean
|
||||
disableTab?: boolean
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
classNamePrefix?: string
|
||||
style?: React.CSSProperties
|
||||
textWidth?: number
|
||||
|
@ -46,19 +47,31 @@ export const TextLabel = React.memo(function TextLabel({
|
|||
align,
|
||||
verticalAlign,
|
||||
wrap,
|
||||
bounds,
|
||||
isSelected,
|
||||
onKeyDown: handleKeyDownCustom,
|
||||
classNamePrefix,
|
||||
style,
|
||||
disableTab = false,
|
||||
textWidth,
|
||||
textHeight,
|
||||
}: 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 hasText = finalText.length > 0
|
||||
|
||||
const legacyAlign = isLegacyAlign(align)
|
||||
const theme = getDefaultColorTheme({ isDarkMode: useIsDarkMode() })
|
||||
|
||||
if (!isEditing && !hasText) {
|
||||
return null
|
||||
|
@ -73,19 +86,12 @@ export const TextLabel = React.memo(function TextLabel({
|
|||
data-align={align}
|
||||
data-hastext={!isEmpty}
|
||||
data-isediting={isEditing}
|
||||
data-iseditinganything={isEditingAnything}
|
||||
data-textwrap={!!wrap}
|
||||
data-isselected={isSelected}
|
||||
style={{
|
||||
justifyContent: align === 'middle' || legacyAlign ? 'center' : align,
|
||||
alignItems: verticalAlign === 'middle' ? 'center' : verticalAlign,
|
||||
...(bounds
|
||||
? {
|
||||
top: bounds.minY,
|
||||
left: bounds.minX,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
position: 'absolute',
|
||||
}
|
||||
: {}),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
@ -96,7 +102,7 @@ export const TextLabel = React.memo(function TextLabel({
|
|||
lineHeight: fontSize * lineHeight + 'px',
|
||||
minHeight: lineHeight + 32,
|
||||
minWidth: textWidth || 0,
|
||||
color: theme[labelColor].solid,
|
||||
color: labelColor,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
}}
|
||||
|
@ -104,7 +110,18 @@ export const TextLabel = React.memo(function TextLabel({
|
|||
<div className={`${cssPrefix} tl-text tl-text-content`} dir="ltr">
|
||||
{finalText}
|
||||
</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>
|
||||
)
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
/* eslint-disable no-inner-declarations */
|
||||
|
||||
import {
|
||||
TLShape,
|
||||
TLShapeId,
|
||||
TLUnknownShape,
|
||||
getPointerInfo,
|
||||
preventDefault,
|
||||
setPointerCapture,
|
||||
stopEventPropagation,
|
||||
useEditor,
|
||||
useValue,
|
||||
|
@ -14,45 +12,86 @@ import React, { useCallback, useEffect, useRef } from 'react'
|
|||
import { INDENT, TextHelpers } from './TextHelpers'
|
||||
|
||||
/** @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 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(() => {
|
||||
const elm = rInput.current
|
||||
if (elm && isEditing && document.activeElement !== elm) {
|
||||
elm.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
// When the label receives focus, set the value to the most recent text value and select all of the text
|
||||
const handleFocus = useCallback(() => {
|
||||
// Store and turn off the skipSelectOnFocus flag
|
||||
const skipSelect = rSkipSelectOnFocus.current
|
||||
rSkipSelectOnFocus.current = false
|
||||
|
||||
// On the next frame, if we're not skipping select AND we have text in the element, then focus the text
|
||||
requestAnimationFrame(() => {
|
||||
const elm = rInput.current
|
||||
if (!elm) return
|
||||
|
||||
const shape = editor.getShape<TLShape & { props: { text: string } }>(id)
|
||||
|
||||
if (shape) {
|
||||
elm.value = shape.props.text
|
||||
if (elm.value.length && !skipSelect) {
|
||||
function selectAllIfEditing({ shapeId }: { shapeId: TLShapeId }) {
|
||||
if (shapeId === id) {
|
||||
const elm = rInput.current
|
||||
if (elm) {
|
||||
if (document.activeElement !== elm) {
|
||||
elm.focus()
|
||||
}
|
||||
elm.select()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
editor.on('select-all-text', selectAllIfEditing)
|
||||
return () => {
|
||||
editor.off('select-all-text', selectAllIfEditing)
|
||||
}
|
||||
}, [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.
|
||||
// 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
|
||||
|
@ -63,36 +102,28 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
|
|||
const elm = rInput.current
|
||||
const editingShapeId = editor.getEditingShapeId()
|
||||
// Did we move to a different shape?
|
||||
// important! these ^v are two different things
|
||||
// is that shape OUR shape?
|
||||
if (elm && editingShapeId === id) {
|
||||
if (ranges) {
|
||||
if (!ranges.length) {
|
||||
// If we don't have any ranges, restore selection
|
||||
// and select all of the text
|
||||
elm.focus()
|
||||
} else {
|
||||
// Otherwise, skip the select-all-on-focus behavior
|
||||
// and restore the selection
|
||||
rSkipSelectOnFocus.current = true
|
||||
elm.focus()
|
||||
if (editingShapeId) {
|
||||
// important! these ^v are two different things
|
||||
// is that shape OUR shape?
|
||||
if (elm && editingShapeId === id) {
|
||||
elm.focus()
|
||||
if (ranges && ranges.length) {
|
||||
const selection = window.getSelection()
|
||||
if (selection) {
|
||||
ranges.forEach((range) => selection.addRange(range))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
elm.focus()
|
||||
}
|
||||
} else {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
})
|
||||
}, [editor, id])
|
||||
|
||||
// When the user presses ctrl / meta enter, complete the editing state.
|
||||
// When the user presses tab, indent or unindent the text.
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return
|
||||
if (editor.getEditingShapeId() !== id) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter': {
|
||||
|
@ -102,23 +133,25 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
|
|||
break
|
||||
}
|
||||
case 'Tab': {
|
||||
preventDefault(e)
|
||||
if (e.shiftKey) {
|
||||
TextHelpers.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextHelpers.indent(e.currentTarget)
|
||||
if (!opts.disableTab) {
|
||||
preventDefault(e)
|
||||
if (e.shiftKey) {
|
||||
TextHelpers.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextHelpers.indent(e.currentTarget)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor, isEditing]
|
||||
[editor, id, opts.disableTab]
|
||||
)
|
||||
|
||||
// When the text changes, update the text value.
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return
|
||||
if (editor.getEditingShapeId() !== id) return
|
||||
|
||||
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 } }>([
|
||||
{ id, type, props: { text } },
|
||||
])
|
||||
editor.updateShape<TLUnknownShape & { props: { text: string } }>({
|
||||
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(
|
||||
(e: React.PointerEvent) => {
|
||||
editor.dispatch({
|
||||
|
@ -182,6 +187,12 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
|
|||
})
|
||||
|
||||
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]
|
||||
)
|
||||
|
@ -190,13 +201,18 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
|
|||
|
||||
return {
|
||||
rInput,
|
||||
isEditing,
|
||||
handleFocus,
|
||||
handleFocus: noop,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
handleChange,
|
||||
handleInputPointerDown,
|
||||
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'
|
||||
|
||||
type TextAreaProps = {
|
||||
isEditing: boolean
|
||||
text: string
|
||||
handleFocus: () => void
|
||||
handleBlur: () => void
|
||||
|
@ -13,6 +14,7 @@ type TextAreaProps = {
|
|||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
||||
{
|
||||
isEditing,
|
||||
text,
|
||||
handleFocus,
|
||||
handleChange,
|
||||
|
@ -29,11 +31,12 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
|
|||
className="tl-text tl-text-input"
|
||||
name="text"
|
||||
tabIndex={-1}
|
||||
readOnly={!isEditing}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoSave="off"
|
||||
autoFocus
|
||||
// autoFocus
|
||||
placeholder=""
|
||||
spellCheck="true"
|
||||
wrap="off"
|
||||
|
@ -45,9 +48,14 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
|
|||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onTouchEnd={stopEventPropagation}
|
||||
onContextMenu={stopEventPropagation}
|
||||
onContextMenu={isEditing ? stopEventPropagation : undefined}
|
||||
onPointerDown={handleInputPointerDown}
|
||||
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 {
|
||||
Box,
|
||||
Editor,
|
||||
HTMLContainer,
|
||||
Rectangle2d,
|
||||
ShapeUtil,
|
||||
SvgExportContext,
|
||||
|
@ -12,11 +11,13 @@ import {
|
|||
TLTextShape,
|
||||
Vec,
|
||||
WeakMapCache,
|
||||
getDefaultColorTheme,
|
||||
textShapeMigrations,
|
||||
textShapeProps,
|
||||
toDomPrecision,
|
||||
useEditor,
|
||||
} from '@tldraw/editor'
|
||||
import { useDefaultColorTheme } from '../shared/ShapeFill'
|
||||
import { SvgTextLabel } from '../shared/SvgTextLabel'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
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,
|
||||
height: height * scale,
|
||||
isFilled: true,
|
||||
isLabel: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -69,29 +71,30 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
|
|||
} = shape
|
||||
|
||||
const { width, height } = this.getMinDimensions(shape)
|
||||
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
|
||||
const theme = useDefaultColorTheme()
|
||||
|
||||
return (
|
||||
<HTMLContainer id={shape.id}>
|
||||
<TextLabel
|
||||
id={id}
|
||||
classNamePrefix="tl-text-shape"
|
||||
type="text"
|
||||
font={font}
|
||||
fontSize={FONT_SIZES[size]}
|
||||
lineHeight={TEXT_PROPS.lineHeight}
|
||||
align={align}
|
||||
verticalAlign="middle"
|
||||
text={text}
|
||||
labelColor={color}
|
||||
textWidth={width}
|
||||
textHeight={height}
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
wrap
|
||||
/>
|
||||
</HTMLContainer>
|
||||
<TextLabel
|
||||
id={id}
|
||||
classNamePrefix="tl-text-shape"
|
||||
type="text"
|
||||
font={font}
|
||||
fontSize={FONT_SIZES[size]}
|
||||
lineHeight={TEXT_PROPS.lineHeight}
|
||||
align={align}
|
||||
verticalAlign="middle"
|
||||
text={text}
|
||||
labelColor={theme[color].solid}
|
||||
isSelected={isSelected}
|
||||
textWidth={width}
|
||||
textHeight={height}
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
wrap
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -110,6 +113,8 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
|
|||
const width = bounds.width / (shape.props.scale ?? 1)
|
||||
const height = bounds.height / (shape.props.scale ?? 1)
|
||||
|
||||
const theme = getDefaultColorTheme(ctx)
|
||||
|
||||
return (
|
||||
<SvgTextLabel
|
||||
fontSize={FONT_SIZES[shape.props.size]}
|
||||
|
@ -117,7 +122,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
|
|||
align={shape.props.align}
|
||||
verticalAlign="middle"
|
||||
text={shape.props.text}
|
||||
labelColor={shape.props.color}
|
||||
labelColor={theme[shape.props.color].solid}
|
||||
bounds={new Box(0, 0, width, height)}
|
||||
padding={0}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
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 */
|
||||
export class DragAndDropManager {
|
||||
|
@ -16,6 +18,12 @@ export class DragAndDropManager {
|
|||
|
||||
updateDroppingNode(movingShapes: TLShape[], cb: () => void) {
|
||||
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.editor.getDroppingOverShape(this.editor.inputs.originPagePoint, movingShapes)?.id ??
|
||||
null
|
||||
|
@ -23,10 +31,10 @@ export class DragAndDropManager {
|
|||
}
|
||||
|
||||
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) {
|
||||
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?
|
||||
if (nextDroppingShapeId === this.prevDroppingShapeId) {
|
||||
this.hintParents(movingShapes)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -64,24 +73,46 @@ export class DragAndDropManager {
|
|||
}
|
||||
|
||||
if (nextDroppingShape) {
|
||||
const res = this.editor
|
||||
this.editor
|
||||
.getShapeUtil(nextDroppingShape)
|
||||
.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?.()
|
||||
|
||||
// next -> curr
|
||||
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[]) {
|
||||
const { prevDroppingShapeId } = this
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Vec,
|
||||
structuredClone,
|
||||
} from '@tldraw/editor'
|
||||
import { kickoutOccludedShapes } from '../selectHelpers'
|
||||
import { MIN_CROP_SIZE } from './Crop/crop-constants'
|
||||
import { CursorTypeMap } from './PointingResizeHandle'
|
||||
|
||||
|
@ -206,6 +207,7 @@ export class Cropping extends StateNode {
|
|||
|
||||
private complete() {
|
||||
this.updateShapes()
|
||||
kickoutOccludedShapes(this.editor, [this.snapshot.shape.id])
|
||||
if (this.info.onInteractionEnd) {
|
||||
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
|
||||
} else {
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
sortByIndex,
|
||||
structuredClone,
|
||||
} from '@tldraw/editor'
|
||||
import { kickoutOccludedShapes } from '../selectHelpers'
|
||||
|
||||
export class DraggingHandle extends StateNode {
|
||||
static override id = 'dragging_handle'
|
||||
|
@ -203,6 +204,7 @@ export class DraggingHandle extends StateNode {
|
|||
|
||||
private complete() {
|
||||
this.editor.snaps.clearIndicators()
|
||||
kickoutOccludedShapes(this.editor, [this.shapeId])
|
||||
|
||||
const { onInteractionEnd } = this.info
|
||||
if (this.editor.getInstanceState().isToolLocked && onInteractionEnd) {
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
import {
|
||||
Group2d,
|
||||
StateNode,
|
||||
TLArrowShape,
|
||||
TLEventHandlers,
|
||||
TLFrameShape,
|
||||
TLGeoShape,
|
||||
} from '@tldraw/editor'
|
||||
import { StateNode, TLEventHandlers, TLFrameShape, TLShape, TLTextShape } from '@tldraw/editor'
|
||||
import { getTextLabels } from '../../../utils/shapes/shapes'
|
||||
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
|
||||
import { updateHoveredId } from '../../selection-logic/updateHoveredId'
|
||||
|
||||
export class EditingShape extends StateNode {
|
||||
static override id = 'editing_shape'
|
||||
|
||||
hitShapeForPointerUp: TLShape | null = null
|
||||
|
||||
override onEnter = () => {
|
||||
const editingShape = this.editor.getEditingShape()
|
||||
if (!editingShape) throw Error('Entered editing state without an editing shape')
|
||||
this.hitShapeForPointerUp = null
|
||||
updateHoveredId(this.editor)
|
||||
this.editor.select(editingShape)
|
||||
}
|
||||
|
@ -34,6 +31,16 @@ export class EditingShape extends StateNode {
|
|||
}
|
||||
|
||||
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) {
|
||||
case 'shape':
|
||||
case 'canvas': {
|
||||
|
@ -42,7 +49,10 @@ export class EditingShape extends StateNode {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
|
||||
this.hitShapeForPointerUp = null
|
||||
|
||||
switch (info.target) {
|
||||
case 'canvas': {
|
||||
const hitShape = getHitShapeOnCanvasPointerDown(this.editor)
|
||||
|
@ -57,54 +67,59 @@ export class EditingShape extends StateNode {
|
|||
break
|
||||
}
|
||||
case 'shape': {
|
||||
const { shape } = info
|
||||
const { shape: selectingShape } = info
|
||||
const editingShape = this.editor.getEditingShape()
|
||||
|
||||
if (!editingShape) {
|
||||
throw Error('Expected an editing shape!')
|
||||
}
|
||||
|
||||
if (shape.type === editingShape.type) {
|
||||
// clicked a shape of the same type as the editing shape
|
||||
// for shapes with labels, check to see if the click was inside of the shape's label
|
||||
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 (
|
||||
this.editor.isShapeOfType<TLGeoShape>(shape, 'geo') ||
|
||||
this.editor.isShapeOfType<TLArrowShape>(shape, 'arrow')
|
||||
textLabel.bounds.containsPoint(pointInShapeSpace, 0) &&
|
||||
textLabel.hitTestPoint(pointInShapeSpace)
|
||||
) {
|
||||
// for shapes with labels, check to see if the click was inside of the shape's label
|
||||
const geometry = this.editor.getShapeUtil(shape).getGeometry(shape) as Group2d
|
||||
const labelGeometry = geometry.children[1]
|
||||
if (labelGeometry) {
|
||||
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
|
||||
// it's a hit to the label!
|
||||
if (selectingShape.id === editingShape.id) {
|
||||
// If we clicked on the editing geo / arrow shape's label, do nothing
|
||||
return
|
||||
} else {
|
||||
// But if we clicked on a different shape of the same type, transition to pointing_shape instead
|
||||
this.parent.transition('pointing_shape', info)
|
||||
this.hitShapeForPointerUp = selectingShape
|
||||
|
||||
this.editor.mark('editing on pointer up')
|
||||
this.editor.select(selectingShape.id)
|
||||
|
||||
// When clicking on a different shape's label, we need to clear the other selection
|
||||
// proactively until the pointer up happens.
|
||||
requestAnimationFrame(() => window.getSelection()?.removeAllRanges())
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
}
|
||||
|
@ -116,6 +131,32 @@ export class EditingShape extends StateNode {
|
|||
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) => {
|
||||
this.parent.transition('idle', info)
|
||||
}
|
||||
|
|
|
@ -13,12 +13,25 @@ import {
|
|||
Vec,
|
||||
VecLike,
|
||||
createShapeId,
|
||||
debugFlags,
|
||||
pointInPolygon,
|
||||
} from '@tldraw/editor'
|
||||
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
|
||||
import { getShouldEnterCropMode } from '../../selection-logic/getShouldEnterCropModeOnPointerDown'
|
||||
import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp'
|
||||
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 {
|
||||
static override id = 'idle'
|
||||
|
@ -257,6 +270,7 @@ export class Idle extends StateNode {
|
|||
if (change) {
|
||||
this.editor.mark('double click edge')
|
||||
this.editor.updateShapes([change])
|
||||
kickoutOccludedShapes(this.editor, [onlySelectedShape.id])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -271,7 +285,7 @@ export class Idle extends StateNode {
|
|||
}
|
||||
|
||||
if (this.shouldStartEditingShape(onlySelectedShape)) {
|
||||
this.startEditingShape(onlySelectedShape, info)
|
||||
this.startEditingShape(onlySelectedShape, info, true /* select all */)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
@ -305,7 +319,7 @@ export class Idle extends StateNode {
|
|||
|
||||
// If the shape can edit, then begin editing
|
||||
if (this.shouldStartEditingShape(shape)) {
|
||||
this.startEditingShape(shape, info)
|
||||
this.startEditingShape(shape, info, true /* select all */)
|
||||
} else {
|
||||
// If the shape's double click handler has not created a change,
|
||||
// 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,
|
||||
// and if the shape can edit, then begin editing the 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 'ArrowDown': {
|
||||
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
|
||||
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
||||
if (onlySelectedShape && this.shouldStartEditingShape(onlySelectedShape)) {
|
||||
this.startEditingShape(onlySelectedShape, {
|
||||
...info,
|
||||
target: 'shape',
|
||||
shape: onlySelectedShape,
|
||||
})
|
||||
this.startEditingShape(
|
||||
onlySelectedShape,
|
||||
{
|
||||
...info,
|
||||
target: 'shape',
|
||||
shape: onlySelectedShape,
|
||||
},
|
||||
true /* select all */
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -479,10 +525,14 @@ export class Idle extends StateNode {
|
|||
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
|
||||
this.editor.mark('editing shape')
|
||||
this.editor.setEditingShape(shape.id)
|
||||
startEditingShapeWithLabel(this.editor, shape, shouldSelectAll)
|
||||
this.parent.transition('editing_shape', info)
|
||||
}
|
||||
|
||||
|
@ -581,7 +631,9 @@ export class Idle extends StateNode {
|
|||
? MAJOR_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) {
|
||||
|
|
|
@ -16,6 +16,8 @@ export class PointingArrowLabel extends StateNode {
|
|||
|
||||
shapeId = '' as TLShapeId
|
||||
markId = ''
|
||||
wasAlreadySelected = false
|
||||
didDrag = false
|
||||
|
||||
private info = {} as TLPointerEventInfo & {
|
||||
shape: TLArrowShape
|
||||
|
@ -38,6 +40,8 @@ export class PointingArrowLabel extends StateNode {
|
|||
this.parent.setCurrentToolIdMask(info.onInteractionEnd)
|
||||
this.info = info
|
||||
this.shapeId = shape.id
|
||||
this.didDrag = false
|
||||
this.wasAlreadySelected = this.editor.getOnlySelectedShapeId() === shape.id
|
||||
this.updateCursor()
|
||||
|
||||
const geometry = this.editor.getShapeGeometry<Group2d>(shape)
|
||||
|
@ -100,6 +104,7 @@ export class PointingArrowLabel extends StateNode {
|
|||
nextLabelPosition = 0.5
|
||||
}
|
||||
|
||||
this.didDrag = true
|
||||
this.editor.updateShape<TLArrowShape>(
|
||||
{ id: shape.id, type: shape.type, props: { labelPosition: nextLabelPosition } },
|
||||
{ squashing: true }
|
||||
|
@ -107,7 +112,16 @@ export class PointingArrowLabel extends StateNode {
|
|||
}
|
||||
|
||||
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'] = () => {
|
||||
|
|
|
@ -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 {
|
||||
static override id = 'pointing_handle'
|
||||
|
@ -32,11 +47,23 @@ export class PointingHandle extends StateNode {
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||
if (this.editor.inputs.isDragging) {
|
||||
const { editor } = this
|
||||
if (editor.inputs.isDragging) {
|
||||
this.startDraggingHandle()
|
||||
}
|
||||
}
|
||||
|
@ -46,7 +73,38 @@ export class PointingHandle extends StateNode {
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -66,3 +124,15 @@ export class PointingHandle extends StateNode {
|
|||
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 {
|
||||
Group2d,
|
||||
HIT_TEST_MARGIN,
|
||||
StateNode,
|
||||
TLArrowShape,
|
||||
TLEventHandlers,
|
||||
TLGeoShape,
|
||||
TLPointerEventInfo,
|
||||
TLShape,
|
||||
} from '@tldraw/editor'
|
||||
import { getTextLabels } from '../../../utils/shapes/shapes'
|
||||
|
||||
export class PointingShape extends StateNode {
|
||||
static override id = 'pointing_shape'
|
||||
|
||||
hitShape = {} as TLShape
|
||||
hitShapeForPointerUp = {} as TLShape
|
||||
isDoubleClick = false
|
||||
|
||||
didSelectOnEnter = false
|
||||
|
||||
|
@ -26,7 +25,11 @@ export class PointingShape extends StateNode {
|
|||
} = this.editor
|
||||
|
||||
this.hitShape = info.shape
|
||||
this.isDoubleClick = false
|
||||
const outermostSelectingShape = this.editor.getOutermostSelectableShape(info.shape)
|
||||
const selectedAncestor = this.editor.findShapeAncestor(outermostSelectingShape, (parent) =>
|
||||
selectedShapeIds.includes(parent.id)
|
||||
)
|
||||
|
||||
if (
|
||||
// If the shape has an onClick handler
|
||||
|
@ -35,7 +38,8 @@ export class PointingShape extends StateNode {
|
|||
outermostSelectingShape.id === focusedGroupId ||
|
||||
// ...or if the shape is within the selection
|
||||
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
|
||||
(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
|
||||
// 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 is a geo shape, and we're inside of the label, then we want to begin editing the label
|
||||
if (
|
||||
selectedShapeIds.length === 1 &&
|
||||
(this.editor.isShapeOfType<TLGeoShape>(selectingShape, 'geo') ||
|
||||
this.editor.isShapeOfType<TLArrowShape>(selectingShape, 'arrow'))
|
||||
) {
|
||||
const geometry = this.editor.getShapeGeometry(selectingShape)
|
||||
const labelGeometry = (geometry as Group2d).children[1]
|
||||
if (labelGeometry) {
|
||||
// if the shape has a text label, and we're inside of the label, then we want to begin editing the label.
|
||||
if (selectedShapeIds.length === 1) {
|
||||
const geometry = this.editor.getShapeUtil(selectingShape).getGeometry(selectingShape)
|
||||
const textLabels = getTextLabels(geometry)
|
||||
const textLabel = textLabels.length === 1 ? textLabels[0] : undefined
|
||||
// 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.
|
||||
if (textLabel) {
|
||||
const pointInShapeSpace = this.editor.getPointInShapeSpace(
|
||||
selectingShape,
|
||||
currentPagePoint
|
||||
)
|
||||
|
||||
if (
|
||||
labelGeometry.bounds.containsPoint(pointInShapeSpace, 0) &&
|
||||
labelGeometry.hitTestPoint(pointInShapeSpace)
|
||||
textLabel.bounds.containsPoint(pointInShapeSpace, 0) &&
|
||||
textLabel.hitTestPoint(pointInShapeSpace)
|
||||
) {
|
||||
this.editor.batch(() => {
|
||||
this.editor.mark('editing on pointer up')
|
||||
|
@ -159,6 +161,10 @@ export class PointingShape extends StateNode {
|
|||
|
||||
this.editor.setEditingShape(selectingShape.id)
|
||||
this.editor.setCurrentTool('select.editing_shape')
|
||||
|
||||
if (this.isDoubleClick) {
|
||||
this.editor.emit('select-all-text', { shapeId: selectingShape.id })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -193,6 +199,10 @@ export class PointingShape extends StateNode {
|
|||
this.parent.transition('idle', info)
|
||||
}
|
||||
|
||||
override onDoubleClick: TLEventHandlers['onDoubleClick'] = () => {
|
||||
this.isDoubleClick = true
|
||||
}
|
||||
|
||||
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
|
||||
if (this.editor.inputs.isDragging) {
|
||||
this.startTranslating(info)
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
compact,
|
||||
moveCameraWhenCloseToEdge,
|
||||
} from '@tldraw/editor'
|
||||
import { kickoutOccludedShapes } from '../selectHelpers'
|
||||
|
||||
type ResizingInfo = TLPointerEventInfo & {
|
||||
target: 'selection'
|
||||
|
@ -111,6 +112,8 @@ export class Resizing extends StateNode {
|
|||
}
|
||||
|
||||
private complete() {
|
||||
kickoutOccludedShapes(this.editor, this.snapshot.selectedShapeIds)
|
||||
|
||||
this.handleResizeEnd()
|
||||
|
||||
if (this.info.isCreating && this.info.onCreate) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
shortAngleDist,
|
||||
snapAngle,
|
||||
} from '@tldraw/editor'
|
||||
import { kickoutOccludedShapes } from '../selectHelpers'
|
||||
import { CursorTypeMap } from './PointingResizeHandle'
|
||||
|
||||
const ONE_DEGREE = Math.PI / 180
|
||||
|
@ -128,6 +129,10 @@ export class Rotating extends StateNode {
|
|||
snapshot: this.snapshot,
|
||||
stage: 'end',
|
||||
})
|
||||
kickoutOccludedShapes(
|
||||
this.editor,
|
||||
this.snapshot.shapeSnapshots.map((s) => s.shape.id)
|
||||
)
|
||||
if (this.info.onInteractionEnd) {
|
||||
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
|
||||
} else {
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import {
|
||||
BoundsSnapPoint,
|
||||
Box,
|
||||
Editor,
|
||||
Mat,
|
||||
MatModel,
|
||||
PageRecordType,
|
||||
StateNode,
|
||||
TLEventHandlers,
|
||||
TLNoteShape,
|
||||
TLPointerEventInfo,
|
||||
TLShape,
|
||||
TLShapePartial,
|
||||
|
@ -15,7 +15,13 @@ import {
|
|||
isPageId,
|
||||
moveCameraWhenCloseToEdge,
|
||||
} from '@tldraw/editor'
|
||||
import {
|
||||
NOTE_PIT_RADIUS,
|
||||
NOTE_SIZE,
|
||||
getAvailableNoteAdjacentPositions,
|
||||
} from '../../../shapes/note/noteHelpers'
|
||||
import { DragAndDropManager } from '../DragAndDropManager'
|
||||
import { kickoutOccludedShapes } from '../selectHelpers'
|
||||
|
||||
export class Translating extends StateNode {
|
||||
static override id = 'translating'
|
||||
|
@ -24,6 +30,7 @@ export class Translating extends StateNode {
|
|||
target: 'shape'
|
||||
isCreating?: boolean
|
||||
onCreate?: () => void
|
||||
didStartInPit?: boolean
|
||||
onInteractionEnd?: string
|
||||
}
|
||||
|
||||
|
@ -85,10 +92,7 @@ export class Translating extends StateNode {
|
|||
this.selectionSnapshot = {} as any
|
||||
this.snapshot = {} as any
|
||||
this.editor.snaps.clearIndicators()
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
this.dragAndDropManager.clear()
|
||||
}
|
||||
|
||||
|
@ -167,6 +171,10 @@ export class Translating extends StateNode {
|
|||
protected complete() {
|
||||
this.updateShapes()
|
||||
this.dragAndDropManager.dropShapes(this.snapshot.movingShapes)
|
||||
kickoutOccludedShapes(
|
||||
this.editor,
|
||||
this.snapshot.movingShapes.map((s) => s.id)
|
||||
)
|
||||
this.handleEnd()
|
||||
|
||||
if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) {
|
||||
|
@ -268,10 +276,7 @@ export class Translating extends StateNode {
|
|||
|
||||
moveShapesToPoint({
|
||||
editor: this.editor,
|
||||
shapeSnapshots: snapshot.shapeSnapshots,
|
||||
averagePagePoint: snapshot.averagePagePoint,
|
||||
initialSelectionPageBounds: snapshot.initialPageBounds,
|
||||
initialSelectionSnapPoints: snapshot.initialSnapPoints,
|
||||
snapshot,
|
||||
})
|
||||
|
||||
this.handleChange()
|
||||
|
@ -302,14 +307,17 @@ function getTranslatingSnapshot(editor: Editor) {
|
|||
const movingShapes: TLShape[] = []
|
||||
const pagePoints: Vec[] = []
|
||||
|
||||
const selectedShapeIds = editor.getSelectedShapeIds()
|
||||
const shapeSnapshots = compact(
|
||||
editor.getSelectedShapeIds().map((id): null | MovingShapeSnapshot => {
|
||||
selectedShapeIds.map((id): null | MovingShapeSnapshot => {
|
||||
const shape = editor.getShape(id)
|
||||
if (!shape) return null
|
||||
movingShapes.push(shape)
|
||||
|
||||
const pagePoint = editor.getShapePageTransform(id)!.point()
|
||||
if (!pagePoint) return null
|
||||
const pageTransform = editor.getShapePageTransform(id)
|
||||
const pagePoint = pageTransform.point()
|
||||
const pageRotation = pageTransform.rotation()
|
||||
|
||||
pagePoints.push(pagePoint)
|
||||
|
||||
const parentTransform = PageRecordType.isId(shape.parentId)
|
||||
|
@ -319,14 +327,18 @@ function getTranslatingSnapshot(editor: Editor) {
|
|||
return {
|
||||
shape,
|
||||
pagePoint,
|
||||
pageRotation,
|
||||
parentTransform,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const onlySelectedShape = editor.getOnlySelectedShape()
|
||||
|
||||
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 {
|
||||
const selectionPageBounds = editor.getSelectionPageBounds()
|
||||
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 {
|
||||
averagePagePoint: Vec.Average(pagePoints),
|
||||
movingShapes,
|
||||
shapeSnapshots,
|
||||
initialPageBounds: editor.getSelectionPageBounds()!,
|
||||
initialSnapPoints,
|
||||
noteAdjacentPositions,
|
||||
noteSnapshot,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -352,24 +401,28 @@ export type TranslatingSnapshot = ReturnType<typeof getTranslatingSnapshot>
|
|||
export interface MovingShapeSnapshot {
|
||||
shape: TLShape
|
||||
pagePoint: Vec
|
||||
pageRotation: number
|
||||
parentTransform: MatModel | null
|
||||
}
|
||||
|
||||
export function moveShapesToPoint({
|
||||
editor,
|
||||
shapeSnapshots: snapshots,
|
||||
averagePagePoint,
|
||||
initialSelectionPageBounds,
|
||||
initialSelectionSnapPoints,
|
||||
snapshot,
|
||||
}: {
|
||||
editor: Editor
|
||||
shapeSnapshots: MovingShapeSnapshot[]
|
||||
averagePagePoint: Vec
|
||||
initialSelectionPageBounds: Box
|
||||
initialSelectionSnapPoints: BoundsSnapPoint[]
|
||||
snapshot: TranslatingSnapshot
|
||||
}) {
|
||||
const { inputs } = editor
|
||||
|
||||
const {
|
||||
noteSnapshot,
|
||||
noteAdjacentPositions,
|
||||
initialPageBounds,
|
||||
initialSnapPoints,
|
||||
shapeSnapshots,
|
||||
averagePagePoint,
|
||||
} = snapshot
|
||||
|
||||
const isGridMode = editor.getInstanceState().isGridMode
|
||||
|
||||
const gridSize = editor.getDocumentSettings().gridSize
|
||||
|
@ -391,19 +444,41 @@ export function moveShapesToPoint({
|
|||
// Provisional snapping
|
||||
editor.snaps.clearIndicators()
|
||||
|
||||
const shouldSnap =
|
||||
(editor.user.getIsSnapMode() ? !inputs.ctrlKey : inputs.ctrlKey) &&
|
||||
editor.inputs.pointerVelocity.len() < 0.5 // ...and if the user is not dragging fast
|
||||
|
||||
if (shouldSnap) {
|
||||
// If the user isn't moving super quick
|
||||
const isSnapping = editor.user.getIsSnapMode() ? !inputs.ctrlKey : inputs.ctrlKey
|
||||
if (isSnapping && editor.inputs.pointerVelocity.len() < 0.5) {
|
||||
// snapping
|
||||
const { nudge } = editor.snaps.shapeBounds.snapTranslateShapes({
|
||||
dragDelta: delta,
|
||||
initialSelectionPageBounds,
|
||||
initialSelectionPageBounds: initialPageBounds,
|
||||
lockedAxis: flatten,
|
||||
initialSelectionSnapPoints,
|
||||
initialSelectionSnapPoints: initialSnapPoints,
|
||||
})
|
||||
|
||||
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)
|
||||
|
@ -416,8 +491,9 @@ export function moveShapesToPoint({
|
|||
|
||||
editor.updateShapes(
|
||||
compact(
|
||||
snapshots.map(({ shape, pagePoint, parentTransform }): TLShapePartial | null => {
|
||||
shapeSnapshots.map(({ shape, pagePoint, parentTransform }): TLShapePartial | null => {
|
||||
const newPagePoint = Vec.Add(pagePoint, averageSnap)
|
||||
|
||||
const newLocalPoint = parentTransform
|
||||
? Mat.applyToPoint(parentTransform, 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'
|
||||
import React from 'react'
|
||||
import { STYLES } from '../../../styles'
|
||||
import { kickoutOccludedShapes } from '../../../tools/SelectTool/selectHelpers'
|
||||
import { useUiEvents } from '../../context/events'
|
||||
import { useRelevantStyles } from '../../hooks/useRelevantStyles'
|
||||
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
|
||||
|
@ -101,6 +102,7 @@ export function CommonStylePickerSet({
|
|||
theme: TLDefaultColorTheme
|
||||
}) {
|
||||
const msg = useTranslation()
|
||||
const editor = useEditor()
|
||||
|
||||
const handleValueChange = useStyleChangeCallback()
|
||||
|
||||
|
@ -163,7 +165,13 @@ export function CommonStylePickerSet({
|
|||
style={DefaultSizeStyle}
|
||||
items={STYLES.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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
useEditor,
|
||||
} from '@tldraw/editor'
|
||||
import * as React from 'react'
|
||||
import { kickoutOccludedShapes } from '../../tools/SelectTool/selectHelpers'
|
||||
import { getEmbedInfo } from '../../utils/embeds/embeds'
|
||||
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
|
||||
import { EditLinkDialog } from '../components/EditLinkDialog'
|
||||
|
@ -321,24 +322,28 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
|
||||
trackEvent('toggle-auto-size', { source })
|
||||
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
|
||||
.getSelectedShapes()
|
||||
.filter(
|
||||
(shape): shape is TLTextShape =>
|
||||
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
|
||||
)
|
||||
.map((shape) => {
|
||||
return {
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
w: 8,
|
||||
autoSize: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
shapes.map((shape) => {
|
||||
return {
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
w: 8,
|
||||
autoSize: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
kickoutOccludedShapes(
|
||||
editor,
|
||||
shapes.map((shape) => shape.id)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -499,17 +504,19 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
const commonBounds = Box.Common(compact(ids.map((id) => editor.getShapePageBounds(id))))
|
||||
offset = instanceState.canMoveCamera
|
||||
? {
|
||||
x: commonBounds.width + 10,
|
||||
x: commonBounds.width + 20,
|
||||
y: 0,
|
||||
}
|
||||
: {
|
||||
x: 16 / editor.getZoomLevel(),
|
||||
y: 16 / editor.getZoomLevel(),
|
||||
// same as the adjacent note margin
|
||||
x: 20,
|
||||
y: 20,
|
||||
}
|
||||
}
|
||||
|
||||
editor.mark('duplicate shapes')
|
||||
editor.duplicateShapes(ids, offset)
|
||||
|
||||
if (instanceState.duplicateProps) {
|
||||
// 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
|
||||
|
@ -602,7 +609,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
|
||||
trackEvent('align-shapes', { operation: 'left', source })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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 })
|
||||
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')
|
||||
const offset = editor.getSelectionRotation() % (HALF_PI / 2)
|
||||
const dontUseOffset = approximately(offset, 0) || approximately(offset, HALF_PI / 2)
|
||||
editor.rotateShapesBy(
|
||||
editor.getSelectedShapeIds(),
|
||||
HALF_PI / 2 - (dontUseOffset ? 0 : offset)
|
||||
)
|
||||
const selectedShapeIds = editor.getSelectedShapeIds()
|
||||
editor.rotateShapesBy(selectedShapeIds, HALF_PI / 2 - (dontUseOffset ? 0 : offset))
|
||||
kickoutOccludedShapes(editor, selectedShapeIds)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -988,10 +1024,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
editor.mark('rotate-ccw')
|
||||
const offset = editor.getSelectionRotation() % (HALF_PI / 2)
|
||||
const offsetCloseToZero = approximately(offset, 0)
|
||||
editor.rotateShapesBy(
|
||||
editor.getSelectedShapeIds(),
|
||||
offsetCloseToZero ? -(HALF_PI / 2) : -offset
|
||||
)
|
||||
const selectedShapeIds = editor.getSelectedShapeIds()
|
||||
editor.rotateShapesBy(selectedShapeIds, offsetCloseToZero ? -(HALF_PI / 2) : -offset)
|
||||
kickoutOccludedShapes(editor, selectedShapeIds)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -10,17 +10,21 @@ import { DEFAULT_TRANSLATION } from './defaultTranslation'
|
|||
|
||||
/* ----------------- (do not change) ---------------- */
|
||||
|
||||
export const RTL_LANGUAGES = new Set(['ar', 'fa', 'he', 'ur', 'ku'])
|
||||
|
||||
/** @public */
|
||||
export type TLUiTranslation = {
|
||||
readonly locale: string
|
||||
readonly label: string
|
||||
readonly messages: Record<TLUiTranslationKey, string>
|
||||
readonly dir: 'rtl' | 'ltr'
|
||||
}
|
||||
|
||||
const EN_TRANSLATION: TLUiTranslation = {
|
||||
locale: 'en',
|
||||
label: 'English',
|
||||
messages: DEFAULT_TRANSLATION as TLUiTranslation['messages'],
|
||||
dir: 'ltr',
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
@ -69,6 +73,7 @@ export async function fetchTranslation(
|
|||
return {
|
||||
locale,
|
||||
label: language.label,
|
||||
dir: RTL_LANGUAGES.has(language.locale) ? 'rtl' : 'ltr',
|
||||
messages: { ...EN_TRANSLATION.messages, ...messages },
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,8 @@ const TranslationsContext = React.createContext<TLUiTranslationContextType>(
|
|||
{} as TLUiTranslationContextType
|
||||
)
|
||||
|
||||
const useCurrentTranslation = () => React.useContext(TranslationsContext)
|
||||
/** @public */
|
||||
export const useCurrentTranslation = () => React.useContext(TranslationsContext)
|
||||
|
||||
/**
|
||||
* Provides a translation context to the editor.
|
||||
|
@ -47,6 +48,7 @@ export const TranslationProvider = track(function TranslationProvider({
|
|||
return {
|
||||
locale: 'en',
|
||||
label: 'English',
|
||||
dir: 'ltr',
|
||||
messages: { ...DEFAULT_TRANSLATION, ...overrides['en'] },
|
||||
}
|
||||
}
|
||||
|
@ -54,6 +56,7 @@ export const TranslationProvider = track(function TranslationProvider({
|
|||
return {
|
||||
locale: 'en',
|
||||
label: 'English',
|
||||
dir: 'ltr',
|
||||
messages: DEFAULT_TRANSLATION,
|
||||
}
|
||||
})
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Box, Editor, TLFrameShape, TLShapeId, TLShapePartial, Vec, compact } fr
|
|||
/**
|
||||
* Remove a frame.
|
||||
*
|
||||
* @param editor - tlraw editor instance.
|
||||
* @param editor - tldraw editor instance.
|
||||
* @param ids - Ids of the frames you wish to remove.
|
||||
*
|
||||
* @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 } }])
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('Enters from pointing and exits to idle', () => {
|
||||
const shape = editor.getShape(ids.box1)
|
||||
|
@ -369,11 +437,11 @@ describe('When editing shapes', () => {
|
|||
// start editing the geo shape
|
||||
editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(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
|
||||
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) })
|
||||
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,
|
||||
|
@ -385,12 +453,12 @@ describe('When editing shapes', () => {
|
|||
// start editing the geo shape
|
||||
editor.doubleClick(50, 50, { target: 'shape', shape: editor.getShape(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
|
||||
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.geo2) })
|
||||
// that other shape should now be editing and selected!
|
||||
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
|
||||
|
@ -403,7 +471,7 @@ describe('When editing shapes', () => {
|
|||
editor.pointerDown(50, 50, { target: 'shape', shape: editor.getShape(ids.text2) })
|
||||
// that other shape should now be editing and selected!
|
||||
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', () => {
|
||||
|
@ -433,7 +501,7 @@ describe('When editing shapes', () => {
|
|||
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.getSelectedShapeIds().length).toBe(0)
|
||||
expect(editor.getCurrentPageShapes().length).toBe(5)
|
||||
|
|
|
@ -85,7 +85,7 @@ export class TestEditor extends Editor {
|
|||
lineHeight: number
|
||||
maxWidth: null | number
|
||||
}
|
||||
): BoxModel => {
|
||||
): BoxModel & { scrollWidth: number } => {
|
||||
const breaks = textToMeasure.split('\n')
|
||||
const longest = breaks.reduce((acc, curr) => {
|
||||
return curr.length > acc.length ? curr : acc
|
||||
|
@ -100,6 +100,7 @@ export class TestEditor extends Editor {
|
|||
h:
|
||||
(opts.maxWidth === null ? breaks.length : Math.ceil(w % opts.maxWidth) + breaks.length) *
|
||||
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.
|
||||
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
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
DefaultFillStyle,
|
||||
GeoShapeGeoStyle,
|
||||
TLArrowShape,
|
||||
TLFrameShape,
|
||||
TLShapeId,
|
||||
|
@ -275,8 +276,8 @@ describe('frame shapes', () => {
|
|||
expect(parentBefore).toBe(frameId)
|
||||
// resize the frame so the shape is partially out of bounds
|
||||
editor.pointerDown(100, 50, { target: 'selection', handle: 'right' })
|
||||
editor.pointerMove(70, 50)
|
||||
editor.pointerUp(70, 50)
|
||||
editor.pointerMove(80, 50)
|
||||
editor.pointerUp(80, 50)
|
||||
const parentAfter = editor.getShape(rectId)?.parentId
|
||||
expect(parentAfter).toBe(frameId)
|
||||
})
|
||||
|
@ -405,7 +406,7 @@ describe('frame shapes', () => {
|
|||
|
||||
expect(editor.getOnlySelectedShape()!.id).toBe(boxAid)
|
||||
expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
|
||||
expect(editor.getHintingShapeIds()).toHaveLength(0)
|
||||
expect(editor.getHintingShapeIds()).toHaveLength(1)
|
||||
// box A should still be beneath box B
|
||||
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()
|
||||
|
||||
expect(editor.getOnlySelectedShape()?.id).toBe(ids.box1)
|
||||
expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
|
||||
|
||||
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()
|
||||
|
||||
expect(editor.getOnlySelectedShape()?.id).toBe(ids.box1)
|
||||
expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
|
||||
expect(editor.getFocusedGroupId()).toBe(ids.group1)
|
||||
|
||||
editor
|
||||
|
@ -1024,6 +1025,65 @@ function dragCreateFrame({
|
|||
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] }) {
|
||||
const rectId: TLShapeId = createShapeId()
|
||||
editor.createShapes([
|
||||
|
@ -1037,3 +1097,117 @@ function createRect({ pos, size }: { pos: [number, number]; size: [number, numbe
|
|||
])
|
||||
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', () => {
|
||||
it('captures the pointer down event if it is on the shape', () => {
|
||||
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._transformPointerUpSpy.mockRestore()
|
||||
editor.setCurrentTool('select')
|
||||
|
@ -42,7 +42,7 @@ describe(SelectTool, () => {
|
|||
})
|
||||
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)
|
||||
const shapeId = editor.getOnlySelectedShape()?.id
|
||||
const shapeId = editor.getLastCreatedShape().id
|
||||
editor._transformPointerDownSpy.mockRestore()
|
||||
editor._transformPointerUpSpy.mockRestore()
|
||||
editor.setCurrentTool('select')
|
||||
|
|
|
@ -253,7 +253,7 @@ describe('when shape is hollow', () => {
|
|||
})
|
||||
|
||||
it('misses on pointer down over shape, misses on pointer up', () => {
|
||||
editor.pointerMove(75, 75)
|
||||
editor.pointerMove(10, 10)
|
||||
expect(editor.getHoveredShapeId()).toBe(null)
|
||||
editor.pointerDown()
|
||||
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', () => {
|
||||
editor.pointerMove(50, 50)
|
||||
editor.pointerMove(35, 35)
|
||||
expect(editor.getHoveredShapeId()).toBe(null)
|
||||
editor.pointerDown() // inside of box1 (which is empty)
|
||||
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', () => {
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
|
||||
editor.keyDown('Shift')
|
||||
editor.pointerMove(275, 75) // above box 2
|
||||
editor.pointerMove(215, 75) // above box 2
|
||||
expect(editor.getHoveredShapeId()).toBe(null)
|
||||
editor.pointerDown()
|
||||
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)', () => {
|
||||
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)
|
||||
editor.pointerDown()
|
||||
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)', () => {
|
||||
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)
|
||||
editor.doubleClick()
|
||||
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', () => {
|
||||
editor.keyDown('Shift')
|
||||
editor.pointerMove(275, 75)
|
||||
editor.pointerMove(215, 75)
|
||||
expect(editor.getHoveredShapeId()).toBe(null)
|
||||
editor.pointerDown() // inside of box 2, inside of group 1
|
||||
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', () => {
|
||||
editor.pointerMove(275, 75)
|
||||
editor.pointerMove(215, 75)
|
||||
editor.keyDown('Shift')
|
||||
expect(editor.getHoveredShapeId()).toBe(null)
|
||||
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', () => {
|
||||
editor.keyDown('Shift')
|
||||
editor.pointerMove(275, 75)
|
||||
editor.pointerMove(215, 75)
|
||||
expect(editor.getHoveredShapeId()).toBe(null)
|
||||
editor.pointerDown() // inside of box 2, empty space, inside of group 1
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
SnapIndicator,
|
||||
TLArrowShape,
|
||||
TLGeoShape,
|
||||
TLNoteShape,
|
||||
TLShapeId,
|
||||
TLShapePartial,
|
||||
Vec,
|
||||
|
@ -1943,3 +1944,189 @@ describe('Moving the camera while panning', () => {
|
|||
.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: {
|
||||
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">;
|
||||
fontSizeAdjustment: T.Validator<number>;
|
||||
font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">;
|
||||
align: EnumStyleProp<"end-legacy" | "end" | "middle-legacy" | "middle" | "start-legacy" | "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]>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ShapePropsType<Config extends Record<string, T.Validatable<any>>> = Expand<{
|
||||
[K in keyof Config]: T.TypeOf<Config[K]>;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export class StyleProp<Type> implements T.Validatable<Type> {
|
||||
// @internal
|
||||
|
@ -895,6 +901,10 @@ export type TLDefaultColorThemeColor = {
|
|||
solid: string;
|
||||
semi: string;
|
||||
pattern: string;
|
||||
note: {
|
||||
fill: string;
|
||||
text: string;
|
||||
};
|
||||
highlight: {
|
||||
srgb: string;
|
||||
p3: string;
|
||||
|
|