[feature] Labels for shapes (#462)
* Adds generic text label * Clean up text label / text util * labels for ellipse and triangles * Add arrow label * Update filesystem.ts * Double click bend to edit label, fix mask location * refactor arrowutil * fix arrow bindings * Rename text to label, add labelPoint * Fix arrow binding, styles on text labels, double click bounds edge to edit label * Update ArrowSession.ts * Update StyleMenu.tsx * set version
This commit is contained in:
parent
e48f0c1794
commit
d7a697647b
52 changed files with 1996 additions and 1216 deletions
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as React from 'react'
|
||||
import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
|
||||
import { TDShapeType, Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
|
||||
|
||||
declare const window: Window & { app: TldrawApp }
|
||||
|
||||
|
@ -12,6 +12,13 @@ export default function Develop(): JSX.Element {
|
|||
const handleMount = React.useCallback((app: TldrawApp) => {
|
||||
window.app = app
|
||||
rTldrawApp.current = app
|
||||
// app.reset()
|
||||
// app.createShapes({
|
||||
// id: 'box1',
|
||||
// type: TDShapeType.Rectangle,
|
||||
// point: [200, 200],
|
||||
// size: [200, 200],
|
||||
// })
|
||||
}, [])
|
||||
|
||||
const handleSignOut = React.useCallback(() => {
|
||||
|
|
|
@ -53,6 +53,7 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
|
|||
camera: { zoom },
|
||||
} = pageState
|
||||
|
||||
let _hideIndicators = hideIndicators
|
||||
let _hideCloneHandles = true
|
||||
let _isEditing = false
|
||||
|
||||
|
@ -63,9 +64,10 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
|
|||
if (selectedShapes.length === 1) {
|
||||
const shape = selectedShapes[0]
|
||||
_isEditing = editingId === shape.id
|
||||
if (_isEditing) _hideIndicators = true
|
||||
const utils = shapeUtils[shape.type] as TLShapeUtil<any, any>
|
||||
_hideCloneHandles = hideCloneHandles || !utils.showCloneHandles
|
||||
if (shape.handles !== undefined) {
|
||||
if (shape.handles !== undefined && !_isEditing) {
|
||||
shapeWithHandles = shape
|
||||
}
|
||||
}
|
||||
|
@ -76,7 +78,7 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
|
|||
{shapeTree.map((node) => (
|
||||
<ShapeNode key={node.shape.id} utils={shapeUtils} {...node} />
|
||||
))}
|
||||
{!hideIndicators &&
|
||||
{!_hideIndicators &&
|
||||
selectedShapes.map((shape) => (
|
||||
<ShapeIndicator
|
||||
key={'selected_' + shape.id}
|
||||
|
@ -86,7 +88,7 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
|
|||
isEditing={_isEditing}
|
||||
/>
|
||||
))}
|
||||
{!hideIndicators && hoveredId && hoveredId !== editingId && (
|
||||
{!_hideIndicators && hoveredId && hoveredId !== editingId && (
|
||||
<ShapeIndicator
|
||||
key={'hovered_' + hoveredId}
|
||||
shape={page.shapes[hoveredId]}
|
||||
|
|
|
@ -35,6 +35,7 @@ export const Shape = observer(function Shape<T extends TLShape, E extends Elemen
|
|||
utils={utils as any}
|
||||
meta={meta}
|
||||
events={events}
|
||||
bounds={bounds}
|
||||
onShapeChange={callbacks.onShapeChange}
|
||||
onShapeBlur={callbacks.onShapeBlur}
|
||||
{...rest}
|
||||
|
|
|
@ -200,7 +200,6 @@ const tlcss = css`
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
contain: layout style size;
|
||||
will-change: var(--tl-performance-all);
|
||||
}
|
||||
|
@ -233,7 +232,6 @@ const tlcss = css`
|
|||
}
|
||||
|
||||
.tl-stroke-hitarea {
|
||||
cursor: pointer;
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: calc(24px * var(--tl-scale));
|
||||
|
@ -243,7 +241,6 @@ const tlcss = css`
|
|||
}
|
||||
|
||||
.tl-fill-hitarea {
|
||||
cursor: pointer;
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
stroke-width: calc(24px * var(--tl-scale));
|
||||
|
|
|
@ -91,6 +91,7 @@ export interface TLComponentProps<T extends TLShape, E = any, M = any> {
|
|||
isSelected: boolean
|
||||
isGhost?: boolean
|
||||
isChildOfSelected?: boolean
|
||||
bounds: TLBounds
|
||||
meta: M
|
||||
onShapeChange?: TLShapeChangeHandler<T, any>
|
||||
onShapeBlur?: TLShapeBlurHandler<any>
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
ShapeStyles,
|
||||
FontStyle,
|
||||
AlignStyle,
|
||||
TDShapeType,
|
||||
} from '~types'
|
||||
import { styled } from '~styles'
|
||||
import { breakpoints } from '~components/breakpoints'
|
||||
|
@ -62,42 +63,61 @@ const ALIGN_ICONS = {
|
|||
|
||||
const themeSelector = (s: TDSnapshot) => (s.settings.isDarkMode ? 'dark' : 'light')
|
||||
|
||||
const showTextStylesSelector = (s: TDSnapshot) => {
|
||||
const optionsSelector = (s: TDSnapshot) => {
|
||||
const { activeTool, currentPageId: pageId } = s.appState
|
||||
const page = s.document.pages[pageId]
|
||||
switch (activeTool) {
|
||||
case 'select': {
|
||||
const page = s.document.pages[pageId]
|
||||
let hasText = false
|
||||
let hasLabel = false
|
||||
for (const id of s.document.pageStates[pageId].selectedIds) {
|
||||
if ('text' in page.shapes[id]) hasText = true
|
||||
if ('label' in page.shapes[id]) hasLabel = true
|
||||
}
|
||||
return hasText ? 'text' : hasLabel ? 'label' : ''
|
||||
}
|
||||
case TDShapeType.Text: {
|
||||
return 'text'
|
||||
}
|
||||
case TDShapeType.Rectangle: {
|
||||
return 'label'
|
||||
}
|
||||
case TDShapeType.Ellipse: {
|
||||
return 'label'
|
||||
}
|
||||
case TDShapeType.Triangle: {
|
||||
return 'label'
|
||||
}
|
||||
case TDShapeType.Arrow: {
|
||||
return 'label'
|
||||
}
|
||||
case TDShapeType.Line: {
|
||||
return 'label'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
activeTool === 'text' ||
|
||||
s.document.pageStates[pageId].selectedIds.some((id) => 'text' in page.shapes[id])
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
|
||||
const app = useTldrawApp()
|
||||
|
||||
const theme = app.useStore(themeSelector)
|
||||
const showTextStyles = app.useStore(showTextStylesSelector)
|
||||
|
||||
const options = app.useStore(optionsSelector)
|
||||
const currentStyle = app.useStore(currentStyleSelector)
|
||||
const selectedIds = app.useStore(selectedIdsSelector)
|
||||
|
||||
const [displayedStyle, setDisplayedStyle] = React.useState(currentStyle)
|
||||
const rDisplayedStyle = React.useRef(currentStyle)
|
||||
|
||||
React.useEffect(() => {
|
||||
const {
|
||||
appState: { currentStyle },
|
||||
page,
|
||||
selectedIds,
|
||||
} = app
|
||||
|
||||
let commonStyle = {} as ShapeStyles
|
||||
|
||||
if (selectedIds.length <= 0) {
|
||||
commonStyle = currentStyle
|
||||
} else {
|
||||
const overrides = new Set<string>([])
|
||||
|
||||
app.selectedIds
|
||||
.map((id) => page.shapes[id])
|
||||
.forEach((shape) => {
|
||||
|
@ -117,7 +137,6 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Until we can work out the correct logic for deciding whether or not to
|
||||
// update the selected style, do a string comparison. Yuck!
|
||||
if (JSON.stringify(commonStyle) !== JSON.stringify(rDisplayedStyle.current)) {
|
||||
|
@ -125,34 +144,27 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
|
|||
setDisplayedStyle(commonStyle)
|
||||
}
|
||||
}, [currentStyle, selectedIds])
|
||||
|
||||
const handleToggleFilled = React.useCallback((checked: boolean) => {
|
||||
app.style({ isFilled: checked })
|
||||
}, [])
|
||||
|
||||
const handleDashChange = React.useCallback((value: string) => {
|
||||
app.style({ dash: value as DashStyle })
|
||||
}, [])
|
||||
|
||||
const handleSizeChange = React.useCallback((value: string) => {
|
||||
app.style({ size: value as SizeStyle })
|
||||
}, [])
|
||||
|
||||
const handleFontChange = React.useCallback((value: string) => {
|
||||
app.style({ font: value as FontStyle })
|
||||
}, [])
|
||||
|
||||
const handleTextAlignChange = React.useCallback((value: string) => {
|
||||
app.style({ textAlign: value as AlignStyle })
|
||||
}, [])
|
||||
|
||||
const handleMenuOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
app.setMenuOpen(open)
|
||||
},
|
||||
[app]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root dir="ltr" onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
|
@ -237,7 +249,7 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
|
|||
))}
|
||||
</StyledGroup>
|
||||
</StyledRow>
|
||||
{showTextStyles && (
|
||||
{(options === 'text' || options === 'label') && (
|
||||
<>
|
||||
<Divider />
|
||||
<StyledRow>
|
||||
|
@ -256,26 +268,28 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
|
|||
))}
|
||||
</StyledGroup>
|
||||
</StyledRow>
|
||||
<StyledRow>
|
||||
Align
|
||||
<StyledGroup
|
||||
dir="ltr"
|
||||
value={displayedStyle.textAlign}
|
||||
onValueChange={handleTextAlignChange}
|
||||
>
|
||||
{Object.values(AlignStyle).map((style) => (
|
||||
<DMRadioItem
|
||||
key={style}
|
||||
isActive={style === displayedStyle.textAlign}
|
||||
value={style}
|
||||
onSelect={preventEvent}
|
||||
bp={breakpoints}
|
||||
>
|
||||
{ALIGN_ICONS[style]}
|
||||
</DMRadioItem>
|
||||
))}
|
||||
</StyledGroup>
|
||||
</StyledRow>
|
||||
{options === 'text' && (
|
||||
<StyledRow>
|
||||
Align
|
||||
<StyledGroup
|
||||
dir="ltr"
|
||||
value={displayedStyle.textAlign}
|
||||
onValueChange={handleTextAlignChange}
|
||||
>
|
||||
{Object.values(AlignStyle).map((style) => (
|
||||
<DMRadioItem
|
||||
key={style}
|
||||
isActive={style === displayedStyle.textAlign}
|
||||
value={style}
|
||||
onSelect={preventEvent}
|
||||
bp={breakpoints}
|
||||
>
|
||||
{ALIGN_ICONS[style]}
|
||||
</DMRadioItem>
|
||||
))}
|
||||
</StyledGroup>
|
||||
</StyledRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DMContent>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export const LETTER_SPACING = '-0.03em'
|
||||
export const GRID_SIZE = 8
|
||||
export const SVG_EXPORT_PADDING = 16
|
||||
export const BINDING_DISTANCE = 16
|
||||
|
@ -10,6 +11,7 @@ export const SLOW_SPEED = 10
|
|||
export const VERY_SLOW_SPEED = 2.5
|
||||
export const GHOSTED_OPACITY = 0.3
|
||||
export const DEAD_ZONE = 3
|
||||
export const LABEL_POINT = [0.5, 0.5]
|
||||
|
||||
import type { Easing } from '~types'
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ const styles = new Map<string, HTMLStyleElement>()
|
|||
|
||||
const UID = `Tldraw-fonts`
|
||||
const CSS = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro');
|
||||
`
|
||||
|
||||
export function useStylesheet() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { mockDocument, TldrawTestApp } from '~test'
|
||||
import { ArrowShape, ColorStyle, SessionType, TDShapeType } from '~types'
|
||||
import { ArrowShape, ColorStyle, RectangleShape, SessionType, TDShapeType } from '~types'
|
||||
import type { SelectTool } from './tools/SelectTool'
|
||||
|
||||
describe('TldrawTestApp', () => {
|
||||
|
|
|
@ -3364,7 +3364,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
|
||||
getShapeUtil = TLDR.getShapeUtil
|
||||
|
||||
static version = 15.2
|
||||
static version = 15.3
|
||||
|
||||
static defaultDocument: TDDocument = {
|
||||
id: 'doc',
|
||||
|
|
|
@ -20,7 +20,12 @@ exports[` 1`] = `
|
|||
\\"dash\\": \\"draw\\",
|
||||
\\"size\\": \\"medium\\",
|
||||
\\"color\\": \\"blue\\"
|
||||
}
|
||||
},
|
||||
\\"label\\": \\"\\",
|
||||
\\"labelPoint\\": [
|
||||
0.5,
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"rect2\\",
|
||||
|
@ -40,7 +45,12 @@ exports[` 1`] = `
|
|||
\\"dash\\": \\"draw\\",
|
||||
\\"size\\": \\"medium\\",
|
||||
\\"color\\": \\"blue\\"
|
||||
}
|
||||
},
|
||||
\\"label\\": \\"\\",
|
||||
\\"labelPoint\\": [
|
||||
0.5,
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"rect3\\",
|
||||
|
@ -60,7 +70,12 @@ exports[` 1`] = `
|
|||
\\"dash\\": \\"draw\\",
|
||||
\\"size\\": \\"medium\\",
|
||||
\\"color\\": \\"blue\\"
|
||||
}
|
||||
},
|
||||
\\"label\\": \\"\\",
|
||||
\\"labelPoint\\": [
|
||||
0.5,
|
||||
0.5
|
||||
]
|
||||
}
|
||||
]"
|
||||
`;
|
||||
|
@ -84,6 +99,11 @@ Array [
|
|||
"rect1": Object {
|
||||
"childIndex": 1,
|
||||
"id": "rect1",
|
||||
"label": "",
|
||||
"labelPoint": Array [
|
||||
0.5,
|
||||
0.5,
|
||||
],
|
||||
"name": "Rectangle",
|
||||
"parentId": "page1",
|
||||
"point": Array [
|
||||
|
@ -145,6 +165,11 @@ Array [
|
|||
"rect2": Object {
|
||||
"childIndex": 1,
|
||||
"id": "rect2",
|
||||
"label": "",
|
||||
"labelPoint": Array [
|
||||
0.5,
|
||||
0.5,
|
||||
],
|
||||
"name": "Rectangle",
|
||||
"parentId": "page1",
|
||||
"point": Array [
|
||||
|
|
|
@ -72,15 +72,27 @@ export function migrate(document: TDDocument, newVersion: number): TDDocument {
|
|||
document.assets = {}
|
||||
}
|
||||
|
||||
if (version < 15.2) {
|
||||
Object.values(document.pages).forEach((page) => {
|
||||
Object.values(page.shapes).forEach((shape) => {
|
||||
Object.values(document.pages).forEach((page) => {
|
||||
Object.values(page.shapes).forEach((shape) => {
|
||||
if (version < 15.2) {
|
||||
if (shape.type === TDShapeType.Image || shape.type === TDShapeType.Video) {
|
||||
shape.style.isFilled = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (version < 15.3) {
|
||||
if (
|
||||
shape.type === TDShapeType.Rectangle ||
|
||||
shape.type === TDShapeType.Triangle ||
|
||||
shape.type === TDShapeType.Ellipse ||
|
||||
shape.type === TDShapeType.Arrow
|
||||
) {
|
||||
shape.label = (shape as any).text || ''
|
||||
shape.labelPoint = [0.5, 0.5]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
Object.values(document.pageStates).forEach((pageState) => {
|
||||
|
|
|
@ -212,6 +212,8 @@ describe('When creating with an arrow session', () => {
|
|||
|
||||
it('Removes a binding when dragged away', () => {
|
||||
const app = new TldrawTestApp()
|
||||
.selectAll()
|
||||
.delete()
|
||||
.createShapes(
|
||||
{ type: TDShapeType.Rectangle, id: 'rect1', point: [200, 200], size: [200, 200] },
|
||||
{ type: TDShapeType.Rectangle, id: 'rect2', point: [400, 200], size: [200, 200] },
|
||||
|
@ -260,7 +262,7 @@ describe('When drawing an arrow', () => {
|
|||
expect(app.shapes.length).toBe(1)
|
||||
})
|
||||
|
||||
it('creates a short arrow if at least one handle is bound to a shape', () => {
|
||||
it('create a short arrow if at least one handle is bound to a shape', () => {
|
||||
const app = new TldrawTestApp()
|
||||
.createShapes({
|
||||
type: TDShapeType.Rectangle,
|
||||
|
@ -292,7 +294,7 @@ describe('When drawing an arrow', () => {
|
|||
expect(app.shapes.length).toBe(1)
|
||||
})
|
||||
|
||||
it('creates a short arrow if start handle is bound', () => {
|
||||
it('create a short arrow if start handle is bound', () => {
|
||||
const app = new TldrawTestApp()
|
||||
.createShapes({
|
||||
type: TDShapeType.Rectangle,
|
||||
|
|
|
@ -14,7 +14,7 @@ import { TLDR } from '~state/TLDR'
|
|||
import { shapeUtils } from '~state/shapes'
|
||||
import { BaseSession } from '../BaseSession'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
import { TLPerformanceMode, Utils } from '@tldraw/core'
|
||||
import { Utils } from '@tldraw/core'
|
||||
|
||||
export class ArrowSession extends BaseSession {
|
||||
type = SessionType.Arrow
|
||||
|
@ -40,9 +40,13 @@ export class ArrowSession extends BaseSession {
|
|||
this.bindableShapeIds = TLDR.getBindableShapeIds(app.state).filter(
|
||||
(id) => !(id === this.initialShape.id || id === this.initialShape.parentId)
|
||||
)
|
||||
|
||||
const oppositeHandleBindingId =
|
||||
this.initialShape.handles[handleId === 'start' ? 'end' : 'start']?.bindingId
|
||||
if (oppositeHandleBindingId) {
|
||||
const oppositeToId = page.bindings[oppositeHandleBindingId].toId
|
||||
this.bindableShapeIds = this.bindableShapeIds.filter((id) => id !== oppositeToId)
|
||||
}
|
||||
const { originPoint } = this.app
|
||||
|
||||
if (this.isCreate) {
|
||||
// If we're creating a new shape, should we bind its first point?
|
||||
// The method may return undefined, which is correct if there is no
|
||||
|
@ -52,11 +56,13 @@ export class ArrowSession extends BaseSession {
|
|||
.find((shape) =>
|
||||
Utils.pointInBounds(originPoint, TLDR.getShapeUtil(shape).getBounds(shape))
|
||||
)?.id
|
||||
if (this.startBindingShapeId) {
|
||||
this.bindableShapeIds.splice(this.bindableShapeIds.indexOf(this.startBindingShapeId), 1)
|
||||
}
|
||||
} else {
|
||||
// If we're editing an existing line, is there a binding already
|
||||
// for the dragging handle?
|
||||
const initialBindingId = this.initialShape.handles[this.handleId].bindingId
|
||||
|
||||
if (initialBindingId) {
|
||||
this.initialBinding = page.bindings[initialBindingId]
|
||||
} else {
|
||||
|
@ -78,22 +84,15 @@ export class ArrowSession extends BaseSession {
|
|||
currentGrid,
|
||||
settings: { showGrid },
|
||||
} = this.app
|
||||
|
||||
const shape = this.app.getShape<ArrowShape>(initialShape.id)
|
||||
|
||||
if (shape.isLocked) return
|
||||
|
||||
const handles = shape.handles
|
||||
|
||||
const handleId = this.handleId as keyof typeof handles
|
||||
|
||||
// If the handle can bind, then we need to search bindable shapes for
|
||||
// a binding.
|
||||
if (!handles[handleId].canBind) return
|
||||
|
||||
// First update the handle's next point
|
||||
let delta = Vec.sub(currentPoint, handles[handleId].point)
|
||||
|
||||
if (shiftKey) {
|
||||
const A = handles[handleId === 'start' ? 'end' : 'start'].point
|
||||
const B = handles[handleId].point
|
||||
|
@ -102,25 +101,18 @@ export class ArrowSession extends BaseSession {
|
|||
const adjusted = Vec.rotWith(C, A, Utils.snapAngleToSegments(angle, 24) - angle)
|
||||
delta = Vec.add(delta, Vec.sub(adjusted, C))
|
||||
}
|
||||
|
||||
const nextPoint = Vec.sub(Vec.add(handles[handleId].point, delta), shape.point)
|
||||
|
||||
const handle = {
|
||||
...handles[handleId],
|
||||
point: showGrid ? Vec.snap(nextPoint, currentGrid) : Vec.toFixed(nextPoint),
|
||||
|
||||
bindingId: undefined,
|
||||
}
|
||||
|
||||
const utils = shapeUtils[TDShapeType.Arrow]
|
||||
|
||||
const change = utils.onHandleChange?.(shape, {
|
||||
[handleId]: handle,
|
||||
})
|
||||
|
||||
// If the handle changed produced no change, bail here
|
||||
if (!change) return
|
||||
|
||||
// If nothing changes, we want these to be the same object reference as
|
||||
// before. If it does change, we'll redefine this later on. And if we've
|
||||
// made it this far, the shape should be a new object reference that
|
||||
|
@ -129,29 +121,23 @@ export class ArrowSession extends BaseSession {
|
|||
shape: Utils.deepMerge(shape, change),
|
||||
bindings: {},
|
||||
}
|
||||
|
||||
if (this.initialBinding) {
|
||||
next.bindings[this.initialBinding.id] = undefined
|
||||
}
|
||||
|
||||
// START BINDING
|
||||
|
||||
// If we have a start binding shape id, the recompute the binding
|
||||
// point based on the current end handle position
|
||||
if (this.startBindingShapeId) {
|
||||
let startBinding: ArrowBinding | undefined
|
||||
|
||||
const target = this.app.page.shapes[this.startBindingShapeId]
|
||||
|
||||
const targetUtils = TLDR.getShapeUtil(target)
|
||||
|
||||
if (!metaKey) {
|
||||
const center = targetUtils.getCenter(target)
|
||||
const handle = next.shape.handles.start
|
||||
const rayPoint = Vec.add(handle.point, next.shape.point)
|
||||
const rayOrigin = center
|
||||
const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
|
||||
|
||||
const isInsideShape = targetUtils.hitTestPoint(target, currentPoint)
|
||||
startBinding = this.findBindingPoint(
|
||||
shape,
|
||||
target,
|
||||
|
@ -160,15 +146,12 @@ export class ArrowSession extends BaseSession {
|
|||
center,
|
||||
rayOrigin,
|
||||
rayDirection,
|
||||
false
|
||||
isInsideShape
|
||||
)
|
||||
}
|
||||
|
||||
if (startBinding) {
|
||||
this.didBind = true
|
||||
|
||||
next.bindings[this.newStartBindingId] = startBinding
|
||||
|
||||
next.shape.handles = {
|
||||
...next.shape.handles,
|
||||
start: {
|
||||
|
@ -176,11 +159,8 @@ export class ArrowSession extends BaseSession {
|
|||
bindingId: startBinding.id,
|
||||
},
|
||||
}
|
||||
|
||||
const target = this.app.page.shapes[this.startBindingShapeId]
|
||||
|
||||
const targetUtils = TLDR.getShapeUtil(target)
|
||||
|
||||
const arrowChange = TLDR.getShapeUtil<ArrowShape>(next.shape.type).onBindingChange?.(
|
||||
next.shape,
|
||||
startBinding,
|
||||
|
@ -189,17 +169,12 @@ export class ArrowSession extends BaseSession {
|
|||
targetUtils.getExpandedBounds(target),
|
||||
targetUtils.getCenter(target)
|
||||
)
|
||||
|
||||
if (arrowChange) {
|
||||
Object.assign(next.shape, arrowChange)
|
||||
}
|
||||
if (arrowChange) Object.assign(next.shape, arrowChange)
|
||||
} else {
|
||||
this.didBind = this.didBind || false
|
||||
|
||||
if (this.app.page.bindings[this.newStartBindingId]) {
|
||||
next.bindings[this.newStartBindingId] = undefined
|
||||
}
|
||||
|
||||
if (shape.handles.start.bindingId === this.newStartBindingId) {
|
||||
next.shape.handles = {
|
||||
...next.shape.handles,
|
||||
|
@ -211,20 +186,15 @@ export class ArrowSession extends BaseSession {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DRAGGED POINT BINDING
|
||||
|
||||
let draggedBinding: ArrowBinding | undefined
|
||||
|
||||
if (!metaKey) {
|
||||
const handle = next.shape.handles[this.handleId]
|
||||
const oppositeHandle = next.shape.handles[this.handleId === 'start' ? 'end' : 'start']
|
||||
const rayOrigin = Vec.add(oppositeHandle.point, next.shape.point)
|
||||
const rayPoint = Vec.add(handle.point, next.shape.point)
|
||||
const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
|
||||
|
||||
const targets = this.bindableShapeIds.map((id) => this.app.page.shapes[id])
|
||||
|
||||
for (const target of targets) {
|
||||
draggedBinding = this.findBindingPoint(
|
||||
shape,
|
||||
|
@ -236,16 +206,12 @@ export class ArrowSession extends BaseSession {
|
|||
rayDirection,
|
||||
altKey
|
||||
)
|
||||
|
||||
if (draggedBinding) break
|
||||
}
|
||||
}
|
||||
|
||||
if (draggedBinding) {
|
||||
this.didBind = true
|
||||
|
||||
next.bindings[this.draggedBindingId] = draggedBinding
|
||||
|
||||
next.shape.handles = {
|
||||
...next.shape.handles,
|
||||
[this.handleId]: {
|
||||
|
@ -253,13 +219,9 @@ export class ArrowSession extends BaseSession {
|
|||
bindingId: this.draggedBindingId,
|
||||
},
|
||||
}
|
||||
|
||||
const target = this.app.page.shapes[draggedBinding.toId]
|
||||
|
||||
const targetUtils = TLDR.getShapeUtil(target)
|
||||
|
||||
const utils = shapeUtils[TDShapeType.Arrow]
|
||||
|
||||
const arrowChange = utils.onBindingChange(
|
||||
next.shape,
|
||||
draggedBinding,
|
||||
|
@ -268,21 +230,17 @@ export class ArrowSession extends BaseSession {
|
|||
targetUtils.getExpandedBounds(target),
|
||||
targetUtils.getCenter(target)
|
||||
)
|
||||
|
||||
if (arrowChange) {
|
||||
Object.assign(next.shape, arrowChange)
|
||||
}
|
||||
} else {
|
||||
this.didBind = this.didBind || false
|
||||
|
||||
const currentBindingId = shape.handles[this.handleId].bindingId
|
||||
|
||||
if (currentBindingId) {
|
||||
next.bindings = {
|
||||
...next.bindings,
|
||||
[currentBindingId]: undefined,
|
||||
}
|
||||
|
||||
next.shape.handles = {
|
||||
...next.shape.handles,
|
||||
[this.handleId]: {
|
||||
|
@ -292,7 +250,6 @@ export class ArrowSession extends BaseSession {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
document: {
|
||||
pages: {
|
||||
|
@ -315,12 +272,18 @@ export class ArrowSession extends BaseSession {
|
|||
cancel = (): TldrawPatch | undefined => {
|
||||
const { initialShape, initialBinding, newStartBindingId, draggedBindingId } = this
|
||||
|
||||
const currentShape = TLDR.onSessionComplete(this.app.page.shapes[initialShape.id]) as ArrowShape
|
||||
|
||||
const isDeleting =
|
||||
this.isCreate ||
|
||||
Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) < 4
|
||||
|
||||
const afterBindings: Record<string, TDBinding | undefined> = {}
|
||||
|
||||
afterBindings[draggedBindingId] = undefined
|
||||
|
||||
if (initialBinding) {
|
||||
afterBindings[initialBinding.id] = initialBinding
|
||||
afterBindings[initialBinding.id] = isDeleting ? undefined : initialBinding
|
||||
}
|
||||
|
||||
if (newStartBindingId) {
|
||||
|
@ -332,14 +295,14 @@ export class ArrowSession extends BaseSession {
|
|||
pages: {
|
||||
[this.app.currentPageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: this.isCreate ? undefined : initialShape,
|
||||
[initialShape.id]: isDeleting ? undefined : initialShape,
|
||||
},
|
||||
bindings: afterBindings,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[this.app.currentPageId]: {
|
||||
selectedIds: this.isCreate ? [] : [initialShape.id],
|
||||
selectedIds: isDeleting ? [] : [initialShape.id],
|
||||
bindingId: undefined,
|
||||
hoveredId: undefined,
|
||||
editingId: undefined,
|
||||
|
@ -351,36 +314,24 @@ export class ArrowSession extends BaseSession {
|
|||
|
||||
complete = (): TldrawPatch | TldrawCommand | undefined => {
|
||||
const { initialShape, initialBinding, newStartBindingId, startBindingShapeId, handleId } = this
|
||||
|
||||
const currentShape = TLDR.onSessionComplete(this.app.page.shapes[initialShape.id]) as ArrowShape
|
||||
const currentBindingId = currentShape.handles[handleId].bindingId
|
||||
|
||||
if (
|
||||
!(currentBindingId || initialBinding) &&
|
||||
Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) < 4
|
||||
) {
|
||||
return this.cancel()
|
||||
}
|
||||
|
||||
const length = Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point)
|
||||
if (!(currentBindingId || initialBinding) && length < 4) return this.cancel()
|
||||
const beforeBindings: Partial<Record<string, TDBinding>> = {}
|
||||
|
||||
const afterBindings: Partial<Record<string, TDBinding>> = {}
|
||||
|
||||
if (initialBinding) {
|
||||
beforeBindings[initialBinding.id] = this.isCreate ? undefined : initialBinding
|
||||
afterBindings[initialBinding.id] = undefined
|
||||
}
|
||||
|
||||
if (currentBindingId) {
|
||||
beforeBindings[currentBindingId] = undefined
|
||||
afterBindings[currentBindingId] = this.app.page.bindings[currentBindingId]
|
||||
}
|
||||
|
||||
if (startBindingShapeId) {
|
||||
beforeBindings[newStartBindingId] = undefined
|
||||
afterBindings[newStartBindingId] = this.app.page.bindings[newStartBindingId]
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'arrow',
|
||||
before: {
|
||||
|
|
|
@ -48,7 +48,6 @@ export class HandleSession extends BaseSession {
|
|||
}
|
||||
|
||||
// First update the handle's next point
|
||||
|
||||
const change = TLDR.getShapeUtil(shape).onHandleChange?.(
|
||||
shape,
|
||||
{
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { mockDocument, TldrawTestApp } from '~test'
|
||||
import { SessionType, TDShapeType, TDStatus } from '~types'
|
||||
|
||||
describe('Translate label session', () => {
|
||||
it.todo('begins, updateSession')
|
||||
it.todo('cancels session')
|
||||
})
|
|
@ -0,0 +1,112 @@
|
|||
import { Vec } from '@tldraw/vec'
|
||||
import {
|
||||
SessionType,
|
||||
ShapesWithProp,
|
||||
TldrawCommand,
|
||||
TldrawPatch,
|
||||
TDStatus,
|
||||
RectangleShape,
|
||||
TriangleShape,
|
||||
EllipseShape,
|
||||
ArrowShape,
|
||||
} from '~types'
|
||||
import { TLDR } from '~state/TLDR'
|
||||
import { BaseSession } from '../BaseSession'
|
||||
import type { TldrawApp } from '../../internal'
|
||||
import type { TLBounds } from '@tldraw/core'
|
||||
|
||||
export class TranslateLabelSession extends BaseSession {
|
||||
type = SessionType.Handle
|
||||
performanceMode = undefined
|
||||
status = TDStatus.TranslatingHandle
|
||||
initialShape: RectangleShape | TriangleShape | EllipseShape | ArrowShape
|
||||
initialShapeBounds: TLBounds
|
||||
|
||||
constructor(app: TldrawApp, shapeId: string) {
|
||||
super(app)
|
||||
this.initialShape = this.app.getShape(shapeId)
|
||||
this.initialShapeBounds = this.app.getShapeBounds(shapeId)
|
||||
}
|
||||
|
||||
start = (): TldrawPatch | undefined => void null
|
||||
|
||||
update = (): TldrawPatch | undefined => {
|
||||
const {
|
||||
initialShapeBounds,
|
||||
app: { currentPageId, currentPoint },
|
||||
} = this
|
||||
|
||||
const newHandlePoint = [
|
||||
Math.max(0, Math.min(1, currentPoint[0] / initialShapeBounds.width)),
|
||||
Math.max(0, Math.min(1, currentPoint[1] / initialShapeBounds.height)),
|
||||
]
|
||||
|
||||
// First update the handle's next point
|
||||
const change = {
|
||||
handlePoint: newHandlePoint,
|
||||
} as Partial<typeof this.initialShape>
|
||||
|
||||
return {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: {
|
||||
[this.initialShape.id]: change,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cancel = (): TldrawPatch | undefined => {
|
||||
const {
|
||||
initialShape,
|
||||
app: { currentPageId },
|
||||
} = this
|
||||
|
||||
return {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: initialShape,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
complete = (): TldrawPatch | TldrawCommand | undefined => {
|
||||
const {
|
||||
initialShape,
|
||||
app: { currentPageId },
|
||||
} = this
|
||||
|
||||
return {
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: initialShape,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: {
|
||||
[initialShape.id]: TLDR.onSessionComplete(this.app.getShape(this.initialShape.id)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './TranslateLabelSession'
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react'
|
||||
import { Utils, TLBounds, SVGContainer } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { defaultStyle, getShapeStyle } from '../shared/shape-styles'
|
||||
import { defaultStyle } from '../shared/shape-styles'
|
||||
import {
|
||||
ArrowShape,
|
||||
TransformInfo,
|
||||
|
@ -22,30 +22,32 @@ import {
|
|||
intersectRayEllipse,
|
||||
intersectRayLineSegment,
|
||||
} from '@tldraw/intersect'
|
||||
import { BINDING_DISTANCE, EASINGS, GHOSTED_OPACITY } from '~constants'
|
||||
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
|
||||
import {
|
||||
getArcLength,
|
||||
getArcPoints,
|
||||
getArrowArc,
|
||||
getArrowArcPath,
|
||||
getArrowPath,
|
||||
getBendPoint,
|
||||
getCtp,
|
||||
getCurvedArrowHeadPoints,
|
||||
getStraightArrowHeadPoints,
|
||||
isAngleBetween,
|
||||
renderCurvedFreehandArrowShaft,
|
||||
renderFreehandArrowShaft,
|
||||
} from './arrowHelpers'
|
||||
import { getTrianglePoints } from '../TriangleUtil'
|
||||
import { getTrianglePoints } from '../TriangleUtil/triangleHelpers'
|
||||
import { styled } from '~styles'
|
||||
import { TextLabel, getFontStyle } from '../shared'
|
||||
import { getTextLabelSize } from '../shared/getTextSize'
|
||||
import { StraightArrow } from './components/StraightArrow'
|
||||
import { CurvedArrow } from './components/CurvedArrow.tsx'
|
||||
|
||||
type T = ArrowShape
|
||||
type E = SVGSVGElement
|
||||
type E = HTMLDivElement
|
||||
|
||||
export class ArrowUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Arrow as const
|
||||
|
||||
hideBounds = true
|
||||
|
||||
canEdit = true
|
||||
|
||||
pathCache = new WeakMap<T, string>()
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
|
@ -88,194 +90,145 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
isFilled: false,
|
||||
...props.style,
|
||||
},
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
...props,
|
||||
}
|
||||
}
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(({ shape, isGhost, meta, events }, ref) => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
decorations = {},
|
||||
style,
|
||||
} = shape
|
||||
|
||||
const isDraw = style.dash === DashStyle.Draw
|
||||
|
||||
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
|
||||
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
|
||||
const { strokeWidth } = styles
|
||||
|
||||
const arrowDist = Vec.dist(start.point, end.point)
|
||||
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
|
||||
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
|
||||
let shaftPath: JSX.Element | null
|
||||
let startArrowHead: { left: number[]; right: number[] } | undefined
|
||||
let endArrowHead: { left: number[]; right: number[] } | undefined
|
||||
|
||||
const getRandom = Utils.rng(shape.id)
|
||||
|
||||
const easing = EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic']
|
||||
|
||||
if (isStraightLine) {
|
||||
const path = isDraw
|
||||
? renderFreehandArrowShaft(shape)
|
||||
: 'M' + Vec.toFixed(start.point) + 'L' + Vec.toFixed(end.point)
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
arrowDist,
|
||||
strokeWidth * 1.618,
|
||||
shape.style.dash,
|
||||
2,
|
||||
false
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, isEditing, isGhost, meta, events, onShapeChange, onShapeBlur }, ref) => {
|
||||
const {
|
||||
id,
|
||||
label = '',
|
||||
handles: { start, bend, end },
|
||||
decorations = {},
|
||||
style,
|
||||
} = shape
|
||||
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
|
||||
const font = getFontStyle(style)
|
||||
const labelSize = label || isEditing ? getTextLabelSize(label, font) : [0, 0]
|
||||
const bounds = this.getBounds(shape)
|
||||
const dist = React.useMemo(() => {
|
||||
const { start, bend, end } = shape.handles
|
||||
if (isStraightLine) return Vec.dist(start.point, end.point)
|
||||
const circle = getCtp(start.point, bend.point, end.point)
|
||||
const center = circle.slice(0, 2)
|
||||
const radius = circle[2]
|
||||
const length = getArcLength(center, radius, start.point, end.point)
|
||||
return Math.abs(length)
|
||||
}, [shape.handles])
|
||||
const scale = Math.max(
|
||||
0.5,
|
||||
Math.min(1, Math.max(dist / (labelSize[1] + 128), dist / (labelSize[0] + 128)))
|
||||
)
|
||||
|
||||
if (decorations.start) {
|
||||
startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength)
|
||||
}
|
||||
|
||||
if (decorations.end) {
|
||||
endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength)
|
||||
}
|
||||
|
||||
// Straight arrow path
|
||||
shaftPath =
|
||||
arrowDist > 2 ? (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? sw / 2 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
} else {
|
||||
const circle = getCtp(shape)
|
||||
|
||||
const { center, radius, length } = getArrowArc(shape)
|
||||
|
||||
const path = isDraw
|
||||
? renderCurvedFreehandArrowShaft(shape, circle, length, easing)
|
||||
: getArrowArcPath(start, end, circle, shape.bend)
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Math.abs(length),
|
||||
sw,
|
||||
shape.style.dash,
|
||||
2,
|
||||
false
|
||||
const offset = React.useMemo(() => {
|
||||
const bounds = this.getBounds(shape)
|
||||
const offset = Vec.sub(shape.handles.bend.point, [bounds.width / 2, bounds.height / 2])
|
||||
return offset
|
||||
}, [shape, scale])
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => {
|
||||
onShapeChange?.({ id, label })
|
||||
},
|
||||
[onShapeChange]
|
||||
)
|
||||
|
||||
if (decorations.start) {
|
||||
startArrowHead = getCurvedArrowHeadPoints(
|
||||
start.point,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length < 0
|
||||
)
|
||||
}
|
||||
|
||||
if (decorations.end) {
|
||||
endArrowHead = getCurvedArrowHeadPoints(
|
||||
end.point,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length >= 0
|
||||
)
|
||||
}
|
||||
|
||||
// Curved arrow path
|
||||
shaftPath = (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={isDraw ? styles.stroke : 'none'}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? 0 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
const Component = isStraightLine ? StraightArrow : CurvedArrow
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
offsetX={offset[0]}
|
||||
offsetY={offset[1]}
|
||||
scale={scale}
|
||||
/>
|
||||
</>
|
||||
<SVGContainer id={shape.id + '_svg'}>
|
||||
<defs>
|
||||
<mask id={shape.id + '_clip'}>
|
||||
<rect
|
||||
x={-100}
|
||||
y={-100}
|
||||
width={bounds.width + 200}
|
||||
height={bounds.height + 200}
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x={bounds.width / 2 - (labelSize[0] / 2) * scale + offset[0]}
|
||||
y={bounds.height / 2 - (labelSize[1] / 2) * scale + offset[1]}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="black"
|
||||
opacity={Math.max(scale, 0.8)}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g
|
||||
pointerEvents="none"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
mask={label || isEditing ? `url(#${shape.id}_clip)` : ``}
|
||||
>
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
start={start.point}
|
||||
end={end.point}
|
||||
bend={bend.point}
|
||||
arrowBend={shape.bend}
|
||||
decorationStart={decorations?.start}
|
||||
decorationEnd={decorations?.end}
|
||||
isDraw={style.dash === DashStyle.Draw}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<g pointerEvents="none" opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
{shaftPath}
|
||||
{startArrowHead && (
|
||||
<>
|
||||
<path
|
||||
className="tl-stroke-hitarea"
|
||||
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
|
||||
/>
|
||||
<path
|
||||
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
|
||||
fill="none"
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<>
|
||||
<path
|
||||
className="tl-stroke-hitarea"
|
||||
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
|
||||
/>
|
||||
<path
|
||||
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
|
||||
fill="none"
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
</SVGContainer>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<ArrowShape>(({ shape }) => {
|
||||
return <path d={getArrowPath(shape)} />
|
||||
const {
|
||||
style,
|
||||
decorations,
|
||||
handles: { start, bend, end },
|
||||
} = shape
|
||||
return (
|
||||
<path
|
||||
d={getArrowPath(
|
||||
style,
|
||||
start.point,
|
||||
bend.point,
|
||||
end.point,
|
||||
decorations?.start,
|
||||
decorations?.end
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
return Utils.getBoundsFromPoints(getArcPoints(shape))
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape
|
||||
return Utils.getBoundsFromPoints(getArcPoints(start.point, bend.point, end.point))
|
||||
})
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point)
|
||||
}
|
||||
|
||||
getRotatedBounds = (shape: T) => {
|
||||
let points = getArcPoints(shape)
|
||||
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape
|
||||
let points = getArcPoints(start.point, bend.point, end.point)
|
||||
const { minX, minY, maxX, maxY } = Utils.getBoundsFromPoints(points)
|
||||
|
||||
if (shape.rotation !== 0) {
|
||||
points = points.map((pt) =>
|
||||
Vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], shape.rotation || 0)
|
||||
|
@ -294,56 +247,52 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
return (
|
||||
next.decorations !== prev.decorations ||
|
||||
next.handles !== prev.handles ||
|
||||
next.style !== prev.style
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
)
|
||||
}
|
||||
|
||||
hitTestPoint = (shape: T, point: number[]): boolean => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape
|
||||
const pt = Vec.sub(point, shape.point)
|
||||
const points = getArcPoints(shape)
|
||||
|
||||
const points = getArcPoints(start.point, bend.point, end.point)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Vec.distanceToLineSegment(points[i - 1], points[i], pt) < 1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
hitTestLineSegment = (shape: T, A: number[], B: number[]): boolean => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape
|
||||
const ptA = Vec.sub(A, shape.point)
|
||||
const ptB = Vec.sub(B, shape.point)
|
||||
|
||||
const points = getArcPoints(shape)
|
||||
|
||||
const points = getArcPoints(start.point, bend.point, end.point)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (intersectLineSegmentLineSegment(points[i - 1], points[i], ptA, ptB).didIntersect) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
hitTestBounds = (shape: T, bounds: TLBounds) => {
|
||||
const { start, end, bend } = shape.handles
|
||||
|
||||
const sp = Vec.add(shape.point, start.point)
|
||||
|
||||
const ep = Vec.add(shape.point, end.point)
|
||||
|
||||
if (Utils.pointInBounds(sp, bounds) || Utils.pointInBounds(ep, bounds)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Vec.isEqual(Vec.med(start.point, end.point), bend.point)) {
|
||||
return intersectLineSegmentBounds(sp, ep, bounds).length > 0
|
||||
} else {
|
||||
const [cx, cy, r] = getCtp(shape)
|
||||
|
||||
const [cx, cy, r] = getCtp(start.point, bend.point, end.point)
|
||||
const cp = Vec.add(shape.point, [cx, cy])
|
||||
|
||||
return intersectArcBounds(cp, r, sp, ep, bounds).length > 0
|
||||
}
|
||||
}
|
||||
|
@ -354,18 +303,12 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
{ initialShape, scaleX, scaleY }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const initialShapeBounds = this.getBounds(initialShape)
|
||||
|
||||
const handles: (keyof T['handles'])[] = ['start', 'end']
|
||||
|
||||
const nextHandles = { ...initialShape.handles }
|
||||
|
||||
handles.forEach((handle) => {
|
||||
const [x, y] = nextHandles[handle].point
|
||||
|
||||
const nw = x / initialShapeBounds.width
|
||||
|
||||
const nh = y / initialShapeBounds.height
|
||||
|
||||
nextHandles[handle] = {
|
||||
...nextHandles[handle],
|
||||
point: [
|
||||
|
@ -374,24 +317,16 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
],
|
||||
}
|
||||
})
|
||||
|
||||
const { start, bend, end } = nextHandles
|
||||
|
||||
const dist = Vec.dist(start.point, end.point)
|
||||
|
||||
const midPoint = Vec.med(start.point, end.point)
|
||||
|
||||
const bendDist = (dist / 2) * initialShape.bend
|
||||
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point))
|
||||
|
||||
const point = Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist))
|
||||
|
||||
nextHandles['bend'] = {
|
||||
...bend,
|
||||
point: Vec.toFixed(Math.abs(bendDist) < 10 ? midPoint : point),
|
||||
}
|
||||
|
||||
return {
|
||||
point: Vec.toFixed([bounds.minX, bounds.minY]),
|
||||
handles: nextHandles,
|
||||
|
@ -442,7 +377,6 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
center: number[]
|
||||
): Partial<T> | void => {
|
||||
const handle = shape.handles[binding.handleId as keyof ArrowShape['handles']]
|
||||
|
||||
let handlePoint = Vec.sub(
|
||||
Vec.add(
|
||||
[expandedBounds.minX, expandedBounds.minY],
|
||||
|
@ -453,19 +387,15 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
),
|
||||
shape.point
|
||||
)
|
||||
|
||||
if (binding.distance) {
|
||||
const intersectBounds = Utils.expandBounds(targetBounds, binding.distance)
|
||||
|
||||
// The direction vector starts from the arrow's opposite handle
|
||||
const origin = Vec.add(
|
||||
shape.point,
|
||||
shape.handles[handle.id === 'start' ? 'end' : 'start'].point
|
||||
)
|
||||
|
||||
// And passes through the dragging handle
|
||||
const direction = Vec.uni(Vec.sub(Vec.add(handlePoint, shape.point), origin))
|
||||
|
||||
if (target.type === TDShapeType.Ellipse) {
|
||||
const hits = intersectRayEllipse(
|
||||
origin,
|
||||
|
@ -475,45 +405,32 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
(target as EllipseShape).radius[1] + binding.distance,
|
||||
target.rotation || 0
|
||||
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
|
||||
|
||||
if (hits[0]) {
|
||||
handlePoint = Vec.sub(hits[0], shape.point)
|
||||
}
|
||||
if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point)
|
||||
} else if (target.type === TDShapeType.Triangle) {
|
||||
const points = getTrianglePoints(target, BINDING_DISTANCE, target.rotation).map((pt) =>
|
||||
const points = getTrianglePoints(target.size, BINDING_DISTANCE, target.rotation).map((pt) =>
|
||||
Vec.add(pt, target.point)
|
||||
)
|
||||
|
||||
const segments = Utils.pointsToLineSegments(points, true)
|
||||
|
||||
const hits = segments
|
||||
.map((segment) => intersectRayLineSegment(origin, direction, segment[0], segment[1]))
|
||||
.filter((intersection) => intersection.didIntersect)
|
||||
.flatMap((intersection) => intersection.points)
|
||||
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
|
||||
|
||||
if (hits[0]) {
|
||||
handlePoint = Vec.sub(hits[0], shape.point)
|
||||
}
|
||||
if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point)
|
||||
} else {
|
||||
let hits = intersectRayBounds(origin, direction, intersectBounds, target.rotation)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
|
||||
|
||||
if (hits.length < 2) {
|
||||
hits = intersectRayBounds(origin, Vec.neg(direction), intersectBounds)
|
||||
.filter((int) => int.didIntersect)
|
||||
.map((int) => int.points[0])
|
||||
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
|
||||
}
|
||||
|
||||
if (hits[0]) {
|
||||
handlePoint = Vec.sub(hits[0], shape.point)
|
||||
}
|
||||
if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point)
|
||||
}
|
||||
}
|
||||
|
||||
return this.onHandleChange(shape, {
|
||||
[handle.id]: {
|
||||
...handle,
|
||||
|
@ -525,7 +442,6 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
onHandleChange = (shape: T, handles: Partial<T['handles']>): Partial<T> | void => {
|
||||
let nextHandles = Utils.deepMerge<ArrowShape['handles']>(shape.handles, handles)
|
||||
let nextBend = shape.bend
|
||||
|
||||
nextHandles = {
|
||||
...nextHandles,
|
||||
start: {
|
||||
|
@ -537,38 +453,27 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
point: Vec.toFixed(nextHandles.end.point),
|
||||
},
|
||||
}
|
||||
|
||||
// If the user is moving the bend handle, we want to move the bend point
|
||||
if ('bend' in handles) {
|
||||
const { start, end, bend } = nextHandles
|
||||
|
||||
const distance = Vec.dist(start.point, end.point)
|
||||
|
||||
const midPoint = Vec.med(start.point, end.point)
|
||||
|
||||
const angle = Vec.angle(start.point, end.point)
|
||||
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point))
|
||||
|
||||
// Create a line segment perendicular to the line between the start and end points
|
||||
const ap = Vec.add(midPoint, Vec.mul(Vec.per(u), distance / 2))
|
||||
const bp = Vec.sub(midPoint, Vec.mul(Vec.per(u), distance / 2))
|
||||
|
||||
const bendPoint = Vec.nearestPointOnLineSegment(ap, bp, bend.point, true)
|
||||
|
||||
// Find the distance between the midpoint and the nearest point on the
|
||||
// line segment to the bend handle's dragged point
|
||||
const bendDist = Vec.dist(midPoint, bendPoint)
|
||||
|
||||
// The shape's "bend" is the ratio of the bend to the distance between
|
||||
// the start and end points. If the bend is below a certain amount, the
|
||||
// bend should be zero.
|
||||
nextBend = Utils.clamp(bendDist / (distance / 2), -0.99, 0.99)
|
||||
|
||||
// If the point is to the left of the line segment, we make the bend
|
||||
// negative, otherwise it's positive.
|
||||
const angleToBend = Vec.angle(start.point, bendPoint)
|
||||
|
||||
// If resulting bend is low enough that the handle will snap to center,
|
||||
// then also snap the bend to center
|
||||
if (Vec.isEqual(midPoint, getBendPoint(nextHandles, nextBend))) {
|
||||
|
@ -578,7 +483,6 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
nextBend *= -1
|
||||
}
|
||||
}
|
||||
|
||||
const nextShape = {
|
||||
point: shape.point,
|
||||
bend: nextBend,
|
||||
|
@ -590,24 +494,19 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Zero out the handles to prevent handles with negative points. If a handle's x or y
|
||||
// is below zero, we need to move the shape left or up to make it zero.
|
||||
|
||||
const topLeft = shape.point
|
||||
|
||||
const nextBounds = this.getBounds({ ...nextShape } as ArrowShape)
|
||||
|
||||
const offset = Vec.sub([nextBounds.minX, nextBounds.minY], topLeft)
|
||||
|
||||
if (!Vec.isEqual(offset, [0, 0])) {
|
||||
Object.values(nextShape.handles).forEach((handle) => {
|
||||
handle.point = Vec.toFixed(Vec.sub(handle.point, offset))
|
||||
})
|
||||
|
||||
nextShape.point = Vec.toFixed(Vec.add(nextShape.point, offset))
|
||||
}
|
||||
|
||||
return nextShape
|
||||
}
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div', { width: '100%', height: '100%' })
|
||||
|
|
|
@ -36,6 +36,11 @@ Object {
|
|||
},
|
||||
},
|
||||
"id": "arrow",
|
||||
"label": "",
|
||||
"labelPoint": Array [
|
||||
0.5,
|
||||
0.5,
|
||||
],
|
||||
"name": "Arrow",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
|
|
|
@ -4,27 +4,22 @@ import Vec from '@tldraw/vec'
|
|||
import getStroke from 'perfect-freehand'
|
||||
import { EASINGS } from '~constants'
|
||||
import { getShapeStyle } from '../shared/shape-styles'
|
||||
import type { ArrowShape, TldrawHandle } from '~types'
|
||||
import type { ArrowShape, Decoration, ShapeStyles } from '~types'
|
||||
import { TLDR } from '../../TLDR'
|
||||
|
||||
export function getArrowArcPath(
|
||||
start: TldrawHandle,
|
||||
end: TldrawHandle,
|
||||
circle: number[],
|
||||
bend: number
|
||||
) {
|
||||
export function getArrowArcPath(start: number[], end: number[], circle: number[], bend: number) {
|
||||
return [
|
||||
'M',
|
||||
start.point[0],
|
||||
start.point[1],
|
||||
start[0],
|
||||
start[1],
|
||||
'A',
|
||||
circle[2],
|
||||
circle[2],
|
||||
0,
|
||||
0,
|
||||
bend < 0 ? 0 : 1,
|
||||
end.point[0],
|
||||
end.point[1],
|
||||
end[0],
|
||||
end[1],
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
|
@ -46,23 +41,18 @@ export function getBendPoint(handles: ArrowShape['handles'], bend: number) {
|
|||
return point
|
||||
}
|
||||
|
||||
export function renderFreehandArrowShaft(shape: ArrowShape) {
|
||||
const { style, id } = shape
|
||||
|
||||
const { start, end } = shape.handles
|
||||
|
||||
export function renderFreehandArrowShaft(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined
|
||||
) {
|
||||
const getRandom = Utils.rng(id)
|
||||
|
||||
const strokeWidth = getShapeStyle(style).strokeWidth
|
||||
|
||||
const startPoint = shape.decorations?.start
|
||||
? Vec.nudge(start.point, end.point, strokeWidth)
|
||||
: start.point
|
||||
|
||||
const endPoint = shape.decorations?.end
|
||||
? Vec.nudge(end.point, start.point, strokeWidth)
|
||||
: end.point
|
||||
|
||||
const startPoint = decorationStart ? Vec.nudge(start, end, strokeWidth) : start
|
||||
const endPoint = decorationEnd ? Vec.nudge(end, start, strokeWidth) : end
|
||||
const stroke = getStroke([startPoint, endPoint], {
|
||||
size: strokeWidth,
|
||||
thinning: 0.618 + getRandom() * 0.2,
|
||||
|
@ -71,54 +61,34 @@ export function renderFreehandArrowShaft(shape: ArrowShape) {
|
|||
streamline: 0,
|
||||
last: true,
|
||||
})
|
||||
|
||||
const path = Utils.getSvgPathFromStroke(stroke)
|
||||
|
||||
return path
|
||||
return Utils.getSvgPathFromStroke(stroke)
|
||||
}
|
||||
|
||||
export function renderCurvedFreehandArrowShaft(
|
||||
shape: ArrowShape,
|
||||
circle: number[],
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined,
|
||||
center: number[],
|
||||
radius: number,
|
||||
length: number,
|
||||
easing: (t: number) => number
|
||||
) {
|
||||
const { style, id } = shape
|
||||
|
||||
const { start, end } = shape.handles
|
||||
|
||||
const getRandom = Utils.rng(id)
|
||||
|
||||
const strokeWidth = getShapeStyle(style).strokeWidth
|
||||
|
||||
const center = [circle[0], circle[1]]
|
||||
|
||||
const radius = circle[2]
|
||||
|
||||
const startPoint = shape.decorations?.start
|
||||
? Vec.rotWith(start.point, center, strokeWidth / length)
|
||||
: start.point
|
||||
|
||||
const endPoint = shape.decorations?.end
|
||||
? Vec.rotWith(end.point, center, -(strokeWidth / length))
|
||||
: end.point
|
||||
|
||||
const startPoint = decorationStart ? Vec.rotWith(start, center, strokeWidth / length) : start
|
||||
const endPoint = decorationEnd ? Vec.rotWith(end, center, -(strokeWidth / length)) : end
|
||||
const startAngle = Vec.angle(center, startPoint)
|
||||
|
||||
const endAngle = Vec.angle(center, endPoint)
|
||||
|
||||
const points: number[][] = []
|
||||
|
||||
const count = 8 + Math.floor((Math.abs(length) / 20) * 1 + getRandom() / 2)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = easing(i / count)
|
||||
|
||||
const angle = Utils.lerpAngles(startAngle, endAngle, t)
|
||||
|
||||
points.push(Vec.toFixed(Vec.nudgeAtAngle(center, angle, radius)))
|
||||
}
|
||||
|
||||
const stroke = getStroke([startPoint, ...points, endPoint], {
|
||||
size: 1 + strokeWidth,
|
||||
thinning: 0.618 + getRandom() * 0.2,
|
||||
|
@ -127,27 +97,11 @@ export function renderCurvedFreehandArrowShaft(
|
|||
streamline: 0,
|
||||
last: true,
|
||||
})
|
||||
|
||||
const path = Utils.getSvgPathFromStroke(stroke)
|
||||
|
||||
return path
|
||||
return Utils.getSvgPathFromStroke(stroke)
|
||||
}
|
||||
|
||||
export function getCtp(shape: ArrowShape) {
|
||||
const { start, end, bend } = shape.handles
|
||||
return Utils.circleFromThreePoints(start.point, end.point, bend.point)
|
||||
}
|
||||
|
||||
export function getArrowArc(shape: ArrowShape) {
|
||||
const { start, end, bend } = shape.handles
|
||||
|
||||
const [cx, cy, radius] = Utils.circleFromThreePoints(start.point, end.point, bend.point)
|
||||
|
||||
const center = [cx, cy]
|
||||
|
||||
const length = getArcLength(center, radius, start.point, end.point)
|
||||
|
||||
return { center, radius, length }
|
||||
export function getCtp(start: number[], bend: number[], end: number[]) {
|
||||
return Utils.circleFromThreePoints(start, end, bend)
|
||||
}
|
||||
|
||||
export function getCurvedArrowHeadPoints(
|
||||
|
@ -158,18 +112,13 @@ export function getCurvedArrowHeadPoints(
|
|||
sweep: boolean
|
||||
) {
|
||||
const ints = intersectCircleCircle(A, r1 * 0.618, C, r2).points
|
||||
|
||||
if (!ints) {
|
||||
TLDR.warn('Could not find an intersection for the arrow head.')
|
||||
return { left: A, right: A }
|
||||
}
|
||||
|
||||
const int = sweep ? ints[0] : ints[1]
|
||||
|
||||
const left = int ? Vec.nudge(Vec.rotWith(int, A, Math.PI / 6), A, r1 * -0.382) : A
|
||||
|
||||
const right = int ? Vec.nudge(Vec.rotWith(int, A, -Math.PI / 6), A, r1 * -0.382) : A
|
||||
|
||||
return { left, right }
|
||||
}
|
||||
|
||||
|
@ -179,13 +128,9 @@ export function getStraightArrowHeadPoints(A: number[], B: number[], r: number)
|
|||
TLDR.warn('Could not find an intersection for the arrow head.')
|
||||
return { left: A, right: A }
|
||||
}
|
||||
|
||||
const int = ints[0]
|
||||
|
||||
const left = int ? Vec.rotWith(int, A, Math.PI / 6) : A
|
||||
|
||||
const right = int ? Vec.rotWith(int, A, -Math.PI / 6) : A
|
||||
|
||||
return { left, right }
|
||||
}
|
||||
|
||||
|
@ -197,88 +142,64 @@ export function getCurvedArrowHeadPath(
|
|||
sweep: boolean
|
||||
) {
|
||||
const { left, right } = getCurvedArrowHeadPoints(A, r1, C, r2, sweep)
|
||||
|
||||
return `M ${left} L ${A} ${right}`
|
||||
}
|
||||
|
||||
export function getStraightArrowHeadPath(A: number[], B: number[], r: number) {
|
||||
const { left, right } = getStraightArrowHeadPoints(A, B, r)
|
||||
|
||||
return `M ${left} L ${A} ${right}`
|
||||
}
|
||||
|
||||
export function getArrowPath(shape: ArrowShape) {
|
||||
const {
|
||||
decorations,
|
||||
handles: { start, end, bend: _bend },
|
||||
style,
|
||||
} = shape
|
||||
|
||||
export function getArrowPath(
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
bend: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined
|
||||
) {
|
||||
const { strokeWidth } = getShapeStyle(style, false)
|
||||
|
||||
const arrowDist = Vec.dist(start.point, end.point)
|
||||
|
||||
const arrowDist = Vec.dist(start, end)
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
|
||||
|
||||
const path: (string | number)[] = []
|
||||
|
||||
const isStraightLine = Vec.dist(_bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
|
||||
|
||||
const isStraightLine = Vec.dist(bend, Vec.toFixed(Vec.med(start, end))) < 1
|
||||
if (isStraightLine) {
|
||||
// Path (line segment)
|
||||
path.push(`M ${start.point} L ${end.point}`)
|
||||
|
||||
// Start arrow head
|
||||
if (decorations?.start) {
|
||||
path.push(getStraightArrowHeadPath(start.point, end.point, arrowHeadLength))
|
||||
path.push(`M ${start} L ${end}`)
|
||||
if (decorationStart) {
|
||||
path.push(getStraightArrowHeadPath(start, end, arrowHeadLength))
|
||||
}
|
||||
|
||||
// End arrow head
|
||||
if (decorations?.end) {
|
||||
path.push(getStraightArrowHeadPath(end.point, start.point, arrowHeadLength))
|
||||
if (decorationEnd) {
|
||||
path.push(getStraightArrowHeadPath(end, start, arrowHeadLength))
|
||||
}
|
||||
} else {
|
||||
const { center, radius, length } = getArrowArc(shape)
|
||||
|
||||
// Path (arc)
|
||||
path.push(`M ${start.point} A ${radius} ${radius} 0 0 ${length > 0 ? '1' : '0'} ${end.point}`)
|
||||
|
||||
// Start Arrow head
|
||||
if (decorations?.start) {
|
||||
path.push(getCurvedArrowHeadPath(start.point, arrowHeadLength, center, radius, length < 0))
|
||||
}
|
||||
|
||||
// End arrow head
|
||||
if (decorations?.end) {
|
||||
path.push(getCurvedArrowHeadPath(end.point, arrowHeadLength, center, radius, length >= 0))
|
||||
const circle = getCtp(start, bend, end)
|
||||
const center = [circle[0], circle[1]]
|
||||
const radius = circle[2]
|
||||
const length = getArcLength(center, radius, start, end)
|
||||
path.push(`M ${start} A ${radius} ${radius} 0 0 ${length > 0 ? '1' : '0'} ${end}`)
|
||||
if (decorationStart)
|
||||
path.push(getCurvedArrowHeadPath(start, arrowHeadLength, center, radius, length < 0))
|
||||
if (decorationEnd) {
|
||||
path.push(getCurvedArrowHeadPath(end, arrowHeadLength, center, radius, length >= 0))
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(' ')
|
||||
}
|
||||
|
||||
export function getArcPoints(shape: ArrowShape) {
|
||||
const { start, bend, end } = shape.handles
|
||||
|
||||
if (Vec.dist2(bend.point, Vec.med(start.point, end.point)) > 4) {
|
||||
const points: number[][] = []
|
||||
|
||||
// We're an arc, calculate points along the arc
|
||||
const { center, radius } = getArrowArc(shape)
|
||||
|
||||
const startAngle = Vec.angle(center, start.point)
|
||||
|
||||
const endAngle = Vec.angle(center, end.point)
|
||||
|
||||
for (let i = 1 / 20; i < 1; i += 1 / 20) {
|
||||
const angle = Utils.lerpAngles(startAngle, endAngle, i)
|
||||
points.push(Vec.nudgeAtAngle(center, angle, radius))
|
||||
}
|
||||
|
||||
return points
|
||||
} else {
|
||||
return [start.point, end.point]
|
||||
export function getArcPoints(start: number[], bend: number[], end: number[]) {
|
||||
if (Vec.dist2(bend, Vec.med(start, end)) <= 4) return [start, end]
|
||||
// The arc is curved; calculate twenty points along the arc
|
||||
const points: number[][] = []
|
||||
const circle = getCtp(start, bend, end)
|
||||
const center = [circle[0], circle[1]]
|
||||
const radius = circle[2]
|
||||
const startAngle = Vec.angle(center, start)
|
||||
const endAngle = Vec.angle(center, end)
|
||||
for (let i = 1 / 20; i < 1; i += 1 / 20) {
|
||||
const angle = Utils.lerpAngles(startAngle, endAngle, i)
|
||||
points.push(Vec.nudgeAtAngle(center, angle, radius))
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
export function isAngleBetween(a: number, b: number, c: number): boolean {
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from 'react'
|
||||
|
||||
export interface ArrowheadProps {
|
||||
left: number[]
|
||||
middle: number[]
|
||||
right: number[]
|
||||
stroke: string
|
||||
strokeWidth: number
|
||||
}
|
||||
|
||||
export function Arrowhead({ left, middle, right, stroke, strokeWidth }: ArrowheadProps) {
|
||||
return (
|
||||
<g>
|
||||
<path className="tl-stroke-hitarea" d={`M ${left} L ${middle} ${right}`} />
|
||||
<path
|
||||
d={`M ${left} L ${middle} ${right}`}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
import { Utils } from '@tldraw/core'
|
||||
import Vec from '@tldraw/vec'
|
||||
import * as React from 'react'
|
||||
import { EASINGS } from '~constants'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
import type { Decoration, ShapeStyles } from '~types'
|
||||
import {
|
||||
getArcLength,
|
||||
getArrowArcPath,
|
||||
getCtp,
|
||||
getCurvedArrowHeadPoints,
|
||||
renderCurvedFreehandArrowShaft,
|
||||
} from '../arrowHelpers'
|
||||
import { Arrowhead } from './ArrowHead'
|
||||
|
||||
interface ArrowSvgProps {
|
||||
id: string
|
||||
style: ShapeStyles
|
||||
start: number[]
|
||||
bend: number[]
|
||||
end: number[]
|
||||
arrowBend: number
|
||||
decorationStart: Decoration | undefined
|
||||
decorationEnd: Decoration | undefined
|
||||
isDarkMode: boolean
|
||||
isDraw: boolean
|
||||
}
|
||||
|
||||
export const CurvedArrow = React.memo(function CurvedArrow({
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
bend,
|
||||
end,
|
||||
arrowBend,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
isDraw,
|
||||
isDarkMode,
|
||||
}: ArrowSvgProps) {
|
||||
const arrowDist = Vec.dist(start, end)
|
||||
if (arrowDist < 2) return null
|
||||
const styles = getShapeStyle(style, isDarkMode)
|
||||
const { strokeWidth } = styles
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
// Calculate a path as a segment of a circle passing through the three points start, bend, and end
|
||||
const circle = getCtp(start, bend, end)
|
||||
const center = [circle[0], circle[1]]
|
||||
const radius = circle[2]
|
||||
const length = getArcLength(center, radius, start, end)
|
||||
const getRandom = Utils.rng(id)
|
||||
const easing = EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic']
|
||||
const path = isDraw
|
||||
? renderCurvedFreehandArrowShaft(
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
center,
|
||||
radius,
|
||||
length,
|
||||
easing
|
||||
)
|
||||
: getArrowArcPath(start, end, circle, arrowBend)
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Math.abs(length),
|
||||
sw,
|
||||
style.dash,
|
||||
2,
|
||||
false
|
||||
)
|
||||
// Arrowheads
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
|
||||
const startArrowHead = decorationStart
|
||||
? getCurvedArrowHeadPoints(start, arrowHeadLength, center, radius, length < 0)
|
||||
: null
|
||||
const endArrowHead = decorationEnd
|
||||
? getCurvedArrowHeadPoints(end, arrowHeadLength, center, radius, length >= 0)
|
||||
: null
|
||||
return (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={isDraw ? styles.stroke : 'none'}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? 0 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{startArrowHead && (
|
||||
<Arrowhead
|
||||
left={startArrowHead.left}
|
||||
middle={start}
|
||||
right={startArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<Arrowhead
|
||||
left={endArrowHead.left}
|
||||
middle={end}
|
||||
right={endArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,90 @@
|
|||
import { Utils } from '@tldraw/core'
|
||||
import Vec from '@tldraw/vec'
|
||||
import * as React from 'react'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
import type { Decoration, ShapeStyles } from '~types'
|
||||
import { getStraightArrowHeadPoints, renderFreehandArrowShaft } from '../arrowHelpers'
|
||||
import { Arrowhead } from './ArrowHead'
|
||||
|
||||
interface ArrowSvgProps {
|
||||
id: string
|
||||
style: ShapeStyles
|
||||
start: number[]
|
||||
bend: number[]
|
||||
end: number[]
|
||||
arrowBend: number
|
||||
decorationStart: Decoration | undefined
|
||||
decorationEnd: Decoration | undefined
|
||||
isDarkMode: boolean
|
||||
isDraw: boolean
|
||||
}
|
||||
|
||||
export const StraightArrow = React.memo(function StraightArrow({
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
isDraw,
|
||||
isDarkMode,
|
||||
}: ArrowSvgProps) {
|
||||
const arrowDist = Vec.dist(start, end)
|
||||
if (arrowDist < 2) return null
|
||||
const styles = getShapeStyle(style, isDarkMode)
|
||||
const { strokeWidth } = styles
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
// Path between start and end points
|
||||
const path = isDraw
|
||||
? renderFreehandArrowShaft(id, style, start, end, decorationStart, decorationEnd)
|
||||
: 'M' + Vec.toFixed(start) + 'L' + Vec.toFixed(end)
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
arrowDist,
|
||||
strokeWidth * 1.618,
|
||||
style.dash,
|
||||
2,
|
||||
false
|
||||
)
|
||||
// Arrowheads
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
|
||||
const startArrowHead = decorationStart
|
||||
? getStraightArrowHeadPoints(start, end, arrowHeadLength)
|
||||
: null
|
||||
const endArrowHead = decorationEnd
|
||||
? getStraightArrowHeadPoints(end, start, arrowHeadLength)
|
||||
: null
|
||||
return (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? sw / 2 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
{startArrowHead && (
|
||||
<Arrowhead
|
||||
left={startArrowHead.left}
|
||||
middle={start}
|
||||
right={startArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<Arrowhead
|
||||
left={endArrowHead.left}
|
||||
middle={end}
|
||||
right={endArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -1,19 +1,23 @@
|
|||
import * as React from 'react'
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { defaultStyle, getShapeStyle } from '~state/shapes/shared'
|
||||
import { defaultStyle, getShapeStyle, getFontStyle } from '~state/shapes/shared'
|
||||
import { EllipseShape, DashStyle, TDShapeType, TDShape, TransformInfo, TDMeta } from '~types'
|
||||
import { GHOSTED_OPACITY } from '~constants'
|
||||
import { GHOSTED_OPACITY, LABEL_POINT } from '~constants'
|
||||
import { TDShapeUtil } from '../TDShapeUtil'
|
||||
import {
|
||||
intersectEllipseBounds,
|
||||
intersectLineSegmentEllipse,
|
||||
intersectRayEllipse,
|
||||
} from '@tldraw/intersect'
|
||||
import { getEllipseIndicatorPathTDSnapshot, getEllipsePath } from './ellipseHelpers'
|
||||
import { getEllipseIndicatorPath } from './ellipseHelpers'
|
||||
import { DrawEllipse } from './components/DrawEllipse'
|
||||
import { DashedEllipse } from './components/DashedEllipse'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
import { styled } from '~styles'
|
||||
|
||||
type T = EllipseShape
|
||||
type E = SVGSVGElement
|
||||
type E = HTMLDivElement
|
||||
type M = TDMeta
|
||||
|
||||
export class EllipseUtil extends TDShapeUtil<T, E> {
|
||||
|
@ -21,6 +25,10 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
canBind = true
|
||||
|
||||
canClone = true
|
||||
|
||||
canEdit = true
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
|
@ -33,132 +41,88 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
radius: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
},
|
||||
props
|
||||
)
|
||||
}
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, isGhost, isSelected, isBinding, meta, events }, ref) => {
|
||||
const {
|
||||
radius: [radiusX, radiusY],
|
||||
style,
|
||||
} = shape
|
||||
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isGhost,
|
||||
isSelected,
|
||||
isBinding,
|
||||
isEditing,
|
||||
meta,
|
||||
bounds,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { id, radius, style, label = '', labelPoint = LABEL_POINT } = shape
|
||||
const font = getFontStyle(shape.style)
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
|
||||
const strokeWidth = styles.strokeWidth
|
||||
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
|
||||
const rx = Math.max(0, radiusX - sw / 2)
|
||||
const ry = Math.max(0, radiusY - sw / 2)
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const path = getEllipsePath(shape, this.getCenter(shape))
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
const rx = Math.max(0, radius[0] - sw / 2)
|
||||
const ry = Math.max(0, radius[1] - sw / 2)
|
||||
const Component = style.dash === DashStyle.Draw ? DrawEllipse : DashedEllipse
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
)
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={(labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
<SVGContainer id={shape.id + '_svg'} opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
{isBinding && (
|
||||
<ellipse
|
||||
className="tl-binding-indicator"
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
strokeWidth={this.bindingDistance}
|
||||
/>
|
||||
)}
|
||||
<ellipse
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={radiusX}
|
||||
ry={radiusY}
|
||||
/>
|
||||
<path
|
||||
d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))}
|
||||
stroke="none"
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={styles.strokeWidth}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
<Component
|
||||
id={id}
|
||||
radius={radius}
|
||||
style={style}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry) // Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h)))
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
perimeter < 64 ? perimeter * 2 : perimeter,
|
||||
strokeWidth * 1.618,
|
||||
shape.style.dash,
|
||||
4
|
||||
)
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
{isBinding && (
|
||||
<ellipse
|
||||
className="tl-binding-indicator"
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={radiusX}
|
||||
ry={radiusY}
|
||||
strokeWidth={this.bindingDistance}
|
||||
/>
|
||||
)}
|
||||
<ellipse
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={radiusX}
|
||||
ry={radiusY}
|
||||
/>
|
||||
<ellipse
|
||||
cx={radiusX}
|
||||
cy={radiusY}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
fill={styles.fill}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T, M>(({ shape }) => {
|
||||
const {
|
||||
radius: [radiusX, radiusY],
|
||||
style,
|
||||
} = shape
|
||||
|
||||
const { id, radius, style } = shape
|
||||
const styles = getShapeStyle(style)
|
||||
const strokeWidth = styles.strokeWidth
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
const rx = Math.max(0, radiusX - sw / 2)
|
||||
const ry = Math.max(0, radiusY - sw / 2)
|
||||
|
||||
const rx = Math.max(0, radius[0] - sw / 2)
|
||||
const ry = Math.max(0, radius[1] - sw / 2)
|
||||
return style.dash === DashStyle.Draw ? (
|
||||
<path d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))} />
|
||||
<path d={getEllipseIndicatorPath(id, radius, style)} />
|
||||
) : (
|
||||
<ellipse cx={radiusX} cy={radiusY} rx={rx} ry={ry} />
|
||||
<ellipse cx={radius[0]} cy={radius[1]} rx={rx} ry={ry} />
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -224,7 +188,7 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
}
|
||||
|
||||
shouldRender = (prev: T, next: T): boolean => {
|
||||
return next.radius !== prev.radius || next.style !== prev.style
|
||||
return next.radius !== prev.radius || next.style !== prev.style || next.label !== prev.label
|
||||
}
|
||||
|
||||
getCenter = (shape: T): number[] => {
|
||||
|
@ -241,12 +205,9 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
) => {
|
||||
{
|
||||
const expandedBounds = this.getExpandedBounds(shape)
|
||||
|
||||
const center = this.getCenter(shape)
|
||||
|
||||
let bindingPoint: number[]
|
||||
let distance: number
|
||||
|
||||
if (
|
||||
!Utils.pointInEllipse(
|
||||
point,
|
||||
|
@ -254,9 +215,9 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
shape.radius[0] + this.bindingDistance,
|
||||
shape.radius[1] + this.bindingDistance
|
||||
)
|
||||
)
|
||||
) {
|
||||
return
|
||||
|
||||
}
|
||||
if (bindAnywhere) {
|
||||
if (Vec.dist(point, this.getCenter(shape)) < 12) {
|
||||
bindingPoint = [0.5, 0.5]
|
||||
|
@ -266,7 +227,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
expandedBounds.height,
|
||||
])
|
||||
}
|
||||
|
||||
distance = 0
|
||||
} else {
|
||||
let intersection = intersectRayEllipse(
|
||||
|
@ -277,7 +237,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))[0]
|
||||
|
||||
if (!intersection) {
|
||||
intersection = intersectLineSegmentEllipse(
|
||||
point,
|
||||
|
@ -288,14 +247,11 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
shape.rotation || 0
|
||||
).points.sort((a, b) => Vec.dist(a, point) - Vec.dist(b, point))[0]
|
||||
}
|
||||
|
||||
if (!intersection) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection)
|
||||
|
||||
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
|
||||
// If we're close to the center, snap to the center
|
||||
bindingPoint = [0.5, 0.5]
|
||||
|
@ -306,7 +262,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
expandedBounds.height,
|
||||
])
|
||||
}
|
||||
|
||||
if (
|
||||
Utils.pointInEllipse(point, center, shape.radius[0], shape.radius[1], shape.rotation || 0)
|
||||
) {
|
||||
|
@ -322,15 +277,10 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points[0]
|
||||
|
||||
if (!innerIntersection) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!innerIntersection) return undefined
|
||||
distance = Math.max(this.bindingDistance / 2, Vec.dist(point, innerIntersection))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point: bindingPoint,
|
||||
distance,
|
||||
|
@ -344,7 +294,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
{ scaleX, scaleY, initialShape }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const { rotation = 0 } = initialShape
|
||||
|
||||
return {
|
||||
point: [bounds.minX, bounds.minY],
|
||||
radius: [bounds.width / 2, bounds.height / 2],
|
||||
|
@ -362,3 +311,5 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div', { width: '100%', height: '100%' })
|
||||
|
|
|
@ -4,6 +4,11 @@ exports[`Ellipse shape Creates a shape: ellipse 1`] = `
|
|||
Object {
|
||||
"childIndex": 1,
|
||||
"id": "ellipse",
|
||||
"label": "",
|
||||
"labelPoint": Array [
|
||||
0.5,
|
||||
0.5,
|
||||
],
|
||||
"name": "Ellipse",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from 'react'
|
||||
import { Utils } from '@tldraw/core'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
|
||||
interface EllipseSvgProps {
|
||||
radius: number[]
|
||||
style: ShapeStyles
|
||||
isSelected: boolean
|
||||
isDarkMode: boolean
|
||||
}
|
||||
|
||||
export const DashedEllipse = React.memo(function DashedEllipse({
|
||||
radius,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: EllipseSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
const rx = Math.max(0, radius[0] - sw / 2)
|
||||
const ry = Math.max(0, radius[1] - sw / 2)
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry)
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
perimeter < 64 ? perimeter * 2 : perimeter,
|
||||
strokeWidth * 1.618,
|
||||
style.dash,
|
||||
4
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ellipse
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={radius[0]}
|
||||
ry={radius[1]}
|
||||
/>
|
||||
<ellipse
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,52 @@
|
|||
import * as React from 'react'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getEllipseIndicatorPath, getEllipsePath } from '../ellipseHelpers'
|
||||
|
||||
interface EllipseSvgProps {
|
||||
id: string
|
||||
radius: number[]
|
||||
style: ShapeStyles
|
||||
isSelected: boolean
|
||||
isDarkMode: boolean
|
||||
}
|
||||
|
||||
export const DrawEllipse = React.memo(function DrawEllipse({
|
||||
id,
|
||||
radius,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: EllipseSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
|
||||
const innerPath = getEllipsePath(id, radius, style)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ellipse
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={radius[0]}
|
||||
ry={radius[1]}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<path
|
||||
d={getEllipseIndicatorPath(id, radius, style)}
|
||||
stroke="none"
|
||||
fill={fill}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={innerPath}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -1,45 +1,26 @@
|
|||
import { Utils } from '@tldraw/core'
|
||||
import Vec from '@tldraw/vec'
|
||||
import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
|
||||
import { EASINGS } from '~constants'
|
||||
import type { EllipseShape } from '~types'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getShapeStyle } from '../shared/shape-styles'
|
||||
|
||||
export function getEllipseStrokePoints(shape: EllipseShape, boundsCenter: number[]) {
|
||||
const {
|
||||
id,
|
||||
radius: [radiusX, radiusY],
|
||||
point,
|
||||
style,
|
||||
} = shape
|
||||
|
||||
export function getEllipseStrokePoints(id: string, radius: number[], style: ShapeStyles) {
|
||||
const { strokeWidth } = getShapeStyle(style)
|
||||
|
||||
const getRandom = Utils.rng(id)
|
||||
|
||||
const center = Vec.sub(boundsCenter, point)
|
||||
|
||||
const rx = radiusX + getRandom() * strokeWidth * 2
|
||||
const ry = radiusY + getRandom() * strokeWidth * 2
|
||||
|
||||
const rx = radius[0] + getRandom() * strokeWidth * 2
|
||||
const ry = radius[1] + getRandom() * strokeWidth * 2
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry)
|
||||
|
||||
const points: number[][] = []
|
||||
|
||||
const start = Math.PI + Math.PI * getRandom()
|
||||
|
||||
const extra = Math.abs(getRandom())
|
||||
|
||||
const count = Math.max(16, perimeter / 10)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = EASINGS.easeInOutSine(i / (count + 1))
|
||||
const rads = start * 2 + Math.PI * (2 + extra) * t
|
||||
const c = Math.cos(rads)
|
||||
const s = Math.sin(rads)
|
||||
points.push([rx * c + center[0], ry * s + center[1], t + 0.5 + getRandom() / 2])
|
||||
points.push([rx * c + radius[0], ry * s + radius[1], t + 0.5 + getRandom() / 2])
|
||||
}
|
||||
|
||||
return getStrokePoints(points, {
|
||||
size: 1 + strokeWidth * 2,
|
||||
thinning: 0.618,
|
||||
|
@ -50,24 +31,14 @@ export function getEllipseStrokePoints(shape: EllipseShape, boundsCenter: number
|
|||
})
|
||||
}
|
||||
|
||||
export function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
|
||||
const {
|
||||
id,
|
||||
radius: [radiusX, radiusY],
|
||||
style,
|
||||
} = shape
|
||||
|
||||
export function getEllipsePath(id: string, radius: number[], style: ShapeStyles) {
|
||||
const { strokeWidth } = getShapeStyle(style)
|
||||
|
||||
const getRandom = Utils.rng(id)
|
||||
|
||||
const rx = radiusX + getRandom() * strokeWidth * 2
|
||||
const ry = radiusY + getRandom() * strokeWidth * 2
|
||||
|
||||
const rx = radius[0] + getRandom() * strokeWidth * 2
|
||||
const ry = radius[1] + getRandom() * strokeWidth * 2
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry)
|
||||
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getStrokeOutlinePoints(getEllipseStrokePoints(shape, boundsCenter), {
|
||||
getStrokeOutlinePoints(getEllipseStrokePoints(id, radius, style), {
|
||||
size: 2 + strokeWidth * 2,
|
||||
thinning: 0.618,
|
||||
end: { taper: perimeter / 8 },
|
||||
|
@ -78,9 +49,9 @@ export function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
|
|||
)
|
||||
}
|
||||
|
||||
export function getEllipseIndicatorPathTDSnapshot(shape: EllipseShape, boundsCenter: number[]) {
|
||||
export function getEllipseIndicatorPath(id: string, radius: number[], style: ShapeStyles) {
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getEllipseStrokePoints(shape, boundsCenter).map((pt) => pt.point.slice(0, 2)),
|
||||
getEllipseStrokePoints(id, radius, style).map((pt) => pt.point.slice(0, 2)),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
import * as React from 'react'
|
||||
import { Utils, SVGContainer } from '@tldraw/core'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
import { getStroke, getStrokePoints } from 'perfect-freehand'
|
||||
import { RectangleShape, DashStyle, TDShapeType, TDMeta } from '~types'
|
||||
import { GHOSTED_OPACITY } from '~constants'
|
||||
import { GHOSTED_OPACITY, LABEL_POINT } from '~constants'
|
||||
import { TDShapeUtil } from '../TDShapeUtil'
|
||||
import {
|
||||
defaultStyle,
|
||||
getShapeStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
getFontStyle,
|
||||
transformSingleRectangle,
|
||||
} from '~state/shapes/shared'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
import { getRectangleIndicatorPathTDSnapshot } from './rectangleHelpers'
|
||||
import { DrawRectangle } from './components/DrawRectangle'
|
||||
import { DashedRectangle } from './components/DashedRectangle'
|
||||
import { BindingIndicator } from './components/BindingIndicator'
|
||||
import { styled } from '~styles'
|
||||
|
||||
type T = RectangleShape
|
||||
type E = SVGSVGElement
|
||||
type E = HTMLDivElement
|
||||
|
||||
export class RectangleUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Rectangle as const
|
||||
|
@ -23,6 +28,8 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
canClone = true
|
||||
|
||||
canEdit = true
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
|
@ -35,139 +42,72 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
|
|||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
},
|
||||
props
|
||||
)
|
||||
}
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, isBinding, isSelected, isGhost, meta, events }, ref) => {
|
||||
const { id, size, style } = shape
|
||||
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
bounds,
|
||||
events,
|
||||
onShapeBlur,
|
||||
onShapeChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { id, size, style, label = '', labelPoint = LABEL_POINT } = shape
|
||||
const font = getFontStyle(style)
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
|
||||
const { strokeWidth } = styles
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const pathTDSnapshot = getRectanglePath(shape)
|
||||
const indicatorPath = getRectangleIndicatorPathTDSnapshot(shape)
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={strokeWidth}
|
||||
y={strokeWidth}
|
||||
width={Math.max(0, size[0] - strokeWidth / 2)}
|
||||
height={Math.max(0, size[1] - strokeWidth / 2)}
|
||||
strokeWidth={this.bindingDistance * 2}
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
d={indicatorPath}
|
||||
/>
|
||||
<path
|
||||
d={indicatorPath}
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<path
|
||||
d={pathTDSnapshot}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={styles.strokeWidth}
|
||||
pointerEvents="none"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
const Component = style.dash === DashStyle.Draw ? DrawRectangle : DashedRectangle
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
)
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={(labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
<SVGContainer id={shape.id + '_svg'} opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
{isBinding && <BindingIndicator strokeWidth={styles.strokeWidth} size={size} />}
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
|
||||
const w = Math.max(0, size[0] - sw / 2)
|
||||
const h = Math.max(0, size[1] - sw / 2)
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
]
|
||||
|
||||
const paths = strokes.map(([start, end, length], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
length,
|
||||
strokeWidth * 1.618,
|
||||
shape.style.dash
|
||||
)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={0}
|
||||
y={0}
|
||||
width={size[0]}
|
||||
height={size[1]}
|
||||
strokeWidth={this.bindingDistance}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
className={isSelected || style.isFilled ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
strokeWidth={this.bindingDistance}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<rect
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
fill={styles.fill}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<g pointerEvents="none" stroke={styles.stroke} strokeWidth={sw} strokeLinecap="round">
|
||||
{paths}
|
||||
</g>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const {
|
||||
style,
|
||||
size: [width, height],
|
||||
} = shape
|
||||
const { id, style, size } = shape
|
||||
|
||||
const styles = getShapeStyle(style, false)
|
||||
const sw = styles.strokeWidth
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
return <path d={getRectangleIndicatorPathTDSnapshot(shape)} />
|
||||
return <path d={getRectangleIndicatorPathTDSnapshot(id, style, size)} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -176,8 +116,8 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
|
|||
y={sw}
|
||||
rx={1}
|
||||
ry={1}
|
||||
width={Math.max(1, width - sw * 2)}
|
||||
height={Math.max(1, height - sw * 2)}
|
||||
width={Math.max(1, size[0] - sw * 2)}
|
||||
height={Math.max(1, size[1] - sw * 2)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
@ -187,7 +127,7 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
|
|||
}
|
||||
|
||||
shouldRender = (prev: T, next: T) => {
|
||||
return next.size !== prev.size || next.style !== prev.style
|
||||
return next.size !== prev.size || next.style !== prev.style || next.label !== prev.label
|
||||
}
|
||||
|
||||
transform = transformRectangle
|
||||
|
@ -195,102 +135,4 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
|
|||
transformSingle = transformSingleRectangle
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
function getRectangleDrawPoints(shape: RectangleShape) {
|
||||
const styles = getShapeStyle(shape.style)
|
||||
|
||||
const getRandom = Utils.rng(shape.id)
|
||||
|
||||
const sw = styles.strokeWidth
|
||||
|
||||
// Dimensions
|
||||
const w = Math.max(0, shape.size[0])
|
||||
const h = Math.max(0, shape.size[1])
|
||||
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(4)).map(() => {
|
||||
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75]
|
||||
})
|
||||
|
||||
// Corners
|
||||
const tl = Vec.add([sw / 2, sw / 2], offsets[0])
|
||||
const tr = Vec.add([w - sw / 2, sw / 2], offsets[1])
|
||||
const br = Vec.add([w - sw / 2, h - sw / 2], offsets[2])
|
||||
const bl = Vec.add([sw / 2, h - sw / 2], offsets[3])
|
||||
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 4))
|
||||
|
||||
// Corner radii
|
||||
const rx = Math.min(w / 2, sw * 2)
|
||||
const ry = Math.min(h / 2, sw * 2)
|
||||
|
||||
// Number of points per side
|
||||
const px = Math.max(8, Math.floor(w / 16))
|
||||
const py = Math.max(8, Math.floor(h / 16))
|
||||
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(Vec.add(tl, [rx, 0]), Vec.sub(tr, [rx, 0]), px),
|
||||
Vec.pointsBetween(Vec.add(tr, [0, ry]), Vec.sub(br, [0, ry]), py),
|
||||
Vec.pointsBetween(Vec.sub(br, [rx, 0]), Vec.add(bl, [rx, 0]), px),
|
||||
Vec.pointsBetween(Vec.sub(bl, [0, ry]), Vec.add(tl, [0, ry]), py),
|
||||
],
|
||||
rm
|
||||
)
|
||||
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
|
||||
const points = [...lines.flat(), ...lines[0]].slice(
|
||||
5,
|
||||
Math.floor((rm % 2 === 0 ? px : py) / -2) + 3
|
||||
)
|
||||
|
||||
return {
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(shape: RectangleShape) {
|
||||
const { points } = getRectangleDrawPoints(shape)
|
||||
|
||||
const { strokeWidth } = getShapeStyle(shape.style)
|
||||
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
}
|
||||
|
||||
return { points, options }
|
||||
}
|
||||
|
||||
function getRectanglePath(shape: RectangleShape) {
|
||||
const { points, options } = getDrawStrokeInfo(shape)
|
||||
|
||||
const stroke = getStroke(points, options)
|
||||
|
||||
return Utils.getSvgPathFromStroke(stroke)
|
||||
}
|
||||
|
||||
function getRectangleIndicatorPathTDSnapshot(shape: RectangleShape) {
|
||||
const { points, options } = getDrawStrokeInfo(shape)
|
||||
|
||||
const strokePoints = getStrokePoints(points, options)
|
||||
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map((pt) => pt.point.slice(0, 2)),
|
||||
false
|
||||
)
|
||||
}
|
||||
const FullWrapper = styled('div', { width: '100%', height: '100%' })
|
||||
|
|
|
@ -4,6 +4,11 @@ exports[`Rectangle shape Creates a shape: rectangle 1`] = `
|
|||
Object {
|
||||
"childIndex": 1,
|
||||
"id": "rectangle",
|
||||
"label": "",
|
||||
"labelPoint": Array [
|
||||
0.5,
|
||||
0.5,
|
||||
],
|
||||
"name": "Rectangle",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from 'react'
|
||||
import { BINDING_DISTANCE } from '~constants'
|
||||
|
||||
interface BindingIndicatorProps {
|
||||
strokeWidth: number
|
||||
size: number[]
|
||||
}
|
||||
export function BindingIndicator({ strokeWidth, size }: BindingIndicatorProps) {
|
||||
return (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={strokeWidth}
|
||||
y={strokeWidth}
|
||||
width={Math.max(0, size[0] - strokeWidth / 2)}
|
||||
height={Math.max(0, size[1] - strokeWidth / 2)}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import * as React from 'react'
|
||||
import { Utils } from '@tldraw/core'
|
||||
import { BINDING_DISTANCE } from '~constants'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
|
||||
interface RectangleSvgProps {
|
||||
id: string
|
||||
style: ShapeStyles
|
||||
isSelected: boolean
|
||||
size: number[]
|
||||
isDarkMode: boolean
|
||||
}
|
||||
|
||||
export const DashedRectangle = React.memo(function DashedRectangle({
|
||||
id,
|
||||
style,
|
||||
size,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: RectangleSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
|
||||
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
|
||||
const w = Math.max(0, size[0] - sw / 2)
|
||||
const h = Math.max(0, size[1] - sw / 2)
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
]
|
||||
|
||||
const paths = strokes.map(([start, end, length], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
length,
|
||||
strokeWidth * 1.618,
|
||||
style.dash
|
||||
)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<rect
|
||||
className={isSelected || style.isFilled ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
strokeWidth={BINDING_DISTANCE}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<rect x={sw / 2} y={sw / 2} width={w} height={h} fill={fill} pointerEvents="none" />
|
||||
)}
|
||||
<g pointerEvents="none" stroke={stroke} strokeWidth={sw} strokeLinecap="round">
|
||||
{paths}
|
||||
</g>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,42 @@
|
|||
import * as React from 'react'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getRectangleIndicatorPathTDSnapshot, getRectanglePath } from '../rectangleHelpers'
|
||||
|
||||
interface RectangleSvgProps {
|
||||
id: string
|
||||
style: ShapeStyles
|
||||
isSelected: boolean
|
||||
isDarkMode: boolean
|
||||
size: number[]
|
||||
}
|
||||
|
||||
export const DrawRectangle = React.memo(function DrawRectangle({
|
||||
id,
|
||||
style,
|
||||
size,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: RectangleSvgProps) {
|
||||
const { isFilled } = style
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
|
||||
const pathTDSnapshot = getRectanglePath(id, style, size)
|
||||
const innerPath = getRectangleIndicatorPathTDSnapshot(id, style, size)
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
d={innerPath}
|
||||
/>
|
||||
{isFilled && <path d={innerPath} fill={fill} pointerEvents="none" />}
|
||||
<path
|
||||
d={pathTDSnapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,98 @@
|
|||
import { Utils } from '@tldraw/core'
|
||||
import Vec from '@tldraw/vec'
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getShapeStyle } from '../shared'
|
||||
|
||||
function getRectangleDrawPoints(id: string, style: ShapeStyles, size: number[]) {
|
||||
const styles = getShapeStyle(style)
|
||||
|
||||
const getRandom = Utils.rng(id)
|
||||
|
||||
const sw = styles.strokeWidth
|
||||
|
||||
// Dimensions
|
||||
const w = Math.max(0, size[0])
|
||||
const h = Math.max(0, size[1])
|
||||
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(4)).map(() => {
|
||||
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75]
|
||||
})
|
||||
|
||||
// Corners
|
||||
const tl = Vec.add([sw / 2, sw / 2], offsets[0])
|
||||
const tr = Vec.add([w - sw / 2, sw / 2], offsets[1])
|
||||
const br = Vec.add([w - sw / 2, h - sw / 2], offsets[2])
|
||||
const bl = Vec.add([sw / 2, h - sw / 2], offsets[3])
|
||||
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 4))
|
||||
|
||||
// Corner radii
|
||||
const rx = Math.min(w / 2, sw * 2)
|
||||
const ry = Math.min(h / 2, sw * 2)
|
||||
|
||||
// Number of points per side
|
||||
const px = Math.max(8, Math.floor(w / 16))
|
||||
const py = Math.max(8, Math.floor(h / 16))
|
||||
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(Vec.add(tl, [rx, 0]), Vec.sub(tr, [rx, 0]), px),
|
||||
Vec.pointsBetween(Vec.add(tr, [0, ry]), Vec.sub(br, [0, ry]), py),
|
||||
Vec.pointsBetween(Vec.sub(br, [rx, 0]), Vec.add(bl, [rx, 0]), px),
|
||||
Vec.pointsBetween(Vec.sub(bl, [0, ry]), Vec.add(tl, [0, ry]), py),
|
||||
],
|
||||
rm
|
||||
)
|
||||
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
|
||||
const points = [...lines.flat(), ...lines[0]].slice(
|
||||
5,
|
||||
Math.floor((rm % 2 === 0 ? px : py) / -2) + 3
|
||||
)
|
||||
|
||||
return {
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(id: string, style: ShapeStyles, size: number[]) {
|
||||
const { points } = getRectangleDrawPoints(id, style, size)
|
||||
const { strokeWidth } = getShapeStyle(style)
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
}
|
||||
return { points, options }
|
||||
}
|
||||
|
||||
export function getRectanglePath(id: string, style: ShapeStyles, size: number[]) {
|
||||
const { points, options } = getDrawStrokeInfo(id, style, size)
|
||||
const stroke = getStroke(points, options)
|
||||
return Utils.getSvgPathFromStroke(stroke)
|
||||
}
|
||||
|
||||
export function getRectangleIndicatorPathTDSnapshot(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
size: number[]
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, style, size)
|
||||
const strokePoints = getStrokePoints(points, options)
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map((pt) => pt.point.slice(0, 2)),
|
||||
false
|
||||
)
|
||||
}
|
|
@ -65,7 +65,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
|
|||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleTextChange = React.useCallback(
|
||||
const handleLabelChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onShapeChange?.({
|
||||
id: shape.id,
|
||||
|
@ -212,7 +212,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
|
|||
ref={rTextArea}
|
||||
onPointerDown={handlePointerDown}
|
||||
value={shape.text}
|
||||
onChange={handleTextChange}
|
||||
onChange={handleLabelChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Utils, HTMLContainer, TLBounds } from '@tldraw/core'
|
|||
import { defaultTextStyle, getShapeStyle, getFontStyle } from '../shared/shape-styles'
|
||||
import { TextShape, TDMeta, TDShapeType, TransformInfo, AlignStyle } from '~types'
|
||||
import { TextAreaUtils } from '../shared'
|
||||
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
|
||||
import { BINDING_DISTANCE, GHOSTED_OPACITY, LETTER_SPACING } from '~constants'
|
||||
import { TDShapeUtil } from '../TDShapeUtil'
|
||||
import { styled } from '~styles'
|
||||
import { Vec } from '@tldraw/vec'
|
||||
|
@ -48,11 +48,11 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, isBinding, isGhost, isEditing, onShapeBlur, onShapeChange, meta, events }, ref) => {
|
||||
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
||||
const { text, style } = shape
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
const font = getFontStyle(shape.style)
|
||||
|
||||
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
||||
const rIsMounted = React.useRef(false)
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
|
@ -209,7 +209,6 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
|||
color: styles.stroke,
|
||||
}}
|
||||
name="text"
|
||||
defaultValue={text}
|
||||
tabIndex={-1}
|
||||
autoComplete="false"
|
||||
autoCapitalize="false"
|
||||
|
@ -217,16 +216,17 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
|||
autoSave="false"
|
||||
autoFocus
|
||||
placeholder=""
|
||||
spellCheck="true"
|
||||
wrap="off"
|
||||
dir="auto"
|
||||
datatype="wysiwyg"
|
||||
defaultValue={text}
|
||||
color={styles.stroke}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onPointerDown={handlePointerDown}
|
||||
spellCheck="true"
|
||||
wrap="off"
|
||||
dir="auto"
|
||||
datatype="wysiwyg"
|
||||
onContextMenu={stopPropagation}
|
||||
/>
|
||||
) : (
|
||||
|
@ -351,8 +351,6 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
|||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
const LETTER_SPACING = -1.5
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let melm: any
|
||||
|
||||
|
|
|
@ -4,10 +4,10 @@ import { TriangleShape, TDShapeType, TDMeta, TDShape, DashStyle } from '~types'
|
|||
import { TDShapeUtil } from '../TDShapeUtil'
|
||||
import {
|
||||
defaultStyle,
|
||||
getShapeStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
transformSingleRectangle,
|
||||
getFontStyle,
|
||||
} from '~state/shapes/shared'
|
||||
import {
|
||||
intersectBoundsPolygon,
|
||||
|
@ -15,12 +15,16 @@ import {
|
|||
intersectRayLineSegment,
|
||||
} from '@tldraw/intersect'
|
||||
import Vec from '@tldraw/vec'
|
||||
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
|
||||
import { getOffsetPolygon } from '../shared/PolygonUtils'
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand'
|
||||
import { BINDING_DISTANCE, GHOSTED_OPACITY, LABEL_POINT } from '~constants'
|
||||
import { getTriangleCentroid, getTrianglePoints } from './triangleHelpers'
|
||||
import { styled } from '~styles'
|
||||
import { DrawTriangle } from './components/DrawTriangle'
|
||||
import { DashedTriangle } from './components/DashedTriangle'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
import { TriangleBindingIndicator } from './components/TriangleBindingIndicator'
|
||||
|
||||
type T = TriangleShape
|
||||
type E = SVGSVGElement
|
||||
type E = HTMLDivElement
|
||||
|
||||
export class TriangleUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Triangle as const
|
||||
|
@ -29,6 +33,8 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
canClone = true
|
||||
|
||||
canEdit = true
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
|
@ -41,104 +47,71 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
},
|
||||
props
|
||||
)
|
||||
}
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, isBinding, isSelected, isGhost, meta, events }, ref) => {
|
||||
const { id, style } = shape
|
||||
|
||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||
|
||||
const { strokeWidth } = styles
|
||||
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
const pathTDSnapshot = getTrianglePath(shape)
|
||||
const indicatorPath = getTriangleIndicatorPathTDSnapshot(shape)
|
||||
const trianglePoints = getTrianglePoints(shape).join()
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
{isBinding && (
|
||||
<polygon
|
||||
className="tl-binding-indicator"
|
||||
points={trianglePoints}
|
||||
strokeWidth={this.bindingDistance * 2}
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
d={indicatorPath}
|
||||
/>
|
||||
<path
|
||||
d={indicatorPath}
|
||||
fill={style.isFilled ? styles.fill : 'none'}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<path
|
||||
d={pathTDSnapshot}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={styles.strokeWidth}
|
||||
pointerEvents="none"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
(
|
||||
{
|
||||
shape,
|
||||
bounds,
|
||||
isBinding,
|
||||
isEditing,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { id, label = '', size, style, labelPoint = LABEL_POINT } = shape
|
||||
const font = getFontStyle(style)
|
||||
const Component = style.dash === DashStyle.Draw ? DrawTriangle : DashedTriangle
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
)
|
||||
const offsetY = React.useMemo(() => {
|
||||
const center = Vec.div(size, 2)
|
||||
const centroid = getTriangleCentroid(size)
|
||||
return (centroid[1] - center[1]) * 0.72
|
||||
}, [size])
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={offsetY + (labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
<SVGContainer id={shape.id + '_svg'} opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
{isBinding && <TriangleBindingIndicator size={size} />}
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const points = getTrianglePoints(shape)
|
||||
const sides = Utils.pointsToLineSegments(points, true)
|
||||
const paths = sides.map(([start, end], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Vec.dist(start, end),
|
||||
strokeWidth * 1.618,
|
||||
shape.style.dash
|
||||
)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
{isBinding && (
|
||||
<polygon
|
||||
className="tl-binding-indicator"
|
||||
points={points.join()}
|
||||
strokeWidth={this.bindingDistance * 2}
|
||||
/>
|
||||
)}
|
||||
<polygon
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
points={points.join()}
|
||||
/>
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { style } = shape
|
||||
const styles = getShapeStyle(style, false)
|
||||
const sw = styles.strokeWidth
|
||||
return <polygon points={getTrianglePoints(shape).join()} />
|
||||
const { size } = shape
|
||||
return <polygon points={getTrianglePoints(size).join()} />
|
||||
})
|
||||
|
||||
private getPoints(shape: T) {
|
||||
|
@ -155,7 +128,7 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
}
|
||||
|
||||
shouldRender = (prev: T, next: T) => {
|
||||
return next.size !== prev.size || next.style !== prev.style
|
||||
return next.size !== prev.size || next.style !== prev.style || next.label !== prev.label
|
||||
}
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
|
@ -164,7 +137,7 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
getExpandedBounds = (shape: T) => {
|
||||
return Utils.getBoundsFromPoints(
|
||||
getTrianglePoints(shape, this.bindingDistance).map((pt) => Vec.add(pt, shape.point))
|
||||
getTrianglePoints(shape.size, this.bindingDistance).map((pt) => Vec.add(pt, shape.point))
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -193,9 +166,9 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return
|
||||
|
||||
const points = getTrianglePoints(shape).map((pt) => Vec.add(pt, shape.point))
|
||||
const points = getTrianglePoints(shape.size).map((pt) => Vec.add(pt, shape.point))
|
||||
|
||||
const expandedPoints = getTrianglePoints(shape, this.bindingDistance).map((pt) =>
|
||||
const expandedPoints = getTrianglePoints(shape.size, this.bindingDistance).map((pt) =>
|
||||
Vec.add(pt, shape.point)
|
||||
)
|
||||
|
||||
|
@ -216,7 +189,7 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
if (!intersections.length) return
|
||||
|
||||
// The center of the triangle
|
||||
const center = Vec.add(getTriangleCentroid(shape), shape.point)
|
||||
const center = Vec.add(getTriangleCentroid(shape.size), shape.point)
|
||||
|
||||
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
|
||||
const intersection = intersections.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
|
||||
|
@ -260,127 +233,4 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
transformSingle = transformSingleRectangle
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
export function getTrianglePoints(shape: T, offset = 0, rotation = 0) {
|
||||
const {
|
||||
size: [w, h],
|
||||
} = shape
|
||||
|
||||
let points = [
|
||||
[w / 2, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
]
|
||||
|
||||
if (offset) points = getOffsetPolygon(points, offset)
|
||||
if (rotation) points = points.map((pt) => Vec.rotWith(pt, [w / 2, h / 2], rotation))
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
export function getTriangleCentroid(shape: T) {
|
||||
const {
|
||||
size: [w, h],
|
||||
} = shape
|
||||
|
||||
const points = [
|
||||
[w / 2, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
]
|
||||
|
||||
return [
|
||||
(points[0][0] + points[1][0] + points[2][0]) / 3,
|
||||
(points[0][1] + points[1][1] + points[2][1]) / 3,
|
||||
]
|
||||
}
|
||||
|
||||
function getTriangleDrawPoints(shape: TriangleShape) {
|
||||
const styles = getShapeStyle(shape.style)
|
||||
|
||||
const {
|
||||
size: [w, h],
|
||||
} = shape
|
||||
|
||||
const getRandom = Utils.rng(shape.id)
|
||||
|
||||
const sw = styles.strokeWidth
|
||||
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(3)).map(() => {
|
||||
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75]
|
||||
})
|
||||
|
||||
// Corners
|
||||
const corners = [
|
||||
Vec.add([w / 2, 0], offsets[0]),
|
||||
Vec.add([w, h], offsets[1]),
|
||||
Vec.add([0, h], offsets[2]),
|
||||
]
|
||||
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 3))
|
||||
|
||||
// Number of points per side
|
||||
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(corners[0], corners[1], 32),
|
||||
Vec.pointsBetween(corners[1], corners[2], 32),
|
||||
Vec.pointsBetween(corners[2], corners[0], 32),
|
||||
],
|
||||
rm
|
||||
)
|
||||
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
|
||||
const points = [...lines.flat(), ...lines[0]]
|
||||
|
||||
return {
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(shape: TriangleShape) {
|
||||
const { points } = getTriangleDrawPoints(shape)
|
||||
|
||||
const { strokeWidth } = getShapeStyle(shape.style)
|
||||
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
}
|
||||
|
||||
return { points, options }
|
||||
}
|
||||
|
||||
function getTrianglePath(shape: TriangleShape) {
|
||||
const { points, options } = getDrawStrokeInfo(shape)
|
||||
|
||||
const stroke = getStroke(points, options)
|
||||
|
||||
return Utils.getSvgPathFromStroke(stroke)
|
||||
}
|
||||
|
||||
function getTriangleIndicatorPathTDSnapshot(shape: TriangleShape) {
|
||||
const { points, options } = getDrawStrokeInfo(shape)
|
||||
|
||||
const strokePoints = getStrokePoints(points, options)
|
||||
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map((pt) => pt.point.slice(0, 2)),
|
||||
false
|
||||
)
|
||||
}
|
||||
const FullWrapper = styled('div', { width: '100%', height: '100%' })
|
||||
|
|
|
@ -4,6 +4,11 @@ exports[`Triangle shape Creates a shape: triangle 1`] = `
|
|||
Object {
|
||||
"childIndex": 1,
|
||||
"id": "triangle",
|
||||
"label": "",
|
||||
"labelPoint": Array [
|
||||
0.5,
|
||||
0.5,
|
||||
],
|
||||
"name": "Triangle",
|
||||
"parentId": "page",
|
||||
"point": Array [
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import * as React from 'react'
|
||||
import { Utils } from '@tldraw/core'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
import { getTrianglePoints } from '../triangleHelpers'
|
||||
import Vec from '@tldraw/vec'
|
||||
|
||||
interface TriangleSvgProps {
|
||||
id: string
|
||||
size: number[]
|
||||
style: ShapeStyles
|
||||
isSelected: boolean
|
||||
isDarkMode: boolean
|
||||
}
|
||||
|
||||
export const DashedTriangle = React.memo(function DashedTriangle({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: TriangleSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
|
||||
const sw = 1 + strokeWidth * 1.618
|
||||
const points = getTrianglePoints(size)
|
||||
const sides = Utils.pointsToLineSegments(points, true)
|
||||
const paths = sides.map(([start, end], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Vec.dist(start, end),
|
||||
strokeWidth * 1.618,
|
||||
style.dash
|
||||
)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const bgPath = points.join()
|
||||
|
||||
return (
|
||||
<>
|
||||
<polygon
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
points={bgPath}
|
||||
/>
|
||||
{style.isFilled && <polygon fill={fill} points={bgPath} pointerEvents="none" />}
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,40 @@
|
|||
import * as React from 'react'
|
||||
import { getShapeStyle } from '~state/shapes/shared'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getTriangleIndicatorPathTDSnapshot, getTrianglePath } from '../triangleHelpers'
|
||||
|
||||
interface TriangleSvgProps {
|
||||
id: string
|
||||
size: number[]
|
||||
style: ShapeStyles
|
||||
isSelected: boolean
|
||||
isDarkMode: boolean
|
||||
}
|
||||
|
||||
export const DrawTriangle = React.memo(function DrawTriangle({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: TriangleSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
|
||||
const pathTDSnapshot = getTrianglePath(id, size, style)
|
||||
const indicatorPath = getTriangleIndicatorPathTDSnapshot(id, size, style)
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
||||
d={indicatorPath}
|
||||
/>
|
||||
{style.isFilled && <path d={indicatorPath} fill={fill} pointerEvents="none" />}
|
||||
<path
|
||||
d={pathTDSnapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from 'react'
|
||||
import { BINDING_DISTANCE } from '~constants'
|
||||
import { getTrianglePoints } from '../triangleHelpers'
|
||||
|
||||
interface TriangleBindingIndicatorProps {
|
||||
size: number[]
|
||||
}
|
||||
|
||||
export function TriangleBindingIndicator({ size }: TriangleBindingIndicatorProps) {
|
||||
const trianglePoints = getTrianglePoints(size).join()
|
||||
return (
|
||||
<polygon
|
||||
className="tl-binding-indicator"
|
||||
points={trianglePoints}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import { Utils } from '@tldraw/core'
|
||||
import Vec from '@tldraw/vec'
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand'
|
||||
import type { ShapeStyles } from '~types'
|
||||
import { getShapeStyle } from '../shared'
|
||||
import { getOffsetPolygon } from '../shared/PolygonUtils'
|
||||
|
||||
export function getTrianglePoints(size: number[], offset = 0, rotation = 0) {
|
||||
const [w, h] = size
|
||||
let points = [
|
||||
[w / 2, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
]
|
||||
if (offset) points = getOffsetPolygon(points, offset)
|
||||
if (rotation) points = points.map((pt) => Vec.rotWith(pt, [w / 2, h / 2], rotation))
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
export function getTriangleCentroid(size: number[]) {
|
||||
const [w, h] = size
|
||||
const points = [
|
||||
[w / 2, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
]
|
||||
return [
|
||||
(points[0][0] + points[1][0] + points[2][0]) / 3,
|
||||
(points[0][1] + points[1][1] + points[2][1]) / 3,
|
||||
]
|
||||
}
|
||||
|
||||
function getTriangleDrawPoints(id: string, size: number[], strokeWidth: number) {
|
||||
const [w, h] = size
|
||||
const getRandom = Utils.rng(id)
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(3)).map(() => {
|
||||
return [getRandom() * strokeWidth * 0.75, getRandom() * strokeWidth * 0.75]
|
||||
})
|
||||
// Corners
|
||||
const corners = [
|
||||
Vec.add([w / 2, 0], offsets[0]),
|
||||
Vec.add([w, h], offsets[1]),
|
||||
Vec.add([0, h], offsets[2]),
|
||||
]
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 3))
|
||||
// Number of points per side
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(corners[0], corners[1], 32),
|
||||
Vec.pointsBetween(corners[1], corners[2], 32),
|
||||
Vec.pointsBetween(corners[2], corners[0], 32),
|
||||
],
|
||||
rm
|
||||
)
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
const points = [...lines.flat(), ...lines[0]]
|
||||
return {
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(id: string, size: number[], style: ShapeStyles) {
|
||||
const { strokeWidth } = getShapeStyle(style)
|
||||
const { points } = getTriangleDrawPoints(id, size, strokeWidth)
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
}
|
||||
return { points, options }
|
||||
}
|
||||
|
||||
export function getTrianglePath(id: string, size: number[], style: ShapeStyles) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style)
|
||||
const stroke = getStroke(points, options)
|
||||
return Utils.getSvgPathFromStroke(stroke)
|
||||
}
|
||||
|
||||
export function getTriangleIndicatorPathTDSnapshot(id: string, size: number[], style: ShapeStyles) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style)
|
||||
const strokePoints = getStrokePoints(points, options)
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map((pt) => pt.point.slice(0, 2)),
|
||||
false
|
||||
)
|
||||
}
|
276
packages/tldraw/src/state/shapes/shared/TextLabel.tsx
Normal file
276
packages/tldraw/src/state/shapes/shared/TextLabel.tsx
Normal file
|
@ -0,0 +1,276 @@
|
|||
import * as React from 'react'
|
||||
import { stopPropagation } from '~components/stopPropagation'
|
||||
import { GHOSTED_OPACITY, LABEL_POINT, LETTER_SPACING } from '~constants'
|
||||
import { TLDR } from '~state/TLDR'
|
||||
import { styled } from '~styles'
|
||||
import { TextAreaUtils } from '.'
|
||||
import { getTextLabelSize } from './getTextSize'
|
||||
|
||||
export interface TextLabelProps {
|
||||
font: string
|
||||
text: string
|
||||
isDarkMode: boolean
|
||||
onBlur?: () => void
|
||||
onChange: (text: string) => void
|
||||
offsetY?: number
|
||||
offsetX?: number
|
||||
scale?: number
|
||||
isEditing?: boolean
|
||||
}
|
||||
|
||||
export const TextLabel = React.memo(function TextLabel({
|
||||
isDarkMode,
|
||||
font,
|
||||
text,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
scale = 1,
|
||||
isEditing = false,
|
||||
onBlur,
|
||||
onChange,
|
||||
}: TextLabelProps) {
|
||||
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
||||
const rIsMounted = React.useRef(false)
|
||||
const size = getTextLabelSize(text, font)
|
||||
const color = isDarkMode ? 'white' : 'black'
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(TLDR.normalizeText(e.currentTarget.value))
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// If this keydown was just the meta key or a shortcut
|
||||
// that includes holding the meta key like (Command+V)
|
||||
// then leave the event untouched. We also have to explicitly
|
||||
// Implement undo/redo for some reason in order to get this working
|
||||
// in the vscode extension. Without the below code the following doesn't work
|
||||
//
|
||||
// - You can't cut/copy/paste when when text-editing/focused
|
||||
// - You can't undo/redo when when text-editing/focused
|
||||
// - You can't use Command+A to select all the text, when when text-editing/focused
|
||||
if (!(e.key === 'Meta' || e.metaKey)) {
|
||||
e.stopPropagation()
|
||||
} else if (e.key === 'z' && e.metaKey) {
|
||||
if (e.shiftKey) {
|
||||
document.execCommand('redo', false)
|
||||
} else {
|
||||
document.execCommand('undo', false)
|
||||
}
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
|
||||
e.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
onChange(TLDR.normalizeText(e.currentTarget.value))
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
e.currentTarget.setSelectionRange(0, 0)
|
||||
onBlur?.()
|
||||
},
|
||||
[onBlur]
|
||||
)
|
||||
|
||||
const handleFocus = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return
|
||||
if (!rIsMounted.current) return
|
||||
|
||||
if (document.activeElement === e.currentTarget) {
|
||||
e.currentTarget.select()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e) => {
|
||||
if (isEditing) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
requestAnimationFrame(() => {
|
||||
rIsMounted.current = true
|
||||
const elm = rInput.current
|
||||
if (elm) {
|
||||
elm.focus()
|
||||
elm.select()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
onBlur?.()
|
||||
}
|
||||
}, [isEditing, onBlur])
|
||||
|
||||
const rInnerWrapper = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const elm = rInnerWrapper.current
|
||||
if (!elm) return
|
||||
elm.style.transform = `scale(${scale}, ${scale}) translate(${offsetX}px, ${offsetY}px)`
|
||||
elm.style.width = size[0] + 'px'
|
||||
elm.style.height = size[1] + 'px'
|
||||
}, [size, offsetY, offsetX, scale])
|
||||
|
||||
return (
|
||||
<TextWrapper>
|
||||
<InnerWrapper
|
||||
ref={rInnerWrapper}
|
||||
hasText={!!text}
|
||||
isEditing={isEditing}
|
||||
style={{
|
||||
font,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextArea
|
||||
ref={rInput}
|
||||
style={{
|
||||
font,
|
||||
color,
|
||||
}}
|
||||
name="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="false"
|
||||
autoCapitalize="false"
|
||||
autoCorrect="false"
|
||||
autoSave="false"
|
||||
autoFocus
|
||||
placeholder=""
|
||||
spellCheck="true"
|
||||
wrap="off"
|
||||
dir="auto"
|
||||
datatype="wysiwyg"
|
||||
defaultValue={text}
|
||||
color={color}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={stopPropagation}
|
||||
/>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
​
|
||||
</InnerWrapper>
|
||||
</TextWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
const TextWrapper = styled('div', {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
variants: {
|
||||
isGhost: {
|
||||
false: { opacity: 1 },
|
||||
true: { transition: 'opacity .2s', opacity: GHOSTED_OPACITY },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const commonTextWrapping = {
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
}
|
||||
|
||||
const InnerWrapper = styled('div', {
|
||||
position: 'absolute',
|
||||
padding: '4px',
|
||||
zIndex: 1,
|
||||
minHeight: 1,
|
||||
minWidth: 1,
|
||||
lineHeight: 1,
|
||||
letterSpacing: LETTER_SPACING,
|
||||
outline: 0,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
backfaceVisibility: 'hidden',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
variants: {
|
||||
hasText: {
|
||||
false: {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
true: {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
},
|
||||
isEditing: {
|
||||
false: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
true: {
|
||||
background: '$boundsBg',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
...commonTextWrapping,
|
||||
})
|
||||
|
||||
const TextArea = styled('textarea', {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
resize: 'none',
|
||||
textAlign: 'inherit',
|
||||
minHeight: 'inherit',
|
||||
minWidth: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
outline: 0,
|
||||
fontWeight: 'inherit',
|
||||
overflow: 'hidden',
|
||||
backfaceVisibility: 'hidden',
|
||||
display: 'inline-block',
|
||||
pointerEvents: 'all',
|
||||
background: '$boundsBg',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
...commonTextWrapping,
|
||||
})
|
71
packages/tldraw/src/state/shapes/shared/getTextSize.ts
Normal file
71
packages/tldraw/src/state/shapes/shared/getTextSize.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { LETTER_SPACING } from '~constants'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let melm: any
|
||||
|
||||
function getMeasurementDiv() {
|
||||
// A div used for measurement
|
||||
document.getElementById('__textLabelMeasure')?.remove()
|
||||
|
||||
const pre = document.createElement('pre')
|
||||
pre.id = '__textLabelMeasure'
|
||||
|
||||
Object.assign(pre.style, {
|
||||
whiteSpace: 'pre',
|
||||
width: 'auto',
|
||||
border: '1px solid transparent',
|
||||
padding: '4px',
|
||||
margin: '0px',
|
||||
letterSpacing: `${LETTER_SPACING}px`,
|
||||
opacity: '0',
|
||||
position: 'absolute',
|
||||
top: '-500px',
|
||||
left: '0px',
|
||||
zIndex: '9999',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
alignmentBaseline: 'mathematical',
|
||||
dominantBaseline: 'mathematical',
|
||||
})
|
||||
|
||||
pre.tabIndex = -1
|
||||
|
||||
document.body.appendChild(pre)
|
||||
return pre
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
melm = getMeasurementDiv()
|
||||
}
|
||||
|
||||
let prevText = ''
|
||||
let prevFont = ''
|
||||
let prevSize = [0, 0]
|
||||
|
||||
export function getTextLabelSize(text: string, font: string) {
|
||||
if (!text) {
|
||||
return [16, 32]
|
||||
}
|
||||
|
||||
if (!melm) {
|
||||
// We're in SSR
|
||||
return [10, 10]
|
||||
}
|
||||
|
||||
if (text === prevText && font === prevFont) {
|
||||
return prevSize
|
||||
}
|
||||
|
||||
prevText = text
|
||||
prevFont = font
|
||||
|
||||
melm.innerHTML = `${text}‍`
|
||||
melm.style.font = font
|
||||
|
||||
// In tests, offsetWidth and offsetHeight will be 0
|
||||
const width = melm.offsetWidth || 1
|
||||
const height = melm.offsetHeight || 1
|
||||
|
||||
prevSize = [width, height]
|
||||
return prevSize
|
||||
}
|
|
@ -3,3 +3,5 @@ export * from './transformRectangle'
|
|||
export * from './transformSingleRectangle'
|
||||
export * from './TextAreaUtils'
|
||||
export * from './shape-styles'
|
||||
export * from './getTextAlign'
|
||||
export * from './TextLabel'
|
||||
|
|
|
@ -60,7 +60,6 @@ export const fills: Record<Theme, Record<ColorStyle, string>> = {
|
|||
Object.entries(colors).map(([k, v]) => [k, Utils.lerpColor(v, canvasLight, 0.82)])
|
||||
) as Record<ColorStyle, string>),
|
||||
[ColorStyle.White]: '#fefefe',
|
||||
[ColorStyle.Black]: '#4d4d4d',
|
||||
},
|
||||
dark: {
|
||||
...(Object.fromEntries(
|
||||
|
|
|
@ -21,13 +21,13 @@ describe('When double clicking link controls', () => {
|
|||
{
|
||||
id: 'rect2',
|
||||
type: TDShapeType.Rectangle,
|
||||
point: [100, 0],
|
||||
point: [200, 0],
|
||||
size: [100, 100],
|
||||
},
|
||||
{
|
||||
id: 'rect3',
|
||||
type: TDShapeType.Rectangle,
|
||||
point: [200, 0],
|
||||
point: [400, 0],
|
||||
size: [100, 100],
|
||||
},
|
||||
{
|
||||
|
@ -38,7 +38,7 @@ describe('When double clicking link controls', () => {
|
|||
{
|
||||
id: 'arrow2',
|
||||
type: TDShapeType.Arrow,
|
||||
point: [200, 200],
|
||||
point: [210, 210],
|
||||
}
|
||||
)
|
||||
.select('arrow1')
|
||||
|
@ -48,16 +48,16 @@ describe('When double clicking link controls', () => {
|
|||
.completeSession()
|
||||
.movePointer({ x: 200, y: 200 })
|
||||
.startSession(SessionType.Arrow, 'arrow1', 'end')
|
||||
.movePointer({ x: 150, y: 50 })
|
||||
.movePointer({ x: 250, y: 50 })
|
||||
.completeSession()
|
||||
.select('arrow2')
|
||||
.movePointer({ x: 200, y: 200 })
|
||||
.startSession(SessionType.Arrow, 'arrow2', 'start')
|
||||
.movePointer({ x: 150, y: 50 })
|
||||
.movePointer({ x: 250, y: 50 })
|
||||
.completeSession()
|
||||
.movePointer({ x: 200, y: 200 })
|
||||
.startSession(SessionType.Arrow, 'arrow2', 'end')
|
||||
.movePointer({ x: 250, y: 50 })
|
||||
.movePointer({ x: 450, y: 50 })
|
||||
.completeSession()
|
||||
.selectNone().document
|
||||
|
||||
|
@ -68,22 +68,22 @@ describe('When double clicking link controls', () => {
|
|||
.pointBoundsHandle('center', [100, 100])
|
||||
.expectShapesToBeAtPoints({
|
||||
rect1: [0, 0],
|
||||
rect2: [100, 0],
|
||||
rect3: [200, 0],
|
||||
rect2: [200, 0],
|
||||
rect3: [400, 0],
|
||||
})
|
||||
|
||||
app.movePointer([200, 200]).expectShapesToBeAtPoints({
|
||||
rect1: [100, 100],
|
||||
rect2: [200, 100],
|
||||
rect3: [300, 100],
|
||||
rect2: [300, 100],
|
||||
rect3: [500, 100],
|
||||
})
|
||||
|
||||
app.completeSession().undo()
|
||||
|
||||
app.expectShapesToBeAtPoints({
|
||||
rect1: [0, 0],
|
||||
rect2: [100, 0],
|
||||
rect3: [200, 0],
|
||||
rect2: [200, 0],
|
||||
rect3: [400, 0],
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -95,8 +95,8 @@ describe('When double clicking link controls', () => {
|
|||
.movePointer({ x: 100, y: 100 })
|
||||
|
||||
expect(app.getShape('rect1').point).toEqual([100, 100])
|
||||
expect(app.getShape('rect2').point).toEqual([200, 100])
|
||||
expect(app.getShape('rect3').point).toEqual([300, 100])
|
||||
expect(app.getShape('rect2').point).toEqual([300, 100])
|
||||
expect(app.getShape('rect3').point).toEqual([500, 100])
|
||||
})
|
||||
|
||||
it('moves all downstream shapes when center is dragged', () => {
|
||||
|
@ -107,8 +107,8 @@ describe('When double clicking link controls', () => {
|
|||
.movePointer({ x: 100, y: 100 })
|
||||
|
||||
expect(app.getShape('rect1').point).toEqual([0, 0])
|
||||
expect(app.getShape('rect2').point).toEqual([200, 100])
|
||||
expect(app.getShape('rect3').point).toEqual([300, 100])
|
||||
expect(app.getShape('rect2').point).toEqual([300, 100])
|
||||
expect(app.getShape('rect3').point).toEqual([500, 100])
|
||||
})
|
||||
|
||||
it('selects all linked shapes when center is double clicked', () => {
|
||||
|
|
|
@ -182,37 +182,47 @@ export class SelectTool extends BaseTool<Status> {
|
|||
this.setStatus(Status.Idle)
|
||||
}
|
||||
|
||||
onKeyDown: TLKeyboardEventHandler = (key) => {
|
||||
if (key === 'Escape') {
|
||||
this.onCancel()
|
||||
return
|
||||
}
|
||||
onKeyDown: TLKeyboardEventHandler = (key, info, e) => {
|
||||
switch (key) {
|
||||
case 'Escape': {
|
||||
this.onCancel()
|
||||
break
|
||||
}
|
||||
case ' ': {
|
||||
if (this.status === Status.Idle) {
|
||||
this.setStatus(Status.SpacePanning)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Tab': {
|
||||
if (this.status === Status.Idle && this.app.selectedIds.length === 1) {
|
||||
const [selectedId] = this.app.selectedIds
|
||||
const clonedShape = this.getShapeClone(selectedId, 'right')
|
||||
|
||||
if (key === ' ' && this.status === Status.Idle) {
|
||||
this.setStatus(Status.SpacePanning)
|
||||
}
|
||||
|
||||
if (key === 'Tab') {
|
||||
if (this.status === Status.Idle && this.app.selectedIds.length === 1) {
|
||||
const [selectedId] = this.app.selectedIds
|
||||
const clonedShape = this.getShapeClone(selectedId, 'right')
|
||||
|
||||
if (clonedShape) {
|
||||
this.app.createShapes(clonedShape)
|
||||
this.setStatus(Status.Idle)
|
||||
if (clonedShape.type === TDShapeType.Sticky) {
|
||||
this.app.select(clonedShape.id)
|
||||
this.app.setEditingId(clonedShape.id)
|
||||
if (clonedShape) {
|
||||
this.app.createShapes(clonedShape)
|
||||
this.setStatus(Status.Idle)
|
||||
if (clonedShape.type === TDShapeType.Sticky) {
|
||||
this.app.select(clonedShape.id)
|
||||
this.app.setEditingId(clonedShape.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Meta':
|
||||
case 'Control':
|
||||
case 'Alt': {
|
||||
this.app.updateSession()
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
const { pageState } = this.app
|
||||
if (pageState.selectedIds.length === 1 && !pageState.editingId) {
|
||||
this.app.setEditingId(pageState.selectedIds[0])
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
|
||||
this.app.updateSession()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -624,19 +634,29 @@ export class SelectTool extends BaseTool<Status> {
|
|||
}
|
||||
|
||||
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = (info) => {
|
||||
if (info.target === 'center' || info.target === 'left' || info.target === 'right') {
|
||||
this.app.select(
|
||||
...TLDR.getLinkedShapeIds(
|
||||
this.app.state,
|
||||
this.app.currentPageId,
|
||||
info.target,
|
||||
info.shiftKey
|
||||
switch (info.target) {
|
||||
case 'center':
|
||||
case 'left':
|
||||
case 'right': {
|
||||
this.app.select(
|
||||
...TLDR.getLinkedShapeIds(
|
||||
this.app.state,
|
||||
this.app.currentPageId,
|
||||
info.target,
|
||||
info.shiftKey
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (this.app.selectedIds.length === 1) {
|
||||
this.app.resetBounds(this.app.selectedIds)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (this.app.selectedIds.length === 1) {
|
||||
this.app.resetBounds(this.app.selectedIds)
|
||||
const shape = this.app.getShape(this.app.selectedIds[0])
|
||||
if ('label' in shape) {
|
||||
this.app.setEditingId(shape.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -652,6 +672,19 @@ export class SelectTool extends BaseTool<Status> {
|
|||
}
|
||||
|
||||
onDoubleClickHandle: TLPointerEventHandler = (info) => {
|
||||
if (info.target === 'bend') {
|
||||
const { selectedIds } = this.app
|
||||
if (selectedIds.length !== 1) return
|
||||
const shape = this.app.getShape(selectedIds[0])
|
||||
if (
|
||||
TLDR.getShapeUtil(shape.type).canEdit &&
|
||||
(shape.parentId === this.app.currentPageId || shape.parentId === this.selectedGroupId)
|
||||
) {
|
||||
this.app.setEditingId(shape.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.app.toggleDecoration(info.target)
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ export const mockDocument: TDDocument = {
|
|||
size: SizeStyle.Medium,
|
||||
color: ColorStyle.Blue,
|
||||
},
|
||||
label: '',
|
||||
},
|
||||
rect2: {
|
||||
id: 'rect2',
|
||||
|
@ -35,6 +36,8 @@ export const mockDocument: TDDocument = {
|
|||
size: SizeStyle.Medium,
|
||||
color: ColorStyle.Blue,
|
||||
},
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
},
|
||||
rect3: {
|
||||
id: 'rect3',
|
||||
|
@ -49,6 +52,8 @@ export const mockDocument: TDDocument = {
|
|||
size: SizeStyle.Medium,
|
||||
color: ColorStyle.Blue,
|
||||
},
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
},
|
||||
},
|
||||
bindings: {},
|
||||
|
|
|
@ -193,6 +193,7 @@ export enum TDStatus {
|
|||
PointingHandle = 'pointingHandle',
|
||||
PointingBounds = 'pointingBounds',
|
||||
PointingBoundsHandle = 'pointingBoundsHandle',
|
||||
TranslatingLabel = 'translatingLabel',
|
||||
TranslatingHandle = 'translatingHandle',
|
||||
Translating = 'translating',
|
||||
Transforming = 'transforming',
|
||||
|
@ -295,7 +296,6 @@ export interface TDBaseShape extends TLShape {
|
|||
handles?: Record<string, TldrawHandle>
|
||||
}
|
||||
|
||||
// The shape created with the draw tool
|
||||
export interface DrawShape extends TDBaseShape {
|
||||
type: TDShapeType.Draw
|
||||
points: number[][]
|
||||
|
@ -308,6 +308,27 @@ export interface TldrawHandle extends TLHandle {
|
|||
bindingId?: string
|
||||
}
|
||||
|
||||
export interface RectangleShape extends TDBaseShape {
|
||||
type: TDShapeType.Rectangle
|
||||
size: number[]
|
||||
label?: string
|
||||
labelPoint?: number[]
|
||||
}
|
||||
|
||||
export interface EllipseShape extends TDBaseShape {
|
||||
type: TDShapeType.Ellipse
|
||||
radius: number[]
|
||||
label?: string
|
||||
labelPoint?: number[]
|
||||
}
|
||||
|
||||
export interface TriangleShape extends TDBaseShape {
|
||||
type: TDShapeType.Triangle
|
||||
size: number[]
|
||||
label?: string
|
||||
labelPoint?: number[]
|
||||
}
|
||||
|
||||
// The shape created with the arrow tool
|
||||
export interface ArrowShape extends TDBaseShape {
|
||||
type: TDShapeType.Arrow
|
||||
|
@ -322,6 +343,8 @@ export interface ArrowShape extends TDBaseShape {
|
|||
end?: Decoration
|
||||
middle?: Decoration
|
||||
}
|
||||
label?: string
|
||||
labelPoint?: number[]
|
||||
}
|
||||
|
||||
export interface ArrowBinding extends TLBinding {
|
||||
|
@ -332,18 +355,6 @@ export interface ArrowBinding extends TLBinding {
|
|||
|
||||
export type TDBinding = ArrowBinding
|
||||
|
||||
// The shape created by the ellipse tool
|
||||
export interface EllipseShape extends TDBaseShape {
|
||||
type: TDShapeType.Ellipse
|
||||
radius: number[]
|
||||
}
|
||||
|
||||
// The shape created by the rectangle tool
|
||||
export interface RectangleShape extends TDBaseShape {
|
||||
type: TDShapeType.Rectangle
|
||||
size: number[]
|
||||
}
|
||||
|
||||
export interface ImageShape extends TDBaseShape {
|
||||
type: TDShapeType.Image
|
||||
size: number[]
|
||||
|
@ -358,12 +369,6 @@ export interface VideoShape extends TDBaseShape {
|
|||
currentTime: number
|
||||
}
|
||||
|
||||
// The shape created by the Triangle tool
|
||||
export interface TriangleShape extends TDBaseShape {
|
||||
type: TDShapeType.Triangle
|
||||
size: number[]
|
||||
}
|
||||
|
||||
// The shape created by the text tool
|
||||
export interface TextShape extends TDBaseShape {
|
||||
type: TDShapeType.Text
|
||||
|
|
|
@ -505,6 +505,20 @@ export class Vec {
|
|||
if (A[0] === B[0]) return NaN
|
||||
return (A[1] - B[1]) / (A[0] - B[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a vector comprised of the maximum of two or more vectors.
|
||||
*/
|
||||
static max = (...v: number[][]) => {
|
||||
return [Math.max(...v.map((a) => a[0])), Math.max(...v.map((a) => a[1]))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a vector comprised of the minimum of two or more vectors.
|
||||
*/
|
||||
static min = (...v: number[][]) => {
|
||||
return [Math.max(...v.map((a) => a[0])), Math.max(...v.map((a) => a[1]))]
|
||||
}
|
||||
}
|
||||
|
||||
export default Vec
|
||||
|
|
Loading…
Reference in a new issue