diff --git a/package.json b/package.json index 9c930269d..684180aee 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ }, "lint-staged": { "*.@(ts|tsx)": [ - "yarn lint", - "yarn format" + "yarn format", + "yarn lint" ] }, "dependencies": { @@ -57,7 +57,7 @@ "monaco-editor": "^0.25.2", "next": "^11.0.1", "next-auth": "^3.27.0", - "next-pwa": "^5.2.21", + "next-pwa": "^5.2.23", "perfect-freehand": "^0.4.91", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/state/commands/arrow.ts b/state/commands/arrow.ts deleted file mode 100644 index f80c9c85f..000000000 --- a/state/commands/arrow.ts +++ /dev/null @@ -1,46 +0,0 @@ -import Command from './command' -import history from '../history' -import { Data } from 'types' -import tld from 'utils/tld' -import { ArrowSnapshot } from 'state/sessions/arrow-session' - -export default function arrowCommand( - data: Data, - before: ArrowSnapshot, - after: ArrowSnapshot -): void { - history.execute( - data, - new Command({ - name: 'point_arrow', - category: 'canvas', - manualSelection: true, - do(data, isInitial) { - if (isInitial) return - - const { initialShape } = after - - const page = tld.getPage(data) - - page.shapes[initialShape.id] = initialShape - - const selectedIds = tld.getSelectedIds(data) - selectedIds.clear() - selectedIds.add(initialShape.id) - data.hoveredId = undefined - data.pointedId = undefined - }, - undo(data) { - const { initialShape } = before - const shapes = tld.getPage(data).shapes - - delete shapes[initialShape.id] - - const selectedIds = tld.getSelectedIds(data) - selectedIds.clear() - data.hoveredId = undefined - data.pointedId = undefined - }, - }) - ) -} diff --git a/state/commands/handle.ts b/state/commands/handle.ts deleted file mode 100644 index e5078b2ab..000000000 --- a/state/commands/handle.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Command from './command' -import history from '../history' -import { Data } from 'types' -import tld from 'utils/tld' -import { HandleSnapshot } from 'state/sessions/handle-session' -import { getShapeUtils } from 'state/shape-utils' - -export default function handleCommand( - data: Data, - before: HandleSnapshot, - after: HandleSnapshot -): void { - history.execute( - data, - new Command({ - name: 'moved_handle', - category: 'canvas', - do(data) { - const { initialShape } = after - - const page = tld.getPage(data) - const shape = page.shapes[initialShape.id] - - getShapeUtils(shape) - .onHandleChange(shape, initialShape.handles) - .onSessionComplete(shape) - }, - undo(data) { - const { initialShape } = before - - const page = tld.getPage(data) - page.shapes[initialShape.id] = initialShape - }, - }) - ) -} diff --git a/state/commands/index.ts b/state/commands/index.ts index b803e9445..a29c58ae3 100644 --- a/state/commands/index.ts +++ b/state/commands/index.ts @@ -1,5 +1,4 @@ import align from './align' -import arrow from './arrow' import changePage from './change-page' import createPage from './create-page' import deletePage from './delete-page' @@ -11,7 +10,6 @@ import duplicate from './duplicate' import edit from './edit' import generate from './generate' import group from './group' -import handle from './handle' import move from './move' import moveToPage from './move-to-page' import mutate from './mutate' @@ -30,7 +28,6 @@ import ungroup from './ungroup' const commands = { align, - arrow, changePage, createPage, deletePage, @@ -42,7 +39,6 @@ const commands = { edit, generate, group, - handle, move, moveToPage, mutate, diff --git a/state/sessions/arrow-session.ts b/state/sessions/arrow-session.ts deleted file mode 100644 index da545861b..000000000 --- a/state/sessions/arrow-session.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ArrowShape, Data } from 'types' -import vec from 'utils/vec' -import BaseSession from './base-session' -import commands from 'state/commands' -import { deepClone, getBoundsFromPoints, setToArray } from 'utils' -import { getShapeUtils } from 'state/shape-utils' -import tld from 'utils/tld' - -export default class ArrowSession extends BaseSession { - points: number[][] - origin: number[] - snapshot: ArrowSnapshot - isLocked: boolean - lockedDirection: 'horizontal' | 'vertical' - - constructor(data: Data, id: string, point: number[], isLocked: boolean) { - super(data) - isLocked - this.origin = point - this.points = [[0, 0]] - this.snapshot = getArrowSnapshot(data, id) - } - - update(data: Data, point: number[], isLocked = false): void { - const { id } = this.snapshot - - const delta = vec.vec(this.origin, point) - - if (isLocked) { - if (!this.isLocked && this.points.length > 1) { - this.isLocked = true - - if (Math.abs(delta[0]) < Math.abs(delta[1])) { - this.lockedDirection = 'vertical' - } else { - this.lockedDirection = 'horizontal' - } - } - } else { - if (this.isLocked) { - this.isLocked = false - } - } - - if (this.isLocked) { - if (this.lockedDirection === 'vertical') { - point[0] = this.origin[0] - } else { - point[1] = this.origin[1] - } - } - - const shape = tld.getPage(data).shapes[id] as ArrowShape - - getShapeUtils(shape).onHandleChange(shape, { - end: { - ...shape.handles.end, - point: vec.sub(point, shape.point), - }, - }) - - tld.updateParents(data, [shape.id]) - } - - cancel(data: Data): void { - const { id, initialShape } = this.snapshot - - const shape = tld.getPage(data).shapes[id] as ArrowShape - - getShapeUtils(shape) - .onHandleChange(shape, { end: initialShape.handles.end }) - .setProperty(shape, 'point', initialShape.point) - - tld.updateParents(data, [shape.id]) - } - - complete(data: Data): void { - const { id } = this.snapshot - - const shape = tld.getPage(data).shapes[id] as ArrowShape - - const { start, end, bend } = shape.handles - - // Normalize point and handles - - const bounds = getBoundsFromPoints([start.point, end.point]) - const corner = [bounds.minX, bounds.minY] - - const newPoint = vec.add(shape.point, corner) - - const nextHandles = { - start: { ...start, point: vec.sub(start.point, corner) }, - end: { ...end, point: vec.sub(end.point, corner) }, - bend: { ...bend, point: vec.sub(bend.point, corner) }, - } - - getShapeUtils(shape) - .setProperty(shape, 'handles', nextHandles) - .setProperty(shape, 'point', newPoint) - .onHandleChange(shape, nextHandles) - - commands.arrow( - data, - this.snapshot, - getArrowSnapshot(data, this.snapshot.id) - ) - } -} - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function getArrowSnapshot(data: Data, id: string) { - const initialShape = deepClone(tld.getPage(data).shapes[id]) as ArrowShape - - return { - id, - initialShape, - selectedIds: setToArray(tld.getSelectedIds(data)), - currentPageId: data.currentPageId, - } -} - -export type ArrowSnapshot = ReturnType diff --git a/state/sessions/handle-session.ts b/state/sessions/handle-session.ts index 6500be207..fbbffe508 100644 --- a/state/sessions/handle-session.ts +++ b/state/sessions/handle-session.ts @@ -1,4 +1,4 @@ -import { Data } from 'types' +import { Data, Shape } from 'types' import vec from 'utils/vec' import BaseSession from './base-session' import commands from 'state/commands' @@ -9,73 +9,58 @@ import { deepClone } from 'utils' export default class HandleSession extends BaseSession { delta = [0, 0] origin: number[] - snapshot: HandleSnapshot + shiftKey: boolean + initialShape: Shape + handleId: string constructor(data: Data, shapeId: string, handleId: string, point: number[]) { super(data) this.origin = point - this.snapshot = getHandleSnapshot(data, shapeId, handleId) + this.handleId = handleId + this.initialShape = deepClone(tld.getShape(data, shapeId)) } - update(data: Data, point: number[], isAligned: boolean): void { - const { handleId, initialShape } = this.snapshot - const shape = tld.getPage(data).shapes[initialShape.id] + update( + data: Data, + point: number[], + shiftKey: boolean, + altKey: boolean, + metaKey: boolean + ): void { + const shape = tld.getPage(data).shapes[this.initialShape.id] + + this.shiftKey = shiftKey const delta = vec.vec(this.origin, point) - if (isAligned) { - if (Math.abs(delta[0]) < Math.abs(delta[1])) { - delta[0] = 0 - } else { - delta[1] = 0 - } - } + const handles = this.initialShape.handles - const handles = initialShape.handles - - // rotate the delta ? - // rotate the handle ? - // rotate the shape around the previous center point - - getShapeUtils(shape).onHandleChange(shape, { - [handleId]: { - ...handles[handleId], - point: vec.add(handles[handleId].point, delta), // vec.rot(delta, shape.rotation)), + getShapeUtils(shape).onHandleChange( + shape, + { + [this.handleId]: { + ...handles[this.handleId], + point: vec.round(vec.add(handles[this.handleId].point, delta)), // vec.rot(delta, shape.rotation)), + }, }, - }) + { delta, shiftKey, altKey, metaKey } + ) } cancel(data: Data): void { - const { initialShape } = this.snapshot - tld.getPage(data).shapes[initialShape.id] = initialShape + tld.getPage(data).shapes[this.initialShape.id] = this.initialShape } complete(data: Data): void { - commands.handle( - data, - this.snapshot, - getHandleSnapshot( - data, - this.snapshot.initialShape.id, - this.snapshot.handleId - ) - ) + const before = this.initialShape + const after = deepClone(tld.getShape(data, before.id)) + commands.mutate(data, [before], [after]) } } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function getHandleSnapshot( - data: Data, - shapeId: string, - handleId: string -) { - const initialShape = deepClone(tld.getShape(data, shapeId)) - - return { - currentPageId: data.currentPageId, - handleId, - initialShape, - } +export function getHandleSnapshot(data: Data, shapeId: string) { + return deepClone(tld.getShape(data, shapeId)) } export type HandleSnapshot = ReturnType diff --git a/state/sessions/index.ts b/state/sessions/index.ts index b50be84b5..4a4b30831 100644 --- a/state/sessions/index.ts +++ b/state/sessions/index.ts @@ -1,4 +1,3 @@ -import ArrowSession from './arrow-session' import BaseSession from './base-session' import BrushSession from './brush-session' import DirectionSession from './direction-session' @@ -11,7 +10,6 @@ import HandleSession from './handle-session' import EditSession from './edit-session' export { - ArrowSession, BaseSession, BrushSession, DirectionSession, diff --git a/state/shape-styles.ts b/state/shape-styles.ts index 9d9e21a78..cd90bf0d3 100644 --- a/state/shape-styles.ts +++ b/state/shape-styles.ts @@ -1,4 +1,3 @@ -import { SVGProps } from 'react' import { ColorStyle, DashStyle, ShapeStyles, SizeStyle } from 'types' export const strokes: Record = { @@ -58,9 +57,11 @@ export function getFontStyle(scale: number, style: ShapeStyles): string { return `${fontSize * scale}px/1.4 Verveine Regular` } -export function getShapeStyle( - style: ShapeStyles -): Partial> { +export function getShapeStyle(style: ShapeStyles): { + stroke: string + fill: string + strokeWidth: number +} { const { color, size, isFilled } = style const strokeWidth = getStrokeWidth(size) diff --git a/state/shape-utils/arrow.tsx b/state/shape-utils/arrow.tsx index b0e3f06f4..3e80dfb5e 100644 --- a/state/shape-utils/arrow.tsx +++ b/state/shape-utils/arrow.tsx @@ -11,6 +11,9 @@ import { circleFromThreePoints, isAngleBetween, getPerfectDashProps, + clampToRotationToSegments, + lerpAngles, + clamp, getFromCache, } from 'utils' import { @@ -29,7 +32,7 @@ import getStroke from 'perfect-freehand' import React from 'react' import { registerShapeUtils } from './register' -const pathCache = new WeakMap([]) +const pathCache = new WeakMap([]) // A cache for semi-expensive circles calculated from three points function getCtp(shape: ArrowShape) { @@ -94,8 +97,6 @@ const arrow = registerShapeUtils({ }, } - // shape.handles.bend.point = getBendPoint(shape) - return shape }, @@ -107,36 +108,31 @@ const arrow = registerShapeUtils({ const { id, bend, handles, style } = shape const { start, end, bend: _bend } = handles - const isStraightLine = vec.isEqual( - _bend.point, - vec.med(start.point, end.point) - ) + const isStraightLine = + vec.dist(_bend.point, vec.round(vec.med(start.point, end.point))) < 1 + + const isDraw = shape.style.dash === DashStyle.Draw const styles = getShapeStyle(style) - const strokeWidth = +styles.strokeWidth - - const sw = strokeWidth * 1.618 + const { strokeWidth } = styles const arrowDist = vec.dist(start.point, end.point) + const arrowHeadlength = Math.min(arrowDist / 3, strokeWidth * 8) + let shaftPath: JSX.Element - let startAngle: number - let endAngle: number + let insetStart: number[] + let insetEnd: number[] if (isStraightLine) { - const straight_sw = - strokeWidth * - (style.dash === DashStyle.Draw && bend === 0 ? 0.5 : 1.618) + const sw = strokeWidth * (isDraw ? 0.618 : 1.618) - if (shape.style.dash === DashStyle.Draw && !pathCache.has(shape)) { - renderFreehandArrowShaft(shape) - } - - const path = - shape.style.dash === DashStyle.Draw - ? pathCache.get(shape) - : 'M' + start.point + 'L' + end.point + const path = isDraw + ? getFromCache(pathCache, shape.handles, (cache) => + cache.set(shape.handles, renderFreehandArrowShaft(shape)) + ) + : 'M' + vec.round(start.point) + 'L' + vec.round(end.point) const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( arrowDist, @@ -145,9 +141,8 @@ const arrow = registerShapeUtils({ 2 ) - startAngle = Math.PI - - endAngle = 0 + insetStart = vec.nudge(start.point, end.point, arrowHeadlength) + insetEnd = vec.nudge(end.point, start.point, arrowHeadlength) // Straight arrow path shaftPath = ( @@ -165,7 +160,7 @@ const arrow = registerShapeUtils({ d={path} fill={styles.stroke} stroke={styles.stroke} - strokeWidth={straight_sw} + strokeWidth={sw} strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} strokeLinecap="round" @@ -175,29 +170,39 @@ const arrow = registerShapeUtils({ } else { const circle = getCtp(shape) - const path = getArrowArcPath(start, end, circle, bend) + const sw = strokeWidth * (isDraw ? 0.618 : 1.618) + + const path = isDraw + ? getFromCache(pathCache, shape.handles, (cache) => + cache.set( + shape.handles, + renderCurvedFreehandArrowShaft(shape, circle) + ) + ) + : getArrowArcPath(start, end, circle, bend) + + const arcLength = getArcLength( + [circle[0], circle[1]], + circle[2], + start.point, + end.point + ) const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - getArcLength( - [circle[0], circle[1]], - circle[2], - start.point, - end.point - ) - 1, + arcLength - 1, sw, shape.style.dash, 2 ) - startAngle = - vec.angle([circle[0], circle[1]], start.point) - - vec.angle(end.point, start.point) + - (Math.PI / 2) * (bend > 0 ? 0.98 : -0.98) + const center = [circle[0], circle[1]] + const radius = circle[2] + const sa = vec.angle(center, start.point) + const ea = vec.angle(center, end.point) + const t = arrowHeadlength / Math.abs(arcLength) - endAngle = - vec.angle([circle[0], circle[1]], end.point) - - vec.angle(start.point, end.point) + - (Math.PI / 2) * (bend > 0 ? 0.98 : -0.98) + insetStart = vec.nudgeAtAngle(center, lerpAngles(sa, ea, t), radius) + insetEnd = vec.nudgeAtAngle(center, lerpAngles(ea, sa, t), radius) // Curved arrow path shaftPath = ( @@ -213,7 +218,7 @@ const arrow = registerShapeUtils({ /> ({ {shaftPath} {shape.decorations.start === Decoration.Arrow && ( )} {shape.decorations.end === Decoration.Arrow && ( @@ -258,7 +263,10 @@ const arrow = registerShapeUtils({ end.point = vec.rotWith(end.point, mp, delta) bend.point = vec.rotWith(bend.point, mp, delta) - this.onHandleChange(shape, shape.handles) + this.onHandleChange(shape, shape.handles, { + delta: [0, 0], + shiftKey: false, + }) return this }, @@ -270,7 +278,10 @@ const arrow = registerShapeUtils({ end.point = vec.rotWith(end.point, mp, delta) bend.point = vec.rotWith(bend.point, mp, delta) - this.onHandleChange(shape, shape.handles) + this.onHandleChange(shape, shape.handles, { + delta: [0, 0], + shiftKey: false, + }) return this }, @@ -402,42 +413,67 @@ const arrow = registerShapeUtils({ return this }, - onHandleChange(shape, handles) { + onHandleChange(shape, handles, { shiftKey }) { + // Apple changes to the handles for (const id in handles) { const handle = handles[id] - shape.handles[handle.id] = handle } - const midPoint = vec.med(shape.handles.start.point, shape.handles.end.point) + // If the user is holding shift, we want to snap the handles to angles + for (const id in handles) { + if ((id === 'start' || id === 'end') && shiftKey) { + const point = handles[id].point + const other = id === 'start' ? shape.handles.end : shape.handles.start + const angle = vec.angle(other.point, point) + const distance = vec.dist(other.point, point) + const newAngle = clampToRotationToSegments(angle, 24) + shape.handles[id].point = vec.nudgeAtAngle( + other.point, + newAngle, + distance + ) + } + } + + // If the user is moving the bend handle, we want to move the bend point if ('bend' in handles) { const { start, end, bend } = shape.handles - const dist = vec.dist(start.point, end.point) - + 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)) - const ap = vec.add(midPoint, vec.mul(vec.per(u), dist / 2)) - const bp = vec.sub(midPoint, vec.mul(vec.per(u), dist / 2)) - bend.point = vec.nearestPointOnLineSegment(ap, bp, bend.point, true) - shape.bend = vec.dist(bend.point, midPoint) / (dist / 2) + // 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 sa = vec.angle(end.point, start.point) - const la = sa - Math.PI / 2 + const bendPoint = vec.nearestPointOnLineSegment(ap, bp, bend.point, true) - if (isAngleBetween(sa, la, vec.angle(end.point, bend.point))) { + // 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. + shape.bend = 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 (isAngleBetween(angle, angle + Math.PI, angleToBend)) { shape.bend *= -1 } } + shape.handles.start.point = vec.round(shape.handles.start.point) + shape.handles.end.point = vec.round(shape.handles.end.point) shape.handles.bend.point = getBendPoint(shape) - if (vec.isEqual(shape.handles.bend.point, midPoint)) { - shape.bend = 0 - } - return this }, @@ -450,9 +486,9 @@ const arrow = registerShapeUtils({ const { start, end, bend } = shape.handles - start.point = vec.sub(start.point, offset) - end.point = vec.sub(end.point, offset) - bend.point = vec.sub(bend.point, offset) + start.point = vec.round(vec.sub(start.point, offset)) + end.point = vec.round(vec.sub(end.point, offset)) + bend.point = vec.round(vec.sub(bend.point, offset)) shape.handles = { ...shape.handles } @@ -499,9 +535,13 @@ function getBendPoint(shape: ArrowShape) { const bendDist = (dist / 2) * shape.bend const u = vec.uni(vec.vec(start.point, end.point)) - return Math.abs(bendDist) < 10 - ? midPoint - : vec.add(midPoint, vec.mul(vec.per(u), bendDist)) + const point = vec.round( + Math.abs(bendDist) < 10 + ? midPoint + : vec.add(midPoint, vec.mul(vec.per(u), bendDist)) + ) + + return point } function renderFreehandArrowShaft(shape: ArrowShape) { @@ -516,11 +556,11 @@ function renderFreehandArrowShaft(shape: ArrowShape) { const stroke = getStroke( [ - start.point, ...vec.pointsBetween(start.point, end.point), end.point, end.point, end.point, + end.point, ], { size: strokeWidth / 2, @@ -529,43 +569,79 @@ function renderFreehandArrowShaft(shape: ArrowShape) { end: { taper: 1 }, start: { taper: 1 + 32 * (st * st * st) }, simulatePressure: true, + last: true, } ) - pathCache.set(shape, getSvgPathFromStroke(stroke)) + const path = getSvgPathFromStroke(stroke) + + return path } -function getArrowHeadPath(shape: ArrowShape, point: number[], angle = 0) { - const { left, right } = getArrowHeadPoints(shape, point, angle) +function renderCurvedFreehandArrowShaft(shape: ArrowShape, circle: number[]) { + const { style, id } = shape + const { start, end } = shape.handles + + const getRandom = rng(id) + + const strokeWidth = +getShapeStyle(style).strokeWidth * 2 + + const st = Math.abs(getRandom()) + + const center = [circle[0], circle[1]] + const radius = circle[2] + + const startAngle = vec.angle(center, start.point) + + const endAngle = vec.angle(center, end.point) + + const points: number[][] = [] + + for (let i = 0; i < 21; i++) { + const t = i / 20 + const angle = lerpAngles(startAngle, endAngle, t) + points.push(vec.round(vec.nudgeAtAngle(center, angle, radius))) + } + + const stroke = getStroke([...points, end.point, end.point], { + size: strokeWidth / 2, + thinning: 0.5 + getRandom() * 0.3, + easing: (t) => t * t, + end: { + taper: shape.decorations.end ? 1 : 1 + strokeWidth * 5 * (st * st * st), + }, + start: { + taper: shape.decorations.start ? 1 : 1 + strokeWidth * 5 * (st * st * st), + }, + simulatePressure: true, + streamline: 0.01, + last: true, + }) + + const path = getSvgPathFromStroke(stroke) + + return path +} + +function getArrowHeadPath(shape: ArrowShape, point: number[], inset: number[]) { + const { left, right } = getArrowHeadPoints(shape, point, inset) return ['M', left, 'L', point, right].join(' ') } -function getArrowHeadPoints(shape: ArrowShape, point: number[], angle = 0) { - const { start, end } = shape.handles - - const stroke = +getShapeStyle(shape.style).strokeWidth * 2 - - const arrowDist = vec.dist(start.point, end.point) - - const arrowHeadlength = Math.min(arrowDist / 3, stroke * 4) - - // Unit vector from start to end - const u = vec.uni(vec.vec(start.point, end.point)) - - // The end of the arrowhead wings - const v = vec.rot(vec.mul(vec.neg(u), arrowHeadlength), angle) - +function getArrowHeadPoints( + shape: ArrowShape, + point: number[], + inset: number[] +) { // Use the shape's random seed to create minor offsets for the angles const getRandom = rng(shape.id) return { - left: vec.add( + left: vec.rotWith(inset, point, Math.PI / 6 + (Math.PI / 12) * getRandom()), + right: vec.rotWith( + inset, point, - vec.rot(v, Math.PI / 6 + (Math.PI / 12) * getRandom()) - ), - right: vec.add( - point, - vec.rot(v, -(Math.PI / 6) + (Math.PI / 12) * getRandom()) + -Math.PI / 6 + (Math.PI / 12) * getRandom() ), } } diff --git a/state/state.ts b/state/state.ts index 76dea97b2..7043f8391 100644 --- a/state/state.ts +++ b/state/state.ts @@ -846,7 +846,7 @@ const state = createState({ }, editing: { onExit: 'completeSession', - onEnter: 'startArrowSession', + onEnter: 'startArrowHandleSession', on: { STOPPED_POINTING: [ 'completeSession', @@ -862,10 +862,10 @@ const state = createState({ to: 'arrow.creating', else: { to: 'selecting' }, }, - PRESSED_SHIFT: 'keyUpdateArrowSession', - RELEASED_SHIFT: 'keyUpdateArrowSession', - MOVED_POINTER: 'updateArrowSession', - PANNED_CAMERA: 'updateArrowSession', + PRESSED_SHIFT: 'keyUpdateHandleSession', + RELEASED_SHIFT: 'keyUpdateHandleSession', + MOVED_POINTER: 'updateHandleSession', + PANNED_CAMERA: 'updateHandleSession', }, }, }, @@ -1186,7 +1186,7 @@ const state = createState({ return tld.getShape(data, payload.target) !== undefined }, isPointingRotationHandle( - data, + _data, payload: { target: Edge | Corner | 'rotate' } ) { return payload.target === 'rotate' @@ -1347,7 +1347,7 @@ const state = createState({ createShape(data, payload, type: ShapeType) { const style = deepClone(data.currentStyle) - let point = vec.round(tld.screenToWorld(payload.point, data)) + let point = tld.screenToWorld(payload.point, data) if (type === ShapeType.Text) { point = vec.sub(point, vec.mul([0, 1], getFontSize(style.size) * 0.8)) @@ -1356,7 +1356,7 @@ const state = createState({ const shape = createShape(type, { id: uniqueId(), parentId: data.currentPageId, - point, + point: vec.round(point), style: deepClone(data.currentStyle), }) @@ -1484,19 +1484,23 @@ const state = createState({ }, keyUpdateHandleSession( data, - payload: { shiftKey: boolean; altKey: boolean } + payload: { shiftKey: boolean; altKey: boolean; metaKey: boolean } ) { session.update( data, tld.screenToWorld(inputs.pointer.point, data), - payload.shiftKey + payload.shiftKey, + payload.altKey, + payload.metaKey ) }, updateHandleSession(data, payload: PointerInfo) { session.update( data, tld.screenToWorld(payload.point, data), - payload.shiftKey + payload.shiftKey, + payload.altKey, + payload.metaKey ) }, @@ -1582,32 +1586,19 @@ const state = createState({ }, // Arrow - startArrowSession(data, payload: PointerInfo) { - const id = Array.from(tld.getSelectedIds(data).values())[0] + startArrowHandleSession(data) { + const shapeId = Array.from(tld.getSelectedIds(data).values())[0] + const handleId = 'end' session.begin( - new Sessions.ArrowSession( + new Sessions.HandleSession( data, - id, - tld.screenToWorld(inputs.pointer.origin, data), - payload.shiftKey + shapeId, + handleId, + tld.screenToWorld(inputs.pointer.origin, data) ) ) }, - keyUpdateArrowSession(data, payload: PointerInfo) { - session.update( - data, - tld.screenToWorld(inputs.pointer.point, data), - payload.shiftKey - ) - }, - updateArrowSession(data, payload: PointerInfo) { - session.update( - data, - tld.screenToWorld(payload.point, data), - payload.shiftKey - ) - }, /* -------------------- Selection ------------------- */ diff --git a/types.ts b/types.ts index 5acdd955d..52875d41b 100644 --- a/types.ts +++ b/types.ts @@ -577,7 +577,13 @@ export interface ShapeUtility { onHandleChange( this: ShapeUtility, shape: Mutable, - handle: Partial + handle: Partial, + info?: Partial<{ + delta: number[] + shiftKey: boolean + altKey: boolean + metaKey: boolean + }> ): ShapeUtility onDoublePointHandle( diff --git a/utils/utils.ts b/utils/utils.ts index 86f204a44..8d8c3652d 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -196,20 +196,6 @@ export function getClosestPointOnCircle( return vec.sub(C, vec.mul(vec.div(v, vec.len(v)), r)) } -function det( - a: number, - b: number, - c: number, - d: number, - e: number, - f: number, - g: number, - h: number, - i: number -): number { - return a * e * i + b * f * g + c * d * h - a * f * h - b * d * i - c * e * g -} - /** * Get a circle from three points. * @param A @@ -222,47 +208,27 @@ export function circleFromThreePoints( B: number[], C: number[] ): number[] { - const a = det(A[0], A[1], 1, B[0], B[1], 1, C[0], C[1], 1) + const [x1, y1] = A + const [x2, y2] = B + const [x3, y3] = C - const bx = -det( - A[0] * A[0] + A[1] * A[1], - A[1], - 1, - B[0] * B[0] + B[1] * B[1], - B[1], - 1, - C[0] * C[0] + C[1] * C[1], - C[1], - 1 - ) - const by = det( - A[0] * A[0] + A[1] * A[1], - A[0], - 1, - B[0] * B[0] + B[1] * B[1], - B[0], - 1, - C[0] * C[0] + C[1] * C[1], - C[0], - 1 - ) - const c = -det( - A[0] * A[0] + A[1] * A[1], - A[0], - A[1], - B[0] * B[0] + B[1] * B[1], - B[0], - B[1], - C[0] * C[0] + C[1] * C[1], - C[0], - C[1] - ) + const a = x1 * (y2 - y3) - y1 * (x2 - x3) + x2 * y3 - x3 * y2 - const x = -bx / (2 * a) - const y = -by / (2 * a) - const r = Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)) + const b = + (x1 * x1 + y1 * y1) * (y3 - y2) + + (x2 * x2 + y2 * y2) * (y1 - y3) + + (x3 * x3 + y3 * y3) * (y2 - y1) - return [x, y, r] + const c = + (x1 * x1 + y1 * y1) * (x2 - x3) + + (x2 * x2 + y2 * y2) * (x3 - x1) + + (x3 * x3 + y3 * y3) * (x1 - x2) + + const x = -b / (2 * a) + + const y = -c / (2 * a) + + return [x, y, Math.hypot(x - x1, y - y1)] } /** @@ -281,7 +247,12 @@ export function perimeterOfEllipse(rx: number, ry: number): number { * @param a0 * @param a1 */ -export function shortAngleDist(a0: number, a1: number): number { +export function shortAngleDist(a0: number, a1: number, clamp = true): number { + if (!clamp) { + const da = a1 - a0 + return 2 * da - da + } + const max = Math.PI * 2 const da = (a1 - a0) % max return ((2 * da) % max) - da @@ -303,7 +274,7 @@ export function longAngleDist(a0: number, a1: number): number { * @param t */ export function lerpAngles(a0: number, a1: number, t: number): number { - return a0 + shortAngleDist(a0, a1) * t + return a0 + shortAngleDist(a0, a1, true) * t } /** @@ -1753,14 +1724,17 @@ export function getSvgPathFromStroke(stroke: number[][]): string { const d = stroke.reduce( (acc, [x0, y0], i, arr) => { const [x1, y1] = arr[(i + 1) % arr.length] - acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2) + acc.push(` ${x0},${y0} ${(x0 + x1) / 2},${(y0 + y1) / 2}`) return acc }, - ['M', ...stroke[0], 'Q'] + ['M ', `${stroke[0][0]},${stroke[0][1]}`, ' Q'] ) - d.push('Z') - return d.join(' ').replaceAll(/(\s[0-9]*\.[0-9]{2})([0-9]*)\b/g, '$1') + d.push(' Z') + + return d + .join('') + .replaceAll(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, '$1') } export function debounce unknown>( diff --git a/utils/vec.ts b/utils/vec.ts index 38a3e4971..68bc9cecc 100644 --- a/utils/vec.ts +++ b/utils/vec.ts @@ -515,6 +515,6 @@ export default class Vec { const t = i / steps return t * t * t }) - .map((t) => [...Vec.lrp(a, b, t), (1 - t) / 2]) + .map((t) => Vec.round([...Vec.lrp(a, b, t), (1 - t) / 2])) } } diff --git a/yarn.lock b/yarn.lock index 21d6a1211..d109a74ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5353,7 +5353,7 @@ globalyzer@0.1.0: resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== -globby@^11.0.3: +globby@^11.0.3, globby@^11.0.4: version "11.0.4" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg== @@ -7887,15 +7887,15 @@ next-auth@^3.27.0: preact-render-to-string "^5.1.14" querystring "^0.2.0" -next-pwa@^5.2.21: - version "5.2.21" - resolved "https://registry.yarnpkg.com/next-pwa/-/next-pwa-5.2.21.tgz#fb71ba35b1a984ec6641c5def64ca8c0ab9c2b0f" - integrity sha512-jL782UGX0E59TmmYi1xe5kgImeGPBO3me/b3RX7bLP0eG8oJeZiJqTVkc5DJNmXgdwOc6RSvRSOUdBQ9zLu4RA== +next-pwa@^5.2.23: + version "5.2.23" + resolved "https://registry.yarnpkg.com/next-pwa/-/next-pwa-5.2.23.tgz#581a50bc9892c9d3ae7be5c9bbf590becf376f0e" + integrity sha512-nGYp0zliA0lJBoKETEZgW4kaiBckF8m9xpBw3VQ+EyVJ57Bn5HfFeNZUvFi2mvDiiwBuqf34baQIt8EhiN8k+A== dependencies: babel-loader "^8.2.2" clean-webpack-plugin "^3.0.0" - globby "^11.0.3" - terser-webpack-plugin "^5.1.1" + globby "^11.0.4" + terser-webpack-plugin "^5.1.4" workbox-webpack-plugin "^6.1.5" workbox-window "^6.1.5" @@ -9537,10 +9537,10 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" -serialize-javascript@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4" - integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA== +serialize-javascript@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" + integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== dependencies: randombytes "^2.1.0" @@ -10225,15 +10225,15 @@ terminal-link@^2.0.0: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" -terser-webpack-plugin@^5.1.1: - version "5.1.3" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.1.3.tgz#30033e955ca28b55664f1e4b30a1347e61aa23af" - integrity sha512-cxGbMqr6+A2hrIB5ehFIF+F/iST5ZOxvOmy9zih9ySbP1C2oEWQSOUS+2SNBTjzx5xLKO4xnod9eywdfq1Nb9A== +terser-webpack-plugin@^5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.1.4.tgz#c369cf8a47aa9922bd0d8a94fe3d3da11a7678a1" + integrity sha512-C2WkFwstHDhVEmsmlCxrXUtVklS+Ir1A7twrYzrDrQQOIMOaVAYykaoo/Aq1K0QRkMoY2hhvDQY1cm4jnIMFwA== dependencies: jest-worker "^27.0.2" p-limit "^3.1.0" schema-utils "^3.0.0" - serialize-javascript "^5.0.1" + serialize-javascript "^6.0.0" source-map "^0.6.1" terser "^5.7.0"