Updates arrows
This commit is contained in:
parent
fc2e3b3c4c
commit
daa44f9911
20 changed files with 1457 additions and 794 deletions
7
.babelrc
Normal file
7
.babelrc
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"env": {
|
||||
"test": {
|
||||
"presets": ["next/babel"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
{}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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) => { ... })
|
|
@ -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')
|
|
@ -38,9 +38,9 @@ const strokeWidths = {
|
|||
}
|
||||
|
||||
const dashArrays = {
|
||||
[DashStyle.Solid]: () => 'none',
|
||||
[DashStyle.Dashed]: (sw: number) => `${sw} ${sw * 2}`,
|
||||
[DashStyle.Dotted]: (sw: number) => `0 ${sw * 1.5}`,
|
||||
[DashStyle.Solid]: () => [1],
|
||||
[DashStyle.Dashed]: (sw: number) => [sw * 2, sw * 4],
|
||||
[DashStyle.Dotted]: (sw: number) => [0, sw * 3],
|
||||
}
|
||||
|
||||
const fontSizes = {
|
||||
|
@ -50,11 +50,11 @@ const fontSizes = {
|
|||
auto: 'auto',
|
||||
}
|
||||
|
||||
function getStrokeWidth(size: SizeStyle) {
|
||||
export function getStrokeWidth(size: SizeStyle) {
|
||||
return strokeWidths[size]
|
||||
}
|
||||
|
||||
function getStrokeDashArray(dash: DashStyle, strokeWidth: number) {
|
||||
export function getStrokeDashArray(dash: DashStyle, strokeWidth: number) {
|
||||
return dashArrays[dash](strokeWidth)
|
||||
}
|
||||
|
||||
|
@ -74,7 +74,7 @@ export function getShapeStyle(
|
|||
const { color, size, dash, isFilled } = style
|
||||
|
||||
const strokeWidth = getStrokeWidth(size)
|
||||
const strokeDasharray = getStrokeDashArray(dash, strokeWidth)
|
||||
const strokeDasharray = getStrokeDashArray(dash, strokeWidth).join()
|
||||
|
||||
return {
|
||||
stroke: strokes[color],
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { uniqueId } from 'utils/utils'
|
||||
import { getArcLength, lerp, uniqueId } from 'utils/utils'
|
||||
import vec from 'utils/vec'
|
||||
import {
|
||||
getSvgPathFromStroke,
|
||||
|
@ -7,7 +7,7 @@ import {
|
|||
translateBounds,
|
||||
pointsBetween,
|
||||
} from 'utils/utils'
|
||||
import { ArrowShape, Bounds, ShapeHandle, ShapeType } from 'types'
|
||||
import { ArrowShape, Bounds, DashStyle, ShapeHandle, ShapeType } from 'types'
|
||||
import { registerShapeUtils } from './index'
|
||||
import { circleFromThreePoints, isAngleBetween } from 'utils/utils'
|
||||
import { pointInBounds } from 'utils/bounds'
|
||||
|
@ -16,22 +16,20 @@ import {
|
|||
intersectLineSegmentBounds,
|
||||
} from 'utils/intersections'
|
||||
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 React from 'react'
|
||||
|
||||
const ctpCache = new WeakMap<ArrowShape['handles'], number[]>()
|
||||
const pathCache = new WeakMap<ArrowShape, string>([])
|
||||
|
||||
// A cache for semi-expensive circles calculated from three points
|
||||
function getCtp(shape: ArrowShape) {
|
||||
if (!ctpCache.has(shape.handles)) {
|
||||
const { start, end, bend } = shape.handles
|
||||
ctpCache.set(
|
||||
shape.handles,
|
||||
circleFromThreePoints(start.point, end.point, bend.point)
|
||||
)
|
||||
}
|
||||
|
||||
return ctpCache.get(shape.handles)
|
||||
const { start, end, bend } = shape.handles
|
||||
return circleFromThreePoints(start.point, end.point, bend.point)
|
||||
}
|
||||
|
||||
const arrow = registerShapeUtils<ArrowShape>({
|
||||
|
@ -40,10 +38,6 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
create(props) {
|
||||
const {
|
||||
point = [0, 0],
|
||||
points = [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
],
|
||||
handles = {
|
||||
start: {
|
||||
id: 'start',
|
||||
|
@ -77,7 +71,6 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
isLocked: false,
|
||||
isHidden: false,
|
||||
bend: 0,
|
||||
points,
|
||||
handles,
|
||||
decorations: {
|
||||
start: null,
|
||||
|
@ -94,63 +87,123 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
},
|
||||
|
||||
render(shape) {
|
||||
const { id, bend, handles } = shape
|
||||
const { id, bend, handles, style } = shape
|
||||
const { start, end, bend: _bend } = handles
|
||||
|
||||
const arrowDist = vec.dist(start.point, end.point)
|
||||
|
||||
const showCircle = !vec.isEqual(
|
||||
const isStraightLine = vec.isEqual(
|
||||
_bend.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 (!ctpCache.has(handles)) {
|
||||
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 (isStraightLine) {
|
||||
// Render a straight arrow as a freehand path.
|
||||
if (!pathCache.has(shape)) {
|
||||
renderPath(shape)
|
||||
}
|
||||
|
||||
const offset = -vec.dist(start.point, end.point) + strokeWidth
|
||||
|
||||
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) {
|
||||
|
@ -179,17 +232,20 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
|
||||
getBounds(shape) {
|
||||
if (!this.boundsCache.has(shape)) {
|
||||
const { start, end } = shape.handles
|
||||
this.boundsCache.set(shape, getBoundsFromPoints([start.point, end.point]))
|
||||
const { start, bend, end } = shape.handles
|
||||
this.boundsCache.set(
|
||||
shape,
|
||||
getBoundsFromPoints([start.point, bend.point, end.point])
|
||||
)
|
||||
}
|
||||
|
||||
return translateBounds(this.boundsCache.get(shape), shape.point)
|
||||
},
|
||||
|
||||
getRotatedBounds(shape) {
|
||||
const { start, end } = shape.handles
|
||||
const { start, bend, end } = shape.handles
|
||||
return translateBounds(
|
||||
getBoundsFromPoints([start.point, end.point], shape.rotation),
|
||||
getBoundsFromPoints([start.point, bend.point, end.point], shape.rotation),
|
||||
shape.point
|
||||
)
|
||||
},
|
||||
|
@ -200,7 +256,7 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
},
|
||||
|
||||
hitTest(shape, point) {
|
||||
const { start, end, bend } = shape.handles
|
||||
const { start, end } = shape.handles
|
||||
if (shape.bend === 0) {
|
||||
return (
|
||||
vec.distanceToLineSegment(
|
||||
|
@ -239,33 +295,42 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
transform(shape, bounds, { initialShape, scaleX, scaleY }) {
|
||||
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.points = shape.points.map((_, i) => {
|
||||
const [x, y] = initialShape.points[i]
|
||||
const handles = ['start', 'end']
|
||||
|
||||
handles.forEach((handle) => {
|
||||
const [x, y] = initialShape.handles[handle].point
|
||||
let nw = x / initialShapeBounds.width
|
||||
let nh = y / initialShapeBounds.height
|
||||
|
||||
if (i === 1) {
|
||||
let [x0, y0] = initialShape.points[0]
|
||||
if (x0 === x) nw = 1
|
||||
if (y0 === y) nh = 1
|
||||
}
|
||||
|
||||
return [
|
||||
shape.handles[handle].point = [
|
||||
bounds.width * (scaleX < 0 ? 1 - nw : nw),
|
||||
bounds.height * (scaleY < 0 ? 1 - nh : nh),
|
||||
]
|
||||
})
|
||||
|
||||
const { start, end, bend } = shape.handles
|
||||
const { start, bend, end } = shape.handles
|
||||
|
||||
start.point = shape.points[0]
|
||||
end.point = shape.points[1]
|
||||
const dist = vec.dist(start.point, end.point)
|
||||
|
||||
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
|
||||
},
|
||||
|
@ -279,10 +344,6 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
|
||||
shape.handles[handle.id] = handle
|
||||
|
||||
if (handle.index < 2) {
|
||||
shape.points[handle.index] = handle.point
|
||||
}
|
||||
|
||||
const { start, end, bend } = shape.handles
|
||||
|
||||
const dist = vec.dist(start.point, end.point)
|
||||
|
@ -327,6 +388,8 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
end.point = vec.sub(end.point, offset)
|
||||
bend.point = vec.sub(bend.point, offset)
|
||||
|
||||
shape.handles = { ...shape.handles }
|
||||
|
||||
return this
|
||||
},
|
||||
|
||||
|
@ -375,47 +438,6 @@ function getBendPoint(shape: ArrowShape) {
|
|||
: 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) {
|
||||
const { style, id } = shape
|
||||
const { start, end } = shape.handles
|
||||
|
@ -424,42 +446,34 @@ function renderPath(shape: ArrowShape, endAngle = 0) {
|
|||
|
||||
const strokeWidth = +getShapeStyle(style).strokeWidth * 2
|
||||
|
||||
const arrowDist = vec.dist(start.point, end.point)
|
||||
|
||||
const styles = getShapeStyle(shape.style)
|
||||
|
||||
const sw = +styles.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)
|
||||
const sw = strokeWidth
|
||||
|
||||
// Start
|
||||
const a = start.point
|
||||
|
||||
// End
|
||||
const b = end.point
|
||||
|
||||
// Middle
|
||||
const m = vec.add(
|
||||
vec.lrp(start.point, end.point, 0.25 + Math.abs(getRandom()) / 2),
|
||||
[getRandom() * sw, getRandom() * sw]
|
||||
)
|
||||
|
||||
// End
|
||||
const b = end.point
|
||||
// Left and right sides of the arrowhead
|
||||
let { left: c, right: d } = getArrowHeadPoints(shape, endAngle)
|
||||
|
||||
// Left
|
||||
let c = vec.add(
|
||||
end.point,
|
||||
vec.rot(v, Math.PI / 6 + (Math.PI / 8) * getRandom())
|
||||
)
|
||||
// Switch which side of the arrow is drawn first
|
||||
if (getRandom() > 0) [c, d] = [d, c]
|
||||
|
||||
// Right
|
||||
let d = vec.add(
|
||||
end.point,
|
||||
vec.rot(v, -(Math.PI / 6) + (Math.PI / 8) * getRandom())
|
||||
)
|
||||
|
||||
if (getRandom() > 0.5) {
|
||||
;[c, d] = [d, c]
|
||||
if (style.dash !== DashStyle.Solid) {
|
||||
pathCache.set(
|
||||
shape,
|
||||
(endAngle ? ['M', c, 'L', b, d] : ['M', a, 'L', b]).join(' ')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const points = endAngle
|
||||
|
@ -471,7 +485,7 @@ function renderPath(shape: ArrowShape, endAngle = 0) {
|
|||
...pointsBetween(d, b),
|
||||
]
|
||||
: [
|
||||
// The shaft too
|
||||
// The arrow shaft
|
||||
b,
|
||||
a,
|
||||
...pointsBetween(a, m),
|
||||
|
@ -493,3 +507,60 @@ function renderPath(shape: ArrowShape, endAngle = 0) {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -2,7 +2,12 @@ import { uniqueId, isMobile } from 'utils/utils'
|
|||
import vec from 'utils/vec'
|
||||
import { TextShape, ShapeType, FontSize, SizeStyle } from 'types'
|
||||
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 state from 'state'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
@ -98,6 +103,32 @@ const text = registerShapeUtils<TextShape>({
|
|||
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 (
|
||||
<foreignObject
|
||||
id={id}
|
||||
|
@ -107,37 +138,26 @@ const text = registerShapeUtils<TextShape>({
|
|||
height={bounds.height}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{isEditing ? (
|
||||
<StyledTextArea
|
||||
ref={ref}
|
||||
style={{
|
||||
font,
|
||||
color: styles.stroke,
|
||||
}}
|
||||
value={text}
|
||||
tabIndex={0}
|
||||
autoComplete="false"
|
||||
autoCapitalize="false"
|
||||
autoCorrect="false"
|
||||
autoSave="false"
|
||||
placeholder=""
|
||||
name="text"
|
||||
autoFocus={isMobile() ? true : false}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<StyledText
|
||||
style={{
|
||||
font,
|
||||
color: styles.stroke,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</StyledText>
|
||||
)}
|
||||
<StyledTextArea
|
||||
ref={ref}
|
||||
style={{
|
||||
font,
|
||||
color: styles.stroke,
|
||||
}}
|
||||
value={text}
|
||||
tabIndex={0}
|
||||
autoComplete="false"
|
||||
autoCapitalize="false"
|
||||
autoCorrect="false"
|
||||
autoSave="false"
|
||||
placeholder=""
|
||||
name="text"
|
||||
autoFocus={isMobile() ? true : false}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</foreignObject>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -5,7 +5,10 @@
|
|||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"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": {
|
||||
"@monaco-editor/react": "^4.1.3",
|
||||
|
@ -46,7 +49,8 @@
|
|||
"@types/react": "^17.0.5",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"cypress": "^7.3.0",
|
||||
"babel-jest": "^27.0.2",
|
||||
"jest": "^27.0.4",
|
||||
"monaco-editor": "^0.24.0",
|
||||
"typescript": "^4.2.4"
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import paste from './paste'
|
|||
import rotateCcw from './rotate-ccw'
|
||||
import stretch from './stretch'
|
||||
import style from './style'
|
||||
import mutate from './mutate'
|
||||
import toggle from './toggle'
|
||||
import transform from './transform'
|
||||
import transformSingle from './transform-single'
|
||||
|
@ -28,6 +29,7 @@ import edit from './edit'
|
|||
import resetBounds from './reset-bounds'
|
||||
|
||||
const commands = {
|
||||
mutate,
|
||||
align,
|
||||
arrow,
|
||||
changePage,
|
||||
|
|
49
state/commands/mutate.ts
Normal file
49
state/commands/mutate.ts
Normal 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)
|
||||
)
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
|
@ -18,24 +18,12 @@ export default function transformCommand(
|
|||
name: 'transform_shapes',
|
||||
category: 'canvas',
|
||||
do(data) {
|
||||
const { type, shapeBounds } = after
|
||||
const { shapeBounds } = after
|
||||
|
||||
const { shapes } = getPage(data)
|
||||
|
||||
for (let id in shapeBounds) {
|
||||
const { initialShapeBounds: bounds } = after.shapeBounds[id]
|
||||
const { initialShape, transformOrigin } = before.shapeBounds[id]
|
||||
const shape = shapes[id]
|
||||
|
||||
getShapeUtils(shape)
|
||||
.transform(shape, bounds, {
|
||||
type,
|
||||
initialShape,
|
||||
transformOrigin,
|
||||
scaleX,
|
||||
scaleY,
|
||||
})
|
||||
.onSessionComplete(shape)
|
||||
shapes[id] = shapeBounds[id].initialShape
|
||||
}
|
||||
|
||||
updateParents(data, Object.keys(shapeBounds))
|
||||
|
|
|
@ -5,6 +5,7 @@ import commands from 'state/commands'
|
|||
import { current, freeze } from 'immer'
|
||||
import { getShapeUtils } from 'lib/shape-utils'
|
||||
import {
|
||||
deepClone,
|
||||
getBoundsCenter,
|
||||
getBoundsFromPoints,
|
||||
getCommonBounds,
|
||||
|
@ -103,26 +104,28 @@ export default class TransformSession extends BaseSession {
|
|||
}
|
||||
|
||||
complete(data: Data) {
|
||||
if (!this.snapshot.hasUnlockedShapes) return
|
||||
const { initialShapes, hasUnlockedShapes } = this.snapshot
|
||||
|
||||
commands.transform(
|
||||
data,
|
||||
this.snapshot,
|
||||
getTransformSnapshot(data, this.transformType),
|
||||
this.scaleX,
|
||||
this.scaleY
|
||||
if (!hasUnlockedShapes) return
|
||||
|
||||
const page = getPage(data)
|
||||
|
||||
const finalShapes = initialShapes.map((shape) =>
|
||||
deepClone(page.shapes[shape.id])
|
||||
)
|
||||
|
||||
commands.mutate(data, initialShapes, finalShapes)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTransformSnapshot(data: Data, transformType: Edge | Corner) {
|
||||
const cData = current(data)
|
||||
const { currentPageId } = cData
|
||||
const page = getPage(cData)
|
||||
const { currentPageId } = data
|
||||
const page = getPage(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)
|
||||
.map((shape) => deepClone(shape))
|
||||
|
||||
const hasUnlockedShapes = initialShapes.length > 0
|
||||
|
||||
|
@ -151,6 +154,7 @@ export function getTransformSnapshot(data: Data, transformType: Edge | Corner) {
|
|||
hasUnlockedShapes,
|
||||
isAllAspectRatioLocked,
|
||||
currentPageId,
|
||||
initialShapes,
|
||||
initialBounds: commonBounds,
|
||||
shapeBounds: Object.fromEntries(
|
||||
initialShapes.map((shape) => {
|
||||
|
|
|
@ -66,7 +66,6 @@ const initialData: Data = {
|
|||
size: SizeStyle.Medium,
|
||||
color: ColorStyle.Black,
|
||||
dash: DashStyle.Solid,
|
||||
fontSize: FontSize.Medium,
|
||||
isFilled: false,
|
||||
},
|
||||
activeTool: 'select',
|
||||
|
@ -131,7 +130,7 @@ const state = createState({
|
|||
wait: 0.01,
|
||||
if: 'hasSelection',
|
||||
do: 'zoomCameraToSelectionActual',
|
||||
else: ['zoomCameraToFit', 'zoomCameraToActual'],
|
||||
else: ['zoomCameraToActual'],
|
||||
},
|
||||
on: {
|
||||
COPIED: { if: 'hasSelection', do: 'copyToClipboard' },
|
||||
|
|
9
todo.md
9
todo.md
|
@ -47,5 +47,10 @@
|
|||
|
||||
## Clipboard
|
||||
|
||||
- [ ] Copy
|
||||
- [ ] Paste
|
||||
- [x] Copy
|
||||
- [x] Paste shapes
|
||||
- [ ] Paste as text
|
||||
|
||||
## Copy to SVG
|
||||
|
||||
- [ ] Copy to SVG
|
||||
|
|
7
types.ts
7
types.ts
|
@ -16,7 +16,7 @@ export interface Data {
|
|||
isToolLocked: boolean
|
||||
isPenLocked: boolean
|
||||
}
|
||||
currentStyle: ShapeStyles & TextStyles
|
||||
currentStyle: ShapeStyles
|
||||
activeTool: ShapeType | 'select'
|
||||
brush?: Bounds
|
||||
boundsRotation: number
|
||||
|
@ -114,10 +114,6 @@ export type ShapeStyles = {
|
|||
isFilled: boolean
|
||||
}
|
||||
|
||||
export type TextStyles = {
|
||||
fontSize: FontSize
|
||||
}
|
||||
|
||||
export interface BaseShape {
|
||||
id: string
|
||||
seed: number
|
||||
|
@ -180,7 +176,6 @@ export interface DrawShape extends BaseShape {
|
|||
|
||||
export interface ArrowShape extends BaseShape {
|
||||
type: ShapeType.Arrow
|
||||
points: number[][]
|
||||
handles: Record<string, ShapeHandle>
|
||||
bend: number
|
||||
decorations?: {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Bounds } from "types"
|
||||
import { Bounds } from 'types'
|
||||
import {
|
||||
intersectPolygonBounds,
|
||||
intersectPolylineBounds,
|
||||
} from "./intersections"
|
||||
} from './intersections'
|
||||
|
||||
/**
|
||||
* Get whether two bounds collide.
|
||||
|
|
|
@ -1850,3 +1850,65 @@ export function decompress(s: string) {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue