Updates arrows

This commit is contained in:
Steve Ruiz 2021-06-21 14:13:16 +01:00
parent fc2e3b3c4c
commit daa44f9911
20 changed files with 1457 additions and 794 deletions

7
.babelrc Normal file
View file

@ -0,0 +1,7 @@
{
"env": {
"test": {
"presets": ["next/babel"]
}
}
}

View file

@ -1 +0,0 @@
{}

View file

@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View file

@ -1,22 +0,0 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View file

@ -1,25 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

View file

@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View file

@ -38,9 +38,9 @@ const strokeWidths = {
} }
const dashArrays = { const dashArrays = {
[DashStyle.Solid]: () => 'none', [DashStyle.Solid]: () => [1],
[DashStyle.Dashed]: (sw: number) => `${sw} ${sw * 2}`, [DashStyle.Dashed]: (sw: number) => [sw * 2, sw * 4],
[DashStyle.Dotted]: (sw: number) => `0 ${sw * 1.5}`, [DashStyle.Dotted]: (sw: number) => [0, sw * 3],
} }
const fontSizes = { const fontSizes = {
@ -50,11 +50,11 @@ const fontSizes = {
auto: 'auto', auto: 'auto',
} }
function getStrokeWidth(size: SizeStyle) { export function getStrokeWidth(size: SizeStyle) {
return strokeWidths[size] return strokeWidths[size]
} }
function getStrokeDashArray(dash: DashStyle, strokeWidth: number) { export function getStrokeDashArray(dash: DashStyle, strokeWidth: number) {
return dashArrays[dash](strokeWidth) return dashArrays[dash](strokeWidth)
} }
@ -74,7 +74,7 @@ export function getShapeStyle(
const { color, size, dash, isFilled } = style const { color, size, dash, isFilled } = style
const strokeWidth = getStrokeWidth(size) const strokeWidth = getStrokeWidth(size)
const strokeDasharray = getStrokeDashArray(dash, strokeWidth) const strokeDasharray = getStrokeDashArray(dash, strokeWidth).join()
return { return {
stroke: strokes[color], stroke: strokes[color],

View file

@ -1,4 +1,4 @@
import { uniqueId } from 'utils/utils' import { getArcLength, lerp, uniqueId } from 'utils/utils'
import vec from 'utils/vec' import vec from 'utils/vec'
import { import {
getSvgPathFromStroke, getSvgPathFromStroke,
@ -7,7 +7,7 @@ import {
translateBounds, translateBounds,
pointsBetween, pointsBetween,
} from 'utils/utils' } from 'utils/utils'
import { ArrowShape, Bounds, ShapeHandle, ShapeType } from 'types' import { ArrowShape, Bounds, DashStyle, ShapeHandle, ShapeType } from 'types'
import { registerShapeUtils } from './index' import { registerShapeUtils } from './index'
import { circleFromThreePoints, isAngleBetween } from 'utils/utils' import { circleFromThreePoints, isAngleBetween } from 'utils/utils'
import { pointInBounds } from 'utils/bounds' import { pointInBounds } from 'utils/bounds'
@ -16,22 +16,20 @@ import {
intersectLineSegmentBounds, intersectLineSegmentBounds,
} from 'utils/intersections' } from 'utils/intersections'
import { pointInCircle } from 'utils/hitTests' import { pointInCircle } from 'utils/hitTests'
import { defaultStyle, getShapeStyle } from 'lib/shape-styles' import {
defaultStyle,
getShapeStyle,
getStrokeDashArray,
} from 'lib/shape-styles'
import getStroke from 'perfect-freehand' import getStroke from 'perfect-freehand'
import React from 'react'
const ctpCache = new WeakMap<ArrowShape['handles'], number[]>()
const pathCache = new WeakMap<ArrowShape, string>([]) const pathCache = new WeakMap<ArrowShape, string>([])
// A cache for semi-expensive circles calculated from three points
function getCtp(shape: ArrowShape) { function getCtp(shape: ArrowShape) {
if (!ctpCache.has(shape.handles)) { const { start, end, bend } = shape.handles
const { start, end, bend } = shape.handles return circleFromThreePoints(start.point, end.point, bend.point)
ctpCache.set(
shape.handles,
circleFromThreePoints(start.point, end.point, bend.point)
)
}
return ctpCache.get(shape.handles)
} }
const arrow = registerShapeUtils<ArrowShape>({ const arrow = registerShapeUtils<ArrowShape>({
@ -40,10 +38,6 @@ const arrow = registerShapeUtils<ArrowShape>({
create(props) { create(props) {
const { const {
point = [0, 0], point = [0, 0],
points = [
[0, 0],
[0, 1],
],
handles = { handles = {
start: { start: {
id: 'start', id: 'start',
@ -77,7 +71,6 @@ const arrow = registerShapeUtils<ArrowShape>({
isLocked: false, isLocked: false,
isHidden: false, isHidden: false,
bend: 0, bend: 0,
points,
handles, handles,
decorations: { decorations: {
start: null, start: null,
@ -94,63 +87,123 @@ const arrow = registerShapeUtils<ArrowShape>({
}, },
render(shape) { render(shape) {
const { id, bend, handles } = shape const { id, bend, handles, style } = shape
const { start, end, bend: _bend } = handles const { start, end, bend: _bend } = handles
const arrowDist = vec.dist(start.point, end.point) const isStraightLine = vec.isEqual(
const showCircle = !vec.isEqual(
_bend.point, _bend.point,
vec.med(start.point, end.point) vec.med(start.point, end.point)
) )
const style = getShapeStyle(shape.style) const styles = getShapeStyle(style)
let body: JSX.Element const strokeWidth = +styles.strokeWidth
if (showCircle) { if (isStraightLine) {
if (!ctpCache.has(handles)) { // Render a straight arrow as a freehand path.
ctpCache.set(
handles,
circleFromThreePoints(start.point, end.point, _bend.point)
)
}
const circle = getCtp(shape)
if (!pathCache.has(shape)) {
renderPath(
shape,
vec.angle([circle[0], circle[1]], end.point) -
vec.angle(start.point, end.point) +
(Math.PI / 2) * (bend > 0 ? 0.98 : -0.98)
)
}
const path = pathCache.get(shape)
body = (
<>
<path
d={getArrowArcPath(start, end, circle, bend)}
fill="none"
strokeWidth={(+style.strokeWidth * 1.85).toString()}
strokeLinecap="round"
/>
<path d={path} strokeWidth={+style.strokeWidth * 1.5} />
</>
)
} else {
if (!pathCache.has(shape)) { if (!pathCache.has(shape)) {
renderPath(shape) renderPath(shape)
} }
const offset = -vec.dist(start.point, end.point) + strokeWidth
const path = pathCache.get(shape) const path = pathCache.get(shape)
body = <path d={path} /> return (
<g id={id}>
{/* Improves hit testing */}
<path
d={path}
stroke="transparent"
fill="none"
strokeWidth={Math.max(8, strokeWidth * 2)}
strokeLinecap="round"
strokeDasharray="none"
/>
{/* Arrowshaft */}
<circle
cx={start.point[0]}
cy={start.point[1]}
r={strokeWidth}
fill={styles.stroke}
stroke="none"
/>
<path
d={path}
fill="none"
strokeWidth={
strokeWidth * (style.dash === DashStyle.Solid ? 1 : 1.618)
}
strokeDashoffset={offset}
strokeLinecap="round"
/>
{/* Arrowhead */}
{style.dash !== DashStyle.Solid && (
<path
d={getArrowHeadPath(shape, 0)}
strokeWidth={strokeWidth * 1.618}
strokeDasharray="none"
fill="none"
/>
)}
</g>
)
} }
return <g id={id}>{body}</g> const circle = getCtp(shape)
if (!pathCache.has(shape)) {
renderPath(
shape,
vec.angle([circle[0], circle[1]], end.point) -
vec.angle(start.point, end.point) +
(Math.PI / 2) * (bend > 0 ? 0.98 : -0.98)
)
}
const path = getArrowArcPath(start, end, circle, bend)
const strokeDashOffset = getStrokeDashOffsetForArc(
shape,
circle,
strokeWidth
)
return (
<g id={id}>
{/* Improves hit testing */}
<path
d={path}
stroke="transparent"
fill="none"
strokeWidth={Math.max(8, strokeWidth * 2)}
strokeLinecap="round"
strokeDasharray="none"
/>
{/* Arrow Shaft */}
<circle
cx={start.point[0]}
cy={start.point[1]}
r={strokeWidth}
fill={styles.stroke}
stroke="none"
/>
<path
d={path}
fill="none"
strokeWidth={strokeWidth * 1.618}
strokeLinecap="round"
strokeDashoffset={strokeDashOffset}
/>
{/* Arrowhead */}
<path
d={pathCache.get(shape)}
strokeWidth={strokeWidth * 1.618}
strokeDasharray="none"
fill="none"
/>
</g>
)
}, },
rotateBy(shape, delta) { rotateBy(shape, delta) {
@ -179,17 +232,20 @@ const arrow = registerShapeUtils<ArrowShape>({
getBounds(shape) { getBounds(shape) {
if (!this.boundsCache.has(shape)) { if (!this.boundsCache.has(shape)) {
const { start, end } = shape.handles const { start, bend, end } = shape.handles
this.boundsCache.set(shape, getBoundsFromPoints([start.point, end.point])) this.boundsCache.set(
shape,
getBoundsFromPoints([start.point, bend.point, end.point])
)
} }
return translateBounds(this.boundsCache.get(shape), shape.point) return translateBounds(this.boundsCache.get(shape), shape.point)
}, },
getRotatedBounds(shape) { getRotatedBounds(shape) {
const { start, end } = shape.handles const { start, bend, end } = shape.handles
return translateBounds( return translateBounds(
getBoundsFromPoints([start.point, end.point], shape.rotation), getBoundsFromPoints([start.point, bend.point, end.point], shape.rotation),
shape.point shape.point
) )
}, },
@ -200,7 +256,7 @@ const arrow = registerShapeUtils<ArrowShape>({
}, },
hitTest(shape, point) { hitTest(shape, point) {
const { start, end, bend } = shape.handles const { start, end } = shape.handles
if (shape.bend === 0) { if (shape.bend === 0) {
return ( return (
vec.distanceToLineSegment( vec.distanceToLineSegment(
@ -239,33 +295,42 @@ const arrow = registerShapeUtils<ArrowShape>({
transform(shape, bounds, { initialShape, scaleX, scaleY }) { transform(shape, bounds, { initialShape, scaleX, scaleY }) {
const initialShapeBounds = this.getBounds(initialShape) const initialShapeBounds = this.getBounds(initialShape)
// let nw = initialShape.point[0] / initialShapeBounds.width
// let nh = initialShape.point[1] / initialShapeBounds.height
// shape.point = [
// bounds.width * (scaleX < 0 ? 1 - nw : nw),
// bounds.height * (scaleY < 0 ? 1 - nh : nh),
// ]
shape.point = [bounds.minX, bounds.minY] shape.point = [bounds.minX, bounds.minY]
shape.points = shape.points.map((_, i) => { const handles = ['start', 'end']
const [x, y] = initialShape.points[i]
handles.forEach((handle) => {
const [x, y] = initialShape.handles[handle].point
let nw = x / initialShapeBounds.width let nw = x / initialShapeBounds.width
let nh = y / initialShapeBounds.height let nh = y / initialShapeBounds.height
if (i === 1) { shape.handles[handle].point = [
let [x0, y0] = initialShape.points[0]
if (x0 === x) nw = 1
if (y0 === y) nh = 1
}
return [
bounds.width * (scaleX < 0 ? 1 - nw : nw), bounds.width * (scaleX < 0 ? 1 - nw : nw),
bounds.height * (scaleY < 0 ? 1 - nh : nh), bounds.height * (scaleY < 0 ? 1 - nh : nh),
] ]
}) })
const { start, end, bend } = shape.handles const { start, bend, end } = shape.handles
start.point = shape.points[0] const dist = vec.dist(start.point, end.point)
end.point = shape.points[1]
bend.point = getBendPoint(shape) const midPoint = vec.med(start.point, end.point)
shape.points = [shape.handles.start.point, shape.handles.end.point] const bendDist = (dist / 2) * initialShape.bend
const u = vec.uni(vec.vec(start.point, end.point))
const point = vec.add(midPoint, vec.mul(vec.per(u), bendDist))
bend.point = Math.abs(bendDist) < 10 ? midPoint : point
return this return this
}, },
@ -279,10 +344,6 @@ const arrow = registerShapeUtils<ArrowShape>({
shape.handles[handle.id] = handle shape.handles[handle.id] = handle
if (handle.index < 2) {
shape.points[handle.index] = handle.point
}
const { start, end, bend } = shape.handles const { start, end, bend } = shape.handles
const dist = vec.dist(start.point, end.point) const dist = vec.dist(start.point, end.point)
@ -327,6 +388,8 @@ const arrow = registerShapeUtils<ArrowShape>({
end.point = vec.sub(end.point, offset) end.point = vec.sub(end.point, offset)
bend.point = vec.sub(bend.point, offset) bend.point = vec.sub(bend.point, offset)
shape.handles = { ...shape.handles }
return this return this
}, },
@ -375,47 +438,6 @@ function getBendPoint(shape: ArrowShape) {
: vec.add(midPoint, vec.mul(vec.per(u), bendDist)) : vec.add(midPoint, vec.mul(vec.per(u), bendDist))
} }
function getResizeOffset(a: Bounds, b: Bounds) {
const { minX: x0, minY: y0, width: w0, height: h0 } = a
const { minX: x1, minY: y1, width: w1, height: h1 } = b
let delta: number[]
if (h0 === h1 && w0 !== w1) {
if (x0 !== x1) {
// moving left edge, pin right edge
delta = vec.sub([x1, y1 + h1 / 2], [x0, y0 + h0 / 2])
} else {
// moving right edge, pin left edge
delta = vec.sub([x1 + w1, y1 + h1 / 2], [x0 + w0, y0 + h0 / 2])
}
} else if (h0 !== h1 && w0 === w1) {
if (y0 !== y1) {
// moving top edge, pin bottom edge
delta = vec.sub([x1 + w1 / 2, y1], [x0 + w0 / 2, y0])
} else {
// moving bottom edge, pin top edge
delta = vec.sub([x1 + w1 / 2, y1 + h1], [x0 + w0 / 2, y0 + h0])
}
} else if (x0 !== x1) {
if (y0 !== y1) {
// moving top left, pin bottom right
delta = vec.sub([x1, y1], [x0, y0])
} else {
// moving bottom left, pin top right
delta = vec.sub([x1, y1 + h1], [x0, y0 + h0])
}
} else if (y0 !== y1) {
// moving top right, pin bottom left
delta = vec.sub([x1 + w1, y1], [x0 + w0, y0])
} else {
// moving bottom right, pin top left
delta = vec.sub([x1 + w1, y1 + h1], [x0 + w0, y0 + h0])
}
return delta
}
function renderPath(shape: ArrowShape, endAngle = 0) { function renderPath(shape: ArrowShape, endAngle = 0) {
const { style, id } = shape const { style, id } = shape
const { start, end } = shape.handles const { start, end } = shape.handles
@ -424,42 +446,34 @@ function renderPath(shape: ArrowShape, endAngle = 0) {
const strokeWidth = +getShapeStyle(style).strokeWidth * 2 const strokeWidth = +getShapeStyle(style).strokeWidth * 2
const arrowDist = vec.dist(start.point, end.point)
const styles = getShapeStyle(shape.style) const styles = getShapeStyle(shape.style)
const sw = +styles.strokeWidth const sw = strokeWidth
const length = Math.min(arrowDist / 2, 24 + sw * 2)
const u = vec.uni(vec.vec(start.point, end.point))
const v = vec.rot(vec.mul(vec.neg(u), length), endAngle)
// Start // Start
const a = start.point const a = start.point
// End
const b = end.point
// Middle // Middle
const m = vec.add( const m = vec.add(
vec.lrp(start.point, end.point, 0.25 + Math.abs(getRandom()) / 2), vec.lrp(start.point, end.point, 0.25 + Math.abs(getRandom()) / 2),
[getRandom() * sw, getRandom() * sw] [getRandom() * sw, getRandom() * sw]
) )
// End // Left and right sides of the arrowhead
const b = end.point let { left: c, right: d } = getArrowHeadPoints(shape, endAngle)
// Left // Switch which side of the arrow is drawn first
let c = vec.add( if (getRandom() > 0) [c, d] = [d, c]
end.point,
vec.rot(v, Math.PI / 6 + (Math.PI / 8) * getRandom())
)
// Right if (style.dash !== DashStyle.Solid) {
let d = vec.add( pathCache.set(
end.point, shape,
vec.rot(v, -(Math.PI / 6) + (Math.PI / 8) * getRandom()) (endAngle ? ['M', c, 'L', b, d] : ['M', a, 'L', b]).join(' ')
) )
return
if (getRandom() > 0.5) {
;[c, d] = [d, c]
} }
const points = endAngle const points = endAngle
@ -471,7 +485,7 @@ function renderPath(shape: ArrowShape, endAngle = 0) {
...pointsBetween(d, b), ...pointsBetween(d, b),
] ]
: [ : [
// The shaft too // The arrow shaft
b, b,
a, a,
...pointsBetween(a, m), ...pointsBetween(a, m),
@ -493,3 +507,60 @@ function renderPath(shape: ArrowShape, endAngle = 0) {
pathCache.set(shape, getSvgPathFromStroke(stroke)) pathCache.set(shape, getSvgPathFromStroke(stroke))
} }
function getArrowHeadPath(shape: ArrowShape, endAngle = 0) {
const { end } = shape.handles
const { left, right } = getArrowHeadPoints(shape, endAngle)
return ['M', left, 'L', end.point, right].join(' ')
}
function getArrowHeadPoints(shape: ArrowShape, endAngle = 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), endAngle)
// Use the shape's random seed to create minor offsets for the angles
const getRandom = rng(shape.id)
return {
left: vec.add(
end.point,
vec.rot(v, Math.PI / 6 + (Math.PI / 8) * getRandom())
),
right: vec.add(
end.point,
vec.rot(v, -(Math.PI / 6) + (Math.PI / 8) * getRandom())
),
}
}
function getStrokeDashOffsetForArc(
shape: ArrowShape,
circle: number[],
strokeWidth: number
) {
const { start, end } = shape.handles
const sweep = getArcLength(
[circle[0], circle[1]],
circle[2],
start.point,
end.point
)
return Math.abs(shape.bend) === 1
? -strokeWidth / 2
: shape.bend < 0
? sweep + strokeWidth
: -sweep + strokeWidth
}

View file

@ -2,7 +2,12 @@ import { uniqueId, isMobile } from 'utils/utils'
import vec from 'utils/vec' import vec from 'utils/vec'
import { TextShape, ShapeType, FontSize, SizeStyle } from 'types' import { TextShape, ShapeType, FontSize, SizeStyle } from 'types'
import { registerShapeUtils } from './index' import { registerShapeUtils } from './index'
import { defaultStyle, getFontStyle, getShapeStyle } from 'lib/shape-styles' import {
defaultStyle,
getFontSize,
getFontStyle,
getShapeStyle,
} from 'lib/shape-styles'
import styled from 'styles' import styled from 'styles'
import state from 'state' import state from 'state'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@ -98,6 +103,32 @@ const text = registerShapeUtils<TextShape>({
state.send('FOCUSED_EDITING_SHAPE') state.send('FOCUSED_EDITING_SHAPE')
} }
const fontSize = getFontSize(shape.style.size) * shape.scale
const gap = fontSize * 0.4
if (!isEditing) {
return (
<g id={id} pointerEvents="none">
{text.split('\n').map((str, i) => (
<text
key={i}
x={4}
y={4 + gap / 2 + i * (fontSize + gap)}
fontFamily="Verveine Regular"
fontStyle="normal"
fontWeight="regular"
fontSize={fontSize}
width={bounds.width}
height={bounds.height}
dominant-baseline="hanging"
>
{str}
</text>
))}
</g>
)
}
return ( return (
<foreignObject <foreignObject
id={id} id={id}
@ -107,37 +138,26 @@ const text = registerShapeUtils<TextShape>({
height={bounds.height} height={bounds.height}
pointerEvents="none" pointerEvents="none"
> >
{isEditing ? ( <StyledTextArea
<StyledTextArea ref={ref}
ref={ref} style={{
style={{ font,
font, color: styles.stroke,
color: styles.stroke, }}
}} value={text}
value={text} tabIndex={0}
tabIndex={0} autoComplete="false"
autoComplete="false" autoCapitalize="false"
autoCapitalize="false" autoCorrect="false"
autoCorrect="false" autoSave="false"
autoSave="false" placeholder=""
placeholder="" name="text"
name="text" autoFocus={isMobile() ? true : false}
autoFocus={isMobile() ? true : false} onFocus={handleFocus}
onFocus={handleFocus} onBlur={handleBlur}
onBlur={handleBlur} onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} onChange={handleChange}
onChange={handleChange} />
/>
) : (
<StyledText
style={{
font,
color: styles.stroke,
}}
>
{text}
</StyledText>
)}
</foreignObject> </foreignObject>
) )
}, },

View file

@ -5,7 +5,10 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start" "start": "next start",
"test": "yarn test:app",
"test:all": "yarn test:code",
"test:update": "yarn test:app --updateSnapshot --watchAll=false"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.1.3", "@monaco-editor/react": "^4.1.3",
@ -46,7 +49,8 @@
"@types/react": "^17.0.5", "@types/react": "^17.0.5",
"@types/react-dom": "^17.0.3", "@types/react-dom": "^17.0.3",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"cypress": "^7.3.0", "babel-jest": "^27.0.2",
"jest": "^27.0.4",
"monaco-editor": "^0.24.0", "monaco-editor": "^0.24.0",
"typescript": "^4.2.4" "typescript": "^4.2.4"
} }

View file

@ -19,6 +19,7 @@ import paste from './paste'
import rotateCcw from './rotate-ccw' import rotateCcw from './rotate-ccw'
import stretch from './stretch' import stretch from './stretch'
import style from './style' import style from './style'
import mutate from './mutate'
import toggle from './toggle' import toggle from './toggle'
import transform from './transform' import transform from './transform'
import transformSingle from './transform-single' import transformSingle from './transform-single'
@ -28,6 +29,7 @@ import edit from './edit'
import resetBounds from './reset-bounds' import resetBounds from './reset-bounds'
const commands = { const commands = {
mutate,
align, align,
arrow, arrow,
changePage, changePage,

49
state/commands/mutate.ts Normal file
View file

@ -0,0 +1,49 @@
import Command from './command'
import history from '../history'
import { Data, Shape } from 'types'
import { getShapeUtils } from 'lib/shape-utils'
import { getPage, updateParents } from 'utils/utils'
// Used when changing the properties of one or more shapes,
// without changing selection or deleting any shapes.
export default function mutateShapesCommand(
data: Data,
before: Shape[],
after: Shape[],
name = 'mutate_shapes'
) {
history.execute(
data,
new Command({
name,
category: 'canvas',
do(data) {
const { shapes } = getPage(data)
after.forEach((shape) => {
shapes[shape.id] = shape
getShapeUtils(shape).onSessionComplete(shape)
})
// updateParents(
// data,
// after.map((shape) => shape.id)
// )
},
undo(data) {
const { shapes } = getPage(data)
before.forEach((shape) => {
shapes[shape.id] = shape
getShapeUtils(shape).onSessionComplete(shape)
})
updateParents(
data,
before.map((shape) => shape.id)
)
},
})
)
}

View file

@ -18,24 +18,12 @@ export default function transformCommand(
name: 'transform_shapes', name: 'transform_shapes',
category: 'canvas', category: 'canvas',
do(data) { do(data) {
const { type, shapeBounds } = after const { shapeBounds } = after
const { shapes } = getPage(data) const { shapes } = getPage(data)
for (let id in shapeBounds) { for (let id in shapeBounds) {
const { initialShapeBounds: bounds } = after.shapeBounds[id] shapes[id] = shapeBounds[id].initialShape
const { initialShape, transformOrigin } = before.shapeBounds[id]
const shape = shapes[id]
getShapeUtils(shape)
.transform(shape, bounds, {
type,
initialShape,
transformOrigin,
scaleX,
scaleY,
})
.onSessionComplete(shape)
} }
updateParents(data, Object.keys(shapeBounds)) updateParents(data, Object.keys(shapeBounds))

View file

@ -5,6 +5,7 @@ import commands from 'state/commands'
import { current, freeze } from 'immer' import { current, freeze } from 'immer'
import { getShapeUtils } from 'lib/shape-utils' import { getShapeUtils } from 'lib/shape-utils'
import { import {
deepClone,
getBoundsCenter, getBoundsCenter,
getBoundsFromPoints, getBoundsFromPoints,
getCommonBounds, getCommonBounds,
@ -103,26 +104,28 @@ export default class TransformSession extends BaseSession {
} }
complete(data: Data) { complete(data: Data) {
if (!this.snapshot.hasUnlockedShapes) return const { initialShapes, hasUnlockedShapes } = this.snapshot
commands.transform( if (!hasUnlockedShapes) return
data,
this.snapshot, const page = getPage(data)
getTransformSnapshot(data, this.transformType),
this.scaleX, const finalShapes = initialShapes.map((shape) =>
this.scaleY deepClone(page.shapes[shape.id])
) )
commands.mutate(data, initialShapes, finalShapes)
} }
} }
export function getTransformSnapshot(data: Data, transformType: Edge | Corner) { export function getTransformSnapshot(data: Data, transformType: Edge | Corner) {
const cData = current(data) const { currentPageId } = data
const { currentPageId } = cData const page = getPage(data)
const page = getPage(cData)
const initialShapes = setToArray(getSelectedIds(data)) const initialShapes = setToArray(getSelectedIds(data))
.flatMap((id) => getDocumentBranch(cData, id).map((id) => page.shapes[id])) .flatMap((id) => getDocumentBranch(data, id).map((id) => page.shapes[id]))
.filter((shape) => !shape.isLocked) .filter((shape) => !shape.isLocked)
.map((shape) => deepClone(shape))
const hasUnlockedShapes = initialShapes.length > 0 const hasUnlockedShapes = initialShapes.length > 0
@ -151,6 +154,7 @@ export function getTransformSnapshot(data: Data, transformType: Edge | Corner) {
hasUnlockedShapes, hasUnlockedShapes,
isAllAspectRatioLocked, isAllAspectRatioLocked,
currentPageId, currentPageId,
initialShapes,
initialBounds: commonBounds, initialBounds: commonBounds,
shapeBounds: Object.fromEntries( shapeBounds: Object.fromEntries(
initialShapes.map((shape) => { initialShapes.map((shape) => {

View file

@ -66,7 +66,6 @@ const initialData: Data = {
size: SizeStyle.Medium, size: SizeStyle.Medium,
color: ColorStyle.Black, color: ColorStyle.Black,
dash: DashStyle.Solid, dash: DashStyle.Solid,
fontSize: FontSize.Medium,
isFilled: false, isFilled: false,
}, },
activeTool: 'select', activeTool: 'select',
@ -131,7 +130,7 @@ const state = createState({
wait: 0.01, wait: 0.01,
if: 'hasSelection', if: 'hasSelection',
do: 'zoomCameraToSelectionActual', do: 'zoomCameraToSelectionActual',
else: ['zoomCameraToFit', 'zoomCameraToActual'], else: ['zoomCameraToActual'],
}, },
on: { on: {
COPIED: { if: 'hasSelection', do: 'copyToClipboard' }, COPIED: { if: 'hasSelection', do: 'copyToClipboard' },

View file

@ -47,5 +47,10 @@
## Clipboard ## Clipboard
- [ ] Copy - [x] Copy
- [ ] Paste - [x] Paste shapes
- [ ] Paste as text
## Copy to SVG
- [ ] Copy to SVG

View file

@ -16,7 +16,7 @@ export interface Data {
isToolLocked: boolean isToolLocked: boolean
isPenLocked: boolean isPenLocked: boolean
} }
currentStyle: ShapeStyles & TextStyles currentStyle: ShapeStyles
activeTool: ShapeType | 'select' activeTool: ShapeType | 'select'
brush?: Bounds brush?: Bounds
boundsRotation: number boundsRotation: number
@ -114,10 +114,6 @@ export type ShapeStyles = {
isFilled: boolean isFilled: boolean
} }
export type TextStyles = {
fontSize: FontSize
}
export interface BaseShape { export interface BaseShape {
id: string id: string
seed: number seed: number
@ -180,7 +176,6 @@ export interface DrawShape extends BaseShape {
export interface ArrowShape extends BaseShape { export interface ArrowShape extends BaseShape {
type: ShapeType.Arrow type: ShapeType.Arrow
points: number[][]
handles: Record<string, ShapeHandle> handles: Record<string, ShapeHandle>
bend: number bend: number
decorations?: { decorations?: {

View file

@ -1,8 +1,8 @@
import { Bounds } from "types" import { Bounds } from 'types'
import { import {
intersectPolygonBounds, intersectPolygonBounds,
intersectPolylineBounds, intersectPolylineBounds,
} from "./intersections" } from './intersections'
/** /**
* Get whether two bounds collide. * Get whether two bounds collide.

View file

@ -1850,3 +1850,65 @@ export function decompress(s: string) {
return out.join('') return out.join('')
} }
function getResizeOffset(a: Bounds, b: Bounds) {
const { minX: x0, minY: y0, width: w0, height: h0 } = a
const { minX: x1, minY: y1, width: w1, height: h1 } = b
let delta: number[]
if (h0 === h1 && w0 !== w1) {
if (x0 !== x1) {
// moving left edge, pin right edge
delta = vec.sub([x1, y1 + h1 / 2], [x0, y0 + h0 / 2])
} else {
// moving right edge, pin left edge
delta = vec.sub([x1 + w1, y1 + h1 / 2], [x0 + w0, y0 + h0 / 2])
}
} else if (h0 !== h1 && w0 === w1) {
if (y0 !== y1) {
// moving top edge, pin bottom edge
delta = vec.sub([x1 + w1 / 2, y1], [x0 + w0 / 2, y0])
} else {
// moving bottom edge, pin top edge
delta = vec.sub([x1 + w1 / 2, y1 + h1], [x0 + w0 / 2, y0 + h0])
}
} else if (x0 !== x1) {
if (y0 !== y1) {
// moving top left, pin bottom right
delta = vec.sub([x1, y1], [x0, y0])
} else {
// moving bottom left, pin top right
delta = vec.sub([x1, y1 + h1], [x0, y0 + h0])
}
} else if (y0 !== y1) {
// moving top right, pin bottom left
delta = vec.sub([x1 + w1, y1], [x0 + w0, y0])
} else {
// moving bottom right, pin top left
delta = vec.sub([x1 + w1, y1 + h1], [x0 + w0, y0 + h0])
}
return delta
}
export function deepClone<T extends unknown[] | object>(obj: T): T {
if (obj === null) return null
let clone = { ...obj }
Object.keys(obj).forEach(
(key) =>
(clone[key] =
typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key])
)
if (Array.isArray(obj)) {
// @ts-ignore
clone.length = obj.length
// @ts-ignore
return Array.from(clone) as T
}
return clone
}

1526
yarn.lock

File diff suppressed because it is too large Load diff