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,
|
||||
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')
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue