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,
rx: number,
ry: number,
rotation: number
rotation = 0
): TLBounds {
const c = Math.cos(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.
* @param stroke ;
*/
static getSvgPathFromStroke(stroke: number[][]): string {
if (!stroke.length) return ''
static getSvgPathFromStroke(points: number[][]): string {
if (!points.length) {
return ''
}
const max = stroke.length - 1
const max = points.length - 1
const d = stroke.reduce(
(acc, [x0, y0], i, arr) => {
if (i === max) return acc
const [x1, y1] = arr[i + 1]
acc.push(` ${x0},${y0} ${(x0 + x1) / 2},${(y0 + y1) / 2}`)
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push('Z')
} else {
acc.push(point, Vec.med(point, arr[i + 1]))
}
return acc
},
['M ', `${stroke[0][0]},${stroke[0][1]}`, ' Q']
['M', points[0], 'Q']
)
return d
.concat('Z')
.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/intersect": "^0.0.95",
"@tldraw/vec": "^0.0.95",
"perfect-freehand": "^1.0.4",
"perfect-freehand": "^1.0.6",
"react-hotkeys-hook": "^3.4.0",
"rko": "^0.5.25"
},

View file

@ -51,9 +51,9 @@ const strokeWidths = {
}
const fontSizes = {
[SizeStyle.Small]: 24,
[SizeStyle.Medium]: 48,
[SizeStyle.Large]: 72,
[SizeStyle.Small]: 40,
[SizeStyle.Medium]: 64,
[SizeStyle.Large]: 140,
auto: 'auto',
}

View file

@ -5,13 +5,11 @@ import { intersectBoundsBounds, intersectBoundsPolyline } from '@tldraw/intersec
import getStroke, { getStrokePoints } from 'perfect-freehand'
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { DrawShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
import { EASINGS } from '~state/utils'
const pointsBoundsCache = new WeakMap<DrawShape['points'], TLBounds>([])
const shapeBoundsCache = new Map<string, TLBounds>()
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']>([])
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) {
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 strokeWidth = styles.strokeWidth
@ -64,15 +72,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
points.length > 3 &&
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) {
const polygonPathData = Utils.getFromCache(polygonCache, points, () => getFillPath(shape))
const drawPathData = isEditing
? getDrawStrokePath(shape, true)
: Utils.getFromCache(drawPathCache, points, () => getDrawStrokePath(shape, false))
return (
<SVGContainer ref={ref} {...events}>
{shouldFill && (
@ -86,7 +86,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
/>
)}
<path
d={drawPathData}
d={pathData}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={strokeWidth}
@ -114,14 +114,12 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
[DashStyle.Dashed]: `-${strokeWidth}`,
}[style.dash]
const path = Utils.getFromCache(simplePathCache, points, () => getSolidStrokePath(shape))
const sw = strokeWidth * 1.618
return (
<SVGContainer ref={ref} {...events}>
<path
d={path}
d={pathData}
fill={shouldFill ? styles.fill : 'none'}
stroke="transparent"
strokeWidth={Math.min(4, strokeWidth * 2)}
@ -130,7 +128,7 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
pointerEvents={shouldFill ? 'all' : 'stroke'}
/>
<path
d={path}
d={pathData}
fill="transparent"
stroke={styles.stroke}
strokeWidth={sw}
@ -147,6 +145,10 @@ export const Draw = new ShapeUtil<DrawShape, SVGSVGElement, TLDrawMeta>(() => ({
Indicator({ shape }) {
const { points } = shape
const pathData = React.useMemo(() => {
return getSolidStrokePath(shape)
}, [points])
const bounds = this.getBounds(shape)
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} />
}
const path = Utils.getFromCache(simplePathCache, points, () => getSolidStrokePath(shape))
return <path d={path} />
return <path d={pathData} />
},
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 stroke = getStroke(shape.points.slice(2), {
size: 1 + styles.strokeWidth * 2,
thinning: 0.7,
size: 1 + styles.strokeWidth * 1.618,
thinning: 0.6,
streamline: 0.7,
smoothing: 0.5,
end: { taper: +styles.strokeWidth * 50 },
start: { taper: +styles.strokeWidth * 50 },
end: { taper: styles.strokeWidth * 10, easing: EASINGS.easeOutQuad },
easing: (t) => Math.sin((t * Math.PI) / 2),
...options,
last: !isEditing,

View file

@ -149,13 +149,19 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
shape.point[1],
shape.radius[0],
shape.radius[1],
shape.rotation || 0
0
)
})
},
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[] {
@ -321,25 +327,30 @@ function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
const ry = radiusY + getRandom() * strokeWidth * 2
const points: number[][] = []
const start = Math.PI + Math.PI * getRandom()
const extra = Math.abs(getRandom())
for (let i = 0; i < 32; i++) {
const t = EASINGS.easeInOutSine(i / 32)
const perimeter = Utils.perimeterOfEllipse(rx, ry)
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 x = rx * Math.cos(rads) + center[0]
const y = ry * Math.sin(rads) + center[1]
points.push([x, y, t + 0.5 + getRandom() / 2])
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])
}
const stroke = getStroke(points, {
size: 1 + strokeWidth,
thinning: 0.6,
easing: EASINGS.easeInOutSine,
end: { taper: strokeWidth * 20 },
start: { taper: strokeWidth * 20 },
size: 1 + strokeWidth * 2,
thinning: 0.5,
end: { taper: perimeter / 8 },
start: { taper: perimeter / 12 },
streamline: 0,
simulatePressure: false,
simulatePressure: true,
})
return Utils.getSvgPathFromStroke(stroke)

View file

@ -201,22 +201,26 @@ function getRectanglePath(shape: RectangleShape) {
const lines = Utils.shuffleArr(
[
Vec.pointsBetween(tr, br, py, EASINGS.easeInOutCubic),
Vec.pointsBetween(br, bl, px, EASINGS.easeInOutCubic),
Vec.pointsBetween(bl, tl, py, EASINGS.easeInOutCubic),
Vec.pointsBetween(tl, tr, px, EASINGS.easeInOutCubic),
Vec.pointsBetween(tr, br, py, EASINGS.linear),
Vec.pointsBetween(br, bl, px, EASINGS.linear),
Vec.pointsBetween(bl, tl, py, EASINGS.linear),
Vec.pointsBetween(tl, tr, px, EASINGS.linear),
],
rm
)
const edgeDist = rm % 2 === 0 ? px : py
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,
thinning: 0.6,
easing: EASINGS.easeOutSine,
end: { cap: true },
start: { cap: true },
size: 1 + styles.strokeWidth * 2,
thinning: 0.5,
end: { taper: edgeDist },
start: { taper: edgeDist },
simulatePressure: false,
last: true,
}

View file

@ -3,9 +3,6 @@ import { Vec } from '@tldraw/vec'
import { Data, DrawShape, Session, TLDrawStatus } from '~types'
import { TLDR } from '~state/tldr'
// TODO
// [ ] - Solve flat lines at corners on perfectly straight lines
export class DrawSession implements Session {
id = 'draw'
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
// when the draw session ends; if the user hasn't added additional
// points, this single point will be interpreted as a "dot" shape.
this.points = []
this.points = [[0, 0, point[2] || 0.5]]
}
start = () => void null
@ -51,7 +48,7 @@ export class DrawSession implements Session {
// Drawing while holding shift will "lock" the pen to either the
// x or y axis, depending on the locking direction.
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
// early based on the bigger dimension.
if (!this.lockedDirection) {
@ -174,8 +171,6 @@ export class DrawSession implements Session {
const { snapshot } = this
const pageId = data.appState.currentPageId
this.points.push(this.last)
return {
id: 'create_draw',
before: {

View file

@ -10730,10 +10730,10 @@ pbkdf2@^3.0.3:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
perfect-freehand@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.4.tgz#1c5318164b10a74a4e6664b8519ff68799b8ab52"
integrity sha512-feJV7C2LSiz6czFZuexYzxh8GHzaQ32bU4Vx+Y4xdCZYxnPFFyCoMVbVL3797zmk0v5rzGJkVfMQRUkLb/uZIg==
perfect-freehand@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.6.tgz#feeb25450241f036ec13b43fa84bbb16f8e78e0f"
integrity sha512-wWkFwpgUirsfBDTb9nG6+VnFR0ge119QKU2Nu96vR4MHZMPGfOsQRD7cUk+9CK5P+TUmnrtX8yOEzUrQ6KHJoA==
performance-now@^2.1.0:
version "2.1.0"