Tweaks the draw appearance, fix ellipse rotation (#93)

This commit is contained in:
Steve Ruiz 2021-09-18 18:39:34 +01:00 committed by GitHub
parent 3c3c23ec4d
commit bec693a1d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 93 additions and 79 deletions

View file

@ -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')
} }
/* -------------------------------------------------- */ /* -------------------------------------------------- */

View file

@ -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"
}, },

View file

@ -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',
} }

View file

@ -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,

View file

@ -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)

View file

@ -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,
} }

View file

@ -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: {

View file

@ -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"