Tweaks the draw appearance, fix ellipse rotation (#93)
This commit is contained in:
parent
3c3c23ec4d
commit
bec693a1d9
8 changed files with 93 additions and 79 deletions
|
@ -1101,7 +1101,7 @@ export class Utils {
|
||||||
y: number,
|
y: number,
|
||||||
rx: number,
|
rx: number,
|
||||||
ry: number,
|
ry: number,
|
||||||
rotation: number
|
rotation = 0
|
||||||
): TLBounds {
|
): TLBounds {
|
||||||
const c = Math.cos(rotation)
|
const c = Math.cos(rotation)
|
||||||
const s = Math.sin(rotation)
|
const s = Math.sin(rotation)
|
||||||
|
@ -1649,29 +1649,34 @@ left past the initial left edge) then swap points on that axis.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regex to trim numbers to 2 decimal places
|
||||||
|
static TRIM_NUMBERS = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn an array of points into a path of quadradic curves.
|
* Turn an array of points into a path of quadradic curves.
|
||||||
* @param stroke ;
|
* @param stroke ;
|
||||||
*/
|
*/
|
||||||
static getSvgPathFromStroke(stroke: number[][]): string {
|
static getSvgPathFromStroke(points: number[][]): string {
|
||||||
if (!stroke.length) return ''
|
if (!points.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const max = stroke.length - 1
|
const max = points.length - 1
|
||||||
|
|
||||||
const d = stroke.reduce(
|
return points
|
||||||
(acc, [x0, y0], i, arr) => {
|
.reduce(
|
||||||
if (i === max) return acc
|
(acc, point, i, arr) => {
|
||||||
const [x1, y1] = arr[i + 1]
|
if (i === max) {
|
||||||
acc.push(` ${x0},${y0} ${(x0 + x1) / 2},${(y0 + y1) / 2}`)
|
acc.push('Z')
|
||||||
return acc
|
} else {
|
||||||
},
|
acc.push(point, Vec.med(point, arr[i + 1]))
|
||||||
['M ', `${stroke[0][0]},${stroke[0][1]}`, ' Q']
|
}
|
||||||
)
|
return acc
|
||||||
|
},
|
||||||
return d
|
['M', points[0], 'Q']
|
||||||
.concat('Z')
|
)
|
||||||
.join('')
|
.join(' ')
|
||||||
.replaceAll(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, '$1')
|
.replaceAll(this.TRIM_NUMBERS, '$1')
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------- */
|
/* -------------------------------------------------- */
|
||||||
|
|
|
@ -68,7 +68,7 @@
|
||||||
"@tldraw/core": "^0.0.95",
|
"@tldraw/core": "^0.0.95",
|
||||||
"@tldraw/intersect": "^0.0.95",
|
"@tldraw/intersect": "^0.0.95",
|
||||||
"@tldraw/vec": "^0.0.95",
|
"@tldraw/vec": "^0.0.95",
|
||||||
"perfect-freehand": "^1.0.4",
|
"perfect-freehand": "^1.0.6",
|
||||||
"react-hotkeys-hook": "^3.4.0",
|
"react-hotkeys-hook": "^3.4.0",
|
||||||
"rko": "^0.5.25"
|
"rko": "^0.5.25"
|
||||||
},
|
},
|
||||||
|
|
|
@ -51,9 +51,9 @@ const strokeWidths = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontSizes = {
|
const fontSizes = {
|
||||||
[SizeStyle.Small]: 24,
|
[SizeStyle.Small]: 40,
|
||||||
[SizeStyle.Medium]: 48,
|
[SizeStyle.Medium]: 64,
|
||||||
[SizeStyle.Large]: 72,
|
[SizeStyle.Large]: 140,
|
||||||
auto: 'auto',
|
auto: 'auto',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,11 @@ import { intersectBoundsBounds, intersectBoundsPolyline } from '@tldraw/intersec
|
||||||
import getStroke, { getStrokePoints } from 'perfect-freehand'
|
import getStroke, { getStrokePoints } from 'perfect-freehand'
|
||||||
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
|
||||||
import { DrawShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
|
import { DrawShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
|
||||||
|
import { EASINGS } from '~state/utils'
|
||||||
|
|
||||||
const pointsBoundsCache = new WeakMap<DrawShape['points'], TLBounds>([])
|
const pointsBoundsCache = new WeakMap<DrawShape['points'], TLBounds>([])
|
||||||
const shapeBoundsCache = new Map<string, TLBounds>()
|
const shapeBoundsCache = new Map<string, TLBounds>()
|
||||||
const rotatedCache = new WeakMap<DrawShape, number[][]>([])
|
const rotatedCache = new WeakMap<DrawShape, number[][]>([])
|
||||||
const drawPathCache = new WeakMap<DrawShape['points'], string>([])
|
|
||||||
const simplePathCache = new WeakMap<DrawShape['points'], string>([])
|
|
||||||
const polygonCache = new WeakMap<DrawShape['points'], string>([])
|
|
||||||
const pointCache = new WeakSet<DrawShape['point']>([])
|
const pointCache = new WeakSet<DrawShape['point']>([])
|
||||||
|
|
||||||
export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
|
@ -34,6 +32,16 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
Component({ shape, meta, events, isEditing }, ref) {
|
Component({ shape, meta, events, isEditing }, ref) {
|
||||||
const { points, style } = shape
|
const { points, style } = shape
|
||||||
|
|
||||||
|
const polygonPathData = React.useMemo(() => {
|
||||||
|
return getFillPath(shape)
|
||||||
|
}, [points, style.size, isEditing])
|
||||||
|
|
||||||
|
const pathData = React.useMemo(() => {
|
||||||
|
return style.dash === DashStyle.Draw
|
||||||
|
? getDrawStrokePath(shape, isEditing)
|
||||||
|
: getSolidStrokePath(shape)
|
||||||
|
}, [points, style.size, style.dash, isEditing])
|
||||||
|
|
||||||
const styles = getShapeStyle(style, meta.isDarkMode)
|
const styles = getShapeStyle(style, meta.isDarkMode)
|
||||||
|
|
||||||
const strokeWidth = styles.strokeWidth
|
const strokeWidth = styles.strokeWidth
|
||||||
|
@ -64,15 +72,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
points.length > 3 &&
|
points.length > 3 &&
|
||||||
Vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2
|
Vec.dist(points[0], points[points.length - 1]) < +styles.strokeWidth * 2
|
||||||
|
|
||||||
// For drawn lines, draw a line from the path cache
|
|
||||||
|
|
||||||
if (shape.style.dash === DashStyle.Draw) {
|
if (shape.style.dash === DashStyle.Draw) {
|
||||||
const polygonPathData = Utils.getFromCache(polygonCache, points, () => getFillPath(shape))
|
|
||||||
|
|
||||||
const drawPathData = isEditing
|
|
||||||
? getDrawStrokePath(shape, true)
|
|
||||||
: Utils.getFromCache(drawPathCache, points, () => getDrawStrokePath(shape, false))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SVGContainer ref={ref} {...events}>
|
<SVGContainer ref={ref} {...events}>
|
||||||
{shouldFill && (
|
{shouldFill && (
|
||||||
|
@ -86,7 +86,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<path
|
<path
|
||||||
d={drawPathData}
|
d={pathData}
|
||||||
fill={styles.stroke}
|
fill={styles.stroke}
|
||||||
stroke={styles.stroke}
|
stroke={styles.stroke}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
|
@ -114,14 +114,12 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
[DashStyle.Dashed]: `-${strokeWidth}`,
|
[DashStyle.Dashed]: `-${strokeWidth}`,
|
||||||
}[style.dash]
|
}[style.dash]
|
||||||
|
|
||||||
const path = Utils.getFromCache(simplePathCache, points, () => getSolidStrokePath(shape))
|
|
||||||
|
|
||||||
const sw = strokeWidth * 1.618
|
const sw = strokeWidth * 1.618
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SVGContainer ref={ref} {...events}>
|
<SVGContainer ref={ref} {...events}>
|
||||||
<path
|
<path
|
||||||
d={path}
|
d={pathData}
|
||||||
fill={shouldFill ? styles.fill : 'none'}
|
fill={shouldFill ? styles.fill : 'none'}
|
||||||
stroke="transparent"
|
stroke="transparent"
|
||||||
strokeWidth={Math.min(4, strokeWidth * 2)}
|
strokeWidth={Math.min(4, strokeWidth * 2)}
|
||||||
|
@ -130,7 +128,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
pointerEvents={shouldFill ? 'all' : 'stroke'}
|
pointerEvents={shouldFill ? 'all' : 'stroke'}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d={path}
|
d={pathData}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
stroke={styles.stroke}
|
stroke={styles.stroke}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
|
@ -147,6 +145,10 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
Indicator({ shape }) {
|
Indicator({ shape }) {
|
||||||
const { points } = shape
|
const { points } = shape
|
||||||
|
|
||||||
|
const pathData = React.useMemo(() => {
|
||||||
|
return getSolidStrokePath(shape)
|
||||||
|
}, [points])
|
||||||
|
|
||||||
const bounds = this.getBounds(shape)
|
const bounds = this.getBounds(shape)
|
||||||
|
|
||||||
const verySmall = bounds.width < 4 && bounds.height < 4
|
const verySmall = bounds.width < 4 && bounds.height < 4
|
||||||
|
@ -155,9 +157,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
|
||||||
return <circle x={bounds.width / 2} y={bounds.height / 2} r={1} />
|
return <circle x={bounds.width / 2} y={bounds.height / 2} r={1} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = Utils.getFromCache(simplePathCache, points, () => getSolidStrokePath(shape))
|
return <path d={pathData} />
|
||||||
|
|
||||||
return <path d={path} />
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getBounds(shape: DrawShape): TLBounds {
|
getBounds(shape: DrawShape): TLBounds {
|
||||||
|
@ -300,12 +300,11 @@ function getDrawStrokePath(shape: DrawShape, isEditing: boolean) {
|
||||||
const options = shape.points[1][2] === 0.5 ? simulatePressureSettings : realPressureSettings
|
const options = shape.points[1][2] === 0.5 ? simulatePressureSettings : realPressureSettings
|
||||||
|
|
||||||
const stroke = getStroke(shape.points.slice(2), {
|
const stroke = getStroke(shape.points.slice(2), {
|
||||||
size: 1 + styles.strokeWidth * 2,
|
size: 1 + styles.strokeWidth * 1.618,
|
||||||
thinning: 0.7,
|
thinning: 0.6,
|
||||||
streamline: 0.7,
|
streamline: 0.7,
|
||||||
smoothing: 0.5,
|
smoothing: 0.5,
|
||||||
end: { taper: +styles.strokeWidth * 50 },
|
end: { taper: styles.strokeWidth * 10, easing: EASINGS.easeOutQuad },
|
||||||
start: { taper: +styles.strokeWidth * 50 },
|
|
||||||
easing: (t) => Math.sin((t * Math.PI) / 2),
|
easing: (t) => Math.sin((t * Math.PI) / 2),
|
||||||
...options,
|
...options,
|
||||||
last: !isEditing,
|
last: !isEditing,
|
||||||
|
|
|
@ -149,13 +149,19 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
|
||||||
shape.point[1],
|
shape.point[1],
|
||||||
shape.radius[0],
|
shape.radius[0],
|
||||||
shape.radius[1],
|
shape.radius[1],
|
||||||
shape.rotation || 0
|
0
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
getRotatedBounds(shape) {
|
getRotatedBounds(shape) {
|
||||||
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
|
return Utils.getRotatedEllipseBounds(
|
||||||
|
shape.point[0],
|
||||||
|
shape.point[1],
|
||||||
|
shape.radius[0],
|
||||||
|
shape.radius[1],
|
||||||
|
shape.rotation
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
getCenter(shape): number[] {
|
getCenter(shape): number[] {
|
||||||
|
@ -321,25 +327,30 @@ function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
|
||||||
const ry = radiusY + getRandom() * strokeWidth * 2
|
const ry = radiusY + getRandom() * strokeWidth * 2
|
||||||
|
|
||||||
const points: number[][] = []
|
const points: number[][] = []
|
||||||
|
|
||||||
const start = Math.PI + Math.PI * getRandom()
|
const start = Math.PI + Math.PI * getRandom()
|
||||||
|
|
||||||
const extra = Math.abs(getRandom())
|
const extra = Math.abs(getRandom())
|
||||||
|
|
||||||
for (let i = 0; i < 32; i++) {
|
const perimeter = Utils.perimeterOfEllipse(rx, ry)
|
||||||
const t = EASINGS.easeInOutSine(i / 32)
|
|
||||||
|
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 rads = start * 2 + Math.PI * (2 + extra) * t
|
||||||
const x = rx * Math.cos(rads) + center[0]
|
const c = Math.cos(rads)
|
||||||
const y = ry * Math.sin(rads) + center[1]
|
const s = Math.sin(rads)
|
||||||
points.push([x, y, t + 0.5 + getRandom() / 2])
|
points.push([rx * c + center[0], ry * s + center[1], t + 0.5 + getRandom() / 2])
|
||||||
}
|
}
|
||||||
|
|
||||||
const stroke = getStroke(points, {
|
const stroke = getStroke(points, {
|
||||||
size: 1 + strokeWidth,
|
size: 1 + strokeWidth * 2,
|
||||||
thinning: 0.6,
|
thinning: 0.5,
|
||||||
easing: EASINGS.easeInOutSine,
|
end: { taper: perimeter / 8 },
|
||||||
end: { taper: strokeWidth * 20 },
|
start: { taper: perimeter / 12 },
|
||||||
start: { taper: strokeWidth * 20 },
|
|
||||||
streamline: 0,
|
streamline: 0,
|
||||||
simulatePressure: false,
|
simulatePressure: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return Utils.getSvgPathFromStroke(stroke)
|
return Utils.getSvgPathFromStroke(stroke)
|
||||||
|
|
|
@ -201,22 +201,26 @@ function getRectanglePath(shape: RectangleShape) {
|
||||||
|
|
||||||
const lines = Utils.shuffleArr(
|
const lines = Utils.shuffleArr(
|
||||||
[
|
[
|
||||||
Vec.pointsBetween(tr, br, py, EASINGS.easeInOutCubic),
|
Vec.pointsBetween(tr, br, py, EASINGS.linear),
|
||||||
Vec.pointsBetween(br, bl, px, EASINGS.easeInOutCubic),
|
Vec.pointsBetween(br, bl, px, EASINGS.linear),
|
||||||
Vec.pointsBetween(bl, tl, py, EASINGS.easeInOutCubic),
|
Vec.pointsBetween(bl, tl, py, EASINGS.linear),
|
||||||
Vec.pointsBetween(tl, tr, px, EASINGS.easeInOutCubic),
|
Vec.pointsBetween(tl, tr, px, EASINGS.linear),
|
||||||
],
|
],
|
||||||
rm
|
rm
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const edgeDist = rm % 2 === 0 ? px : py
|
||||||
|
|
||||||
const stroke = getStroke(
|
const stroke = getStroke(
|
||||||
[...lines.flat(), ...lines[0], ...lines[1]].slice(4, Math.floor((rm % 2 === 0 ? px : py) / -2)),
|
[...lines.flat(), ...lines[0], ...lines[1]].slice(
|
||||||
|
4,
|
||||||
|
Math.floor((rm % 2 === 0 ? px : py) / -2) + 2
|
||||||
|
),
|
||||||
{
|
{
|
||||||
size: 1 + styles.strokeWidth,
|
size: 1 + styles.strokeWidth * 2,
|
||||||
thinning: 0.6,
|
thinning: 0.5,
|
||||||
easing: EASINGS.easeOutSine,
|
end: { taper: edgeDist },
|
||||||
end: { cap: true },
|
start: { taper: edgeDist },
|
||||||
start: { cap: true },
|
|
||||||
simulatePressure: false,
|
simulatePressure: false,
|
||||||
last: true,
|
last: true,
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,6 @@ import { Vec } from '@tldraw/vec'
|
||||||
import { Data, DrawShape, Session, TLDrawStatus } from '~types'
|
import { Data, DrawShape, Session, TLDrawStatus } from '~types'
|
||||||
import { TLDR } from '~state/tldr'
|
import { TLDR } from '~state/tldr'
|
||||||
|
|
||||||
// TODO
|
|
||||||
// [ ] - Solve flat lines at corners on perfectly straight lines
|
|
||||||
|
|
||||||
export class DrawSession implements Session {
|
export class DrawSession implements Session {
|
||||||
id = 'draw'
|
id = 'draw'
|
||||||
status = TLDrawStatus.Creating
|
status = TLDrawStatus.Creating
|
||||||
|
@ -30,7 +27,7 @@ export class DrawSession implements Session {
|
||||||
// Add a first point but don't update the shape yet. We'll update
|
// Add a first point but don't update the shape yet. We'll update
|
||||||
// when the draw session ends; if the user hasn't added additional
|
// when the draw session ends; if the user hasn't added additional
|
||||||
// points, this single point will be interpreted as a "dot" shape.
|
// points, this single point will be interpreted as a "dot" shape.
|
||||||
this.points = []
|
this.points = [[0, 0, point[2] || 0.5]]
|
||||||
}
|
}
|
||||||
|
|
||||||
start = () => void null
|
start = () => void null
|
||||||
|
@ -51,7 +48,7 @@ export class DrawSession implements Session {
|
||||||
// Drawing while holding shift will "lock" the pen to either the
|
// Drawing while holding shift will "lock" the pen to either the
|
||||||
// x or y axis, depending on the locking direction.
|
// x or y axis, depending on the locking direction.
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
if (!this.isLocked && this.points.length > 1) {
|
if (!this.isLocked && this.points.length > 2) {
|
||||||
// If we're locking before knowing what direction we're in, set it
|
// If we're locking before knowing what direction we're in, set it
|
||||||
// early based on the bigger dimension.
|
// early based on the bigger dimension.
|
||||||
if (!this.lockedDirection) {
|
if (!this.lockedDirection) {
|
||||||
|
@ -174,8 +171,6 @@ export class DrawSession implements Session {
|
||||||
const { snapshot } = this
|
const { snapshot } = this
|
||||||
const pageId = data.appState.currentPageId
|
const pageId = data.appState.currentPageId
|
||||||
|
|
||||||
this.points.push(this.last)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'create_draw',
|
id: 'create_draw',
|
||||||
before: {
|
before: {
|
||||||
|
|
|
@ -10730,10 +10730,10 @@ pbkdf2@^3.0.3:
|
||||||
safe-buffer "^5.0.1"
|
safe-buffer "^5.0.1"
|
||||||
sha.js "^2.4.8"
|
sha.js "^2.4.8"
|
||||||
|
|
||||||
perfect-freehand@^1.0.4:
|
perfect-freehand@^1.0.6:
|
||||||
version "1.0.4"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.4.tgz#1c5318164b10a74a4e6664b8519ff68799b8ab52"
|
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.6.tgz#feeb25450241f036ec13b43fa84bbb16f8e78e0f"
|
||||||
integrity sha512-feJV7C2LSiz6czFZuexYzxh8GHzaQ32bU4Vx+Y4xdCZYxnPFFyCoMVbVL3797zmk0v5rzGJkVfMQRUkLb/uZIg==
|
integrity sha512-wWkFwpgUirsfBDTb9nG6+VnFR0ge119QKU2Nu96vR4MHZMPGfOsQRD7cUk+9CK5P+TUmnrtX8yOEzUrQ6KHJoA==
|
||||||
|
|
||||||
performance-now@^2.1.0:
|
performance-now@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue