Adds most of text feature, except creation

This commit is contained in:
Steve Ruiz 2021-06-15 12:58:51 +01:00
parent 94fcca1685
commit 027815f199
40 changed files with 718 additions and 124 deletions

View file

@ -15,7 +15,6 @@ import CenterHandle from './center-handle'
import CornerHandle from './corner-handle'
import EdgeHandle from './edge-handle'
import RotateHandle from './rotate-handle'
import Handles from './handles'
export default function Bounds() {
const isBrushing = useSelector((s) => s.isIn('brushSelecting'))

View file

@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useRef } from 'react'
import state, { useSelector } from 'state'
import inputs from 'state/inputs'
import styled from 'styles'
@ -8,11 +8,12 @@ function handlePointerDown(e: React.PointerEvent<SVGRectElement>) {
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
const info = inputs.pointerDown(e, 'bounds')
if (e.button === 0) {
state.send('POINTED_BOUNDS', inputs.pointerDown(e, 'bounds'))
state.send('POINTED_BOUNDS', info)
} else if (e.button === 2) {
state.send('RIGHT_POINTED', inputs.pointerDown(e, 'bounds'))
state.send('RIGHT_POINTED', info)
}
}

View file

@ -23,12 +23,12 @@ export default function CenterHandle({
const StyledBounds = styled('rect', {
fill: 'none',
stroke: '$bounds',
zStrokeWidth: 2,
zStrokeWidth: 1.5,
variants: {
isLocked: {
true: {
zStrokeWidth: 1,
zStrokeWidth: 1.5,
zDash: 2,
},
},

View file

@ -1,4 +1,4 @@
import useHandleEvents from 'hooks/useBoundsHandleEvents'
import useBoundsEvents from 'hooks/useBoundsEvents'
import styled from 'styles'
import { Corner, Bounds } from 'types'
@ -11,7 +11,7 @@ export default function CornerHandle({
bounds: Bounds
corner: Corner
}) {
const events = useHandleEvents(corner)
const events = useBoundsEvents(corner)
const isTop = corner === Corner.TopLeft || corner === Corner.TopRight
const isLeft = corner === Corner.TopLeft || corner === Corner.BottomLeft
@ -53,5 +53,5 @@ const StyledCorner = styled('rect', {
const StyledCornerInner = styled('rect', {
stroke: '$bounds',
fill: '#fff',
zStrokeWidth: 2,
zStrokeWidth: 1.5,
})

View file

@ -1,4 +1,4 @@
import useHandleEvents from 'hooks/useBoundsHandleEvents'
import useBoundsEvents from 'hooks/useBoundsEvents'
import styled from 'styles'
import { Edge, Bounds } from 'types'
@ -11,7 +11,7 @@ export default function EdgeHandle({
bounds: Bounds
edge: Edge
}) {
const events = useHandleEvents(edge)
const events = useBoundsEvents(edge)
const isHorizontal = edge === Edge.Top || edge === Edge.Bottom
const isFarEdge = edge === Edge.Right || edge === Edge.Bottom

View file

@ -1,4 +1,4 @@
import useHandleEvents from 'hooks/useBoundsHandleEvents'
import useHandleEvents from 'hooks/useBoundsEvents'
import styled from 'styles'
import { Bounds } from 'types'
@ -33,6 +33,6 @@ export default function Rotate({
const StyledRotateHandle = styled('circle', {
stroke: '$bounds',
fill: '#fff',
zStrokeWidth: 2,
zStrokeWidth: 1.5,
cursor: 'grab',
})

View file

@ -9,6 +9,8 @@ export default function Defs() {
const currentPageShapeIds = useSelector(({ data }) => {
return Object.values(getPage(data).shapes)
.filter(Boolean)
.filter((shape) => !getShapeUtils(shape).isForeignObject)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
}, deepCompareArrays)
@ -29,6 +31,7 @@ export default function Defs() {
const Def = memo(function Def({ id }: { id: string }) {
const shape = useSelector((s) => getPage(s.data).shapes[id])
if (!shape) return null
return getShapeUtils(shape).render(shape)
return getShapeUtils(shape).render(shape, { isEditing: false })
})

View file

@ -26,6 +26,7 @@ export default function Page() {
[window.innerWidth, window.innerHeight],
s.data
)
viewportCache.set(pageState, {
minX,
minY,

View file

@ -1,5 +1,5 @@
import React, { useRef, memo } from 'react'
import { useSelector } from 'state'
import state, { useSelector } from 'state'
import styled from 'styles'
import { getShapeUtils } from 'lib/shape-utils'
import { getBoundsCenter, getPage } from 'utils/utils'
@ -18,9 +18,11 @@ interface ShapeProps {
function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
const shape = useSelector((s) => getPage(s.data).shapes[id])
const isEditing = useSelector((s) => s.data.editingId === id)
const rGroup = useRef<SVGGElement>(null)
const events = useShapeEvents(id, shape?.type === ShapeType.Group, rGroup)
const events = useShapeEvents(id, getShapeUtils(shape)?.isParent, rGroup)
// This is a problem with deleted shapes. The hooks in this component
// may sometimes run before the hook in the Page component, which means
@ -28,9 +30,13 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
// detects the change and pulls this component.
if (!shape) return null
const isGroup = shape.type === ShapeType.Group
const utils = getShapeUtils(shape)
const style = getShapeStyle(shape.style)
const shapeUtils = getShapeUtils(shape)
const { isShy, isParent, isForeignObject } = shapeUtils
const center = getShapeUtils(shape).getCenter(shape)
const bounds = shapeUtils.getBounds(shape)
const center = shapeUtils.getCenter(shape)
const rotation = shape.rotation * (180 / Math.PI)
const transform = `
@ -39,22 +45,46 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
translate(${shape.point})
`
const style = getShapeStyle(shape.style)
return (
<StyledGroup ref={rGroup} transform={transform}>
{isSelecting && !isGroup && (
<HoverIndicator
as="use"
href={'#' + id}
strokeWidth={+style.strokeWidth + 4}
variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
{...events}
/>
)}
<StyledGroup
ref={rGroup}
transform={transform}
onBlur={() => state.send('BLURRED_SHAPE', { target: id })}
>
{isSelecting &&
!isShy &&
(isForeignObject ? (
<HoverIndicator
as="rect"
width={bounds.width}
height={bounds.height}
strokeWidth={1.5}
variant={'ghost'}
{...events}
/>
) : (
<HoverIndicator
as="use"
href={'#' + id}
strokeWidth={+style.strokeWidth + 4}
variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
{...events}
/>
))}
{!shape.isHidden && <RealShape isGroup={isGroup} id={id} style={style} />}
{isGroup &&
{!shape.isHidden &&
(isForeignObject ? (
shapeUtils.render(shape, { isEditing })
) : (
<RealShape
isParent={isParent}
id={id}
style={style}
isEditing={isEditing}
/>
))}
{isParent &&
shape.children.map((shapeId) => (
<Shape
key={shapeId}
@ -68,17 +98,19 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
}
interface RealShapeProps {
isGroup: boolean
id: string
style: Partial<React.SVGProps<SVGUseElement>>
isParent: boolean
isEditing: boolean
}
const RealShape = memo(function RealShape({
isGroup,
id,
style,
isParent,
isEditing,
}: RealShapeProps) {
return <StyledShape as="use" data-shy={isGroup} href={'#' + id} {...style} />
return <StyledShape as="use" data-shy={isParent} href={'#' + id} {...style} />
})
const StyledShape = styled('path', {
@ -91,11 +123,15 @@ const HoverIndicator = styled('path', {
stroke: '$selected',
strokeLinecap: 'round',
strokeLinejoin: 'round',
transform: 'all .2s',
fill: 'transparent',
filter: 'url(#expand)',
variants: {
variant: {
ghost: {
pointerEvents: 'all',
filter: 'none',
opacity: 0,
},
hollow: {
pointerEvents: 'stroke',
},

View file

@ -1,19 +1,25 @@
import { useCallback, useRef } from 'react'
import { useCallback } from 'react'
import inputs from 'state/inputs'
import { Edge, Corner } from 'types'
import state from '../state'
export default function useBoundsHandleEvents(
handle: Edge | Corner | 'rotate'
) {
export default function useBoundsEvents(handle: Edge | Corner | 'rotate') {
const onPointerDown = useCallback(
(e) => {
if (e.buttons !== 1) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
state.send('POINTED_BOUNDS_HANDLE', inputs.pointerDown(e, handle))
if (e.button === 0) {
const info = inputs.pointerDown(e, handle)
if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) {
state.send('DOUBLE_POINTED_BOUNDS_HANDLE', info)
}
state.send('POINTED_BOUNDS_HANDLE', info)
}
},
[handle]
)

View file

@ -6,7 +6,20 @@ import { getKeyboardEventInfo, metaKey } from 'utils/utils'
export default function useKeyboardEvents() {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (metaKey(e) && !['i', 'r', 'j'].includes(e.key)) {
if (
metaKey(e) &&
![
'a',
'i',
'r',
'j',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'z',
].includes(e.key)
) {
e.preventDefault()
}

View file

@ -4,12 +4,12 @@ import inputs from 'state/inputs'
export default function useShapeEvents(
id: string,
isGroup: boolean,
isParent: boolean,
rGroup: MutableRefObject<SVGElement>
) {
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isGroup) return
if (isParent) return
if (!inputs.canAccept(e.pointerId)) return
e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId)
@ -42,7 +42,7 @@ export default function useShapeEvents(
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
if (isGroup) {
if (isParent) {
state.send('HOVERED_GROUP', inputs.pointerEnter(e, id))
} else {
state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
@ -55,7 +55,7 @@ export default function useShapeEvents(
(e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
if (isGroup) {
if (isParent) {
state.send('MOVED_OVER_GROUP', inputs.pointerEnter(e, id))
} else {
state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
@ -67,7 +67,7 @@ export default function useShapeEvents(
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
if (isGroup) {
if (isParent) {
state.send('UNHOVERED_GROUP', { target: id })
} else {
state.send('UNHOVERED_SHAPE', { target: id })

View file

@ -15,7 +15,7 @@ export default class Ray extends CodeShape<RayShape> {
type: ShapeType.Ray,
isGenerated: true,
name: 'Ray',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
direction: [0, 1],

View file

@ -1,5 +1,5 @@
import { SVGProps } from 'react'
import { ColorStyle, DashStyle, Shape, ShapeStyles, SizeStyle } from 'types'
import { ColorStyle, DashStyle, FontSize, ShapeStyles, SizeStyle } from 'types'
export const strokes: Record<ColorStyle, string> = {
[ColorStyle.White]: 'rgba(248, 249, 250, 1.000)',
@ -43,6 +43,14 @@ const dashArrays = {
[DashStyle.Dotted]: (sw: number) => `0 ${sw * 1.5}`,
}
const fontSizes = {
[FontSize.Small]: 16,
[FontSize.Medium]: 28,
[FontSize.Large]: 32,
[FontSize.ExtraLarge]: 72,
auto: 'auto',
}
function getStrokeWidth(size: SizeStyle) {
return strokeWidths[size]
}
@ -51,6 +59,16 @@ function getStrokeDashArray(dash: DashStyle, strokeWidth: number) {
return dashArrays[dash](strokeWidth)
}
export function getFontSize(size: FontSize) {
return fontSizes[size]
}
export function getFontStyle(size: FontSize, style: ShapeStyles) {
const fontSize = getFontSize(size)
return `${fontSize}px Verveine Regular`
}
export function getShapeStyle(
style: ShapeStyles
): Partial<SVGProps<SVGUseElement>> {

View file

@ -70,7 +70,7 @@ const arrow = registerShapeUtils<ArrowShape>({
type: ShapeType.Arrow,
isGenerated: false,
name: 'Arrow',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point,
rotation: 0,

View file

@ -18,7 +18,7 @@ const circle = registerShapeUtils<CircleShape>({
type: ShapeType.Circle,
isGenerated: false,
name: 'Circle',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,

View file

@ -17,7 +17,7 @@ const dot = registerShapeUtils<DotShape>({
type: ShapeType.Dot,
isGenerated: false,
name: 'Dot',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,

View file

@ -28,7 +28,7 @@ const draw = registerShapeUtils<DrawShape>({
type: ShapeType.Draw,
isGenerated: false,
name: 'Draw',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
points: [],

View file

@ -21,7 +21,7 @@ const ellipse = registerShapeUtils<EllipseShape>({
type: ShapeType.Ellipse,
isGenerated: false,
name: 'Ellipse',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
radiusX: 1,

View file

@ -22,6 +22,8 @@ import { boundsContainPolygon } from 'utils/bounds'
const group = registerShapeUtils<GroupShape>({
boundsCache: new WeakMap([]),
isShy: true,
isParent: true,
create(props) {
return {
@ -30,7 +32,7 @@ const group = registerShapeUtils<GroupShape>({
type: ShapeType.Group,
isGenerated: false,
name: 'Group',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
size: [1, 1],

View file

@ -8,7 +8,6 @@ import {
ShapeBinding,
Mutable,
ShapeByType,
Data,
} from 'types'
import * as vec from 'utils/vec'
import {
@ -32,6 +31,7 @@ import ray from './ray'
import draw from './draw'
import arrow from './arrow'
import group from './group'
import text from './text'
/*
Shape Utiliies
@ -51,12 +51,24 @@ export interface ShapeUtility<K extends Shape> {
// Whether to show transform controls when this shape is selected.
canTransform: boolean
// Whether the shape's aspect ratio can change
// Whether the shape's aspect ratio can change.
canChangeAspectRatio: boolean
// Whether the shape's style can be filled
// Whether the shape's style can be filled.
canStyleFill: boolean
// Whether the shape may be edited in an editing mode
canEdit: boolean
// Whether the shape is a foreign object.
isForeignObject: boolean
// Whether the shape can contain other shapes.
isParent: boolean
// Whether the shape is only shown when on hovered.
isShy: boolean
// Create a new shape.
create(props: Partial<K>): K
@ -148,11 +160,21 @@ export interface ShapeUtility<K extends Shape> {
handle: Partial<K['handles']>
): ShapeUtility<K>
// Respond when a user double clicks the shape's bounds.
onBoundsReset(this: ShapeUtility<K>, shape: Mutable<K>): ShapeUtility<K>
// Respond when a user double clicks the center of the shape.
onDoubleFocus(this: ShapeUtility<K>, shape: Mutable<K>): ShapeUtility<K>
// Clean up changes when a session ends.
onSessionComplete(this: ShapeUtility<K>, shape: Mutable<K>): ShapeUtility<K>
// Render a shape to JSX.
render(this: ShapeUtility<K>, shape: K): JSX.Element
render(
this: ShapeUtility<K>,
shape: K,
info: { isEditing: boolean }
): JSX.Element
// Get the bounds of the a shape.
getBounds(this: ShapeUtility<K>, shape: K): Bounds
@ -168,6 +190,8 @@ export interface ShapeUtility<K extends Shape> {
// Test whether bounds collide with or contain a shape.
hitTestBounds(this: ShapeUtility<K>, shape: K, bounds: Bounds): boolean
getShouldDelete(this: ShapeUtility<K>, shape: K): boolean
}
// A mapping of shape types to shape utilities.
@ -181,7 +205,7 @@ const shapeUtilityMap: Record<ShapeType, ShapeUtility<Shape>> = {
[ShapeType.Ray]: ray,
[ShapeType.Draw]: draw,
[ShapeType.Arrow]: arrow,
[ShapeType.Text]: arrow,
[ShapeType.Text]: text,
[ShapeType.Group]: group,
}
@ -191,7 +215,7 @@ const shapeUtilityMap: Record<ShapeType, ShapeUtility<Shape>> = {
* @returns
*/
export function getShapeUtils<T extends Shape>(shape: T): ShapeUtility<T> {
return shapeUtilityMap[shape.type] as ShapeUtility<T>
return shapeUtilityMap[shape?.type] as ShapeUtility<T>
}
function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
@ -200,6 +224,10 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
canTransform: true,
canChangeAspectRatio: true,
canStyleFill: true,
canEdit: false,
isShy: false,
isParent: false,
isForeignObject: false,
create(props) {
return {
@ -207,7 +235,7 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
isGenerated: false,
point: [0, 0],
name: 'Shape',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
rotation: 0,
isAspectRatioLocked: false,
@ -262,6 +290,14 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
return this
},
onDoubleFocus() {
return this
},
onBoundsReset() {
return this
},
onSessionComplete() {
return this
},
@ -313,6 +349,10 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
Object.assign(shape.style, style)
return this
},
getShouldDelete(shape) {
return false
},
}
}

View file

@ -19,7 +19,7 @@ const line = registerShapeUtils<LineShape>({
type: ShapeType.Line,
isGenerated: false,
name: 'Line',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
direction: [0, 0],

View file

@ -17,7 +17,7 @@ const polyline = registerShapeUtils<PolylineShape>({
type: ShapeType.Polyline,
isGenerated: false,
name: 'Polyline',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
points: [[0, 0]],

View file

@ -18,7 +18,7 @@ const ray = registerShapeUtils<RayShape>({
type: ShapeType.Ray,
isGenerated: false,
name: 'Ray',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
direction: [0, 1],

View file

@ -24,7 +24,7 @@ const rectangle = registerShapeUtils<RectangleShape>({
type: ShapeType.Rectangle,
isGenerated: false,
name: 'Rectangle',
parentId: 'page0',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
size: [1, 1],

192
lib/shape-utils/text.tsx Normal file
View file

@ -0,0 +1,192 @@
import { v4 as uuid } from 'uuid'
import * as vec from 'utils/vec'
import { TextShape, ShapeType, FontSize } from 'types'
import { registerShapeUtils } from './index'
import { defaultStyle, getFontStyle, getShapeStyle } from 'lib/shape-styles'
import styled from 'styles'
import state from 'state'
import { useEffect, useRef } from 'react'
// A div used for measurement
if (document.getElementById('__textMeasure')) {
document.getElementById('__textMeasure').remove()
}
const mdiv = document.createElement('pre')
mdiv.id = '__textMeasure'
mdiv.style.whiteSpace = 'pre'
mdiv.style.width = 'auto'
mdiv.style.border = '1px solid red'
mdiv.style.padding = '4px'
mdiv.style.margin = '0px'
mdiv.style.opacity = '0'
mdiv.style.position = 'absolute'
mdiv.style.top = '-500px'
mdiv.style.left = '0px'
mdiv.style.zIndex = '9999'
document.body.appendChild(mdiv)
const text = registerShapeUtils<TextShape>({
isForeignObject: true,
canChangeAspectRatio: false,
canEdit: true,
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
seed: Math.random(),
type: ShapeType.Text,
isGenerated: false,
name: 'Text',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
text: '',
size: 'auto',
fontSize: FontSize.Medium,
...props,
}
},
render(shape, { isEditing }) {
const { id, text, style } = shape
const styles = getShapeStyle(style)
const font = getFontStyle(shape.fontSize, shape.style)
const bounds = this.getBounds(shape)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
state.send('EDITED_SHAPE', { change: { text: e.currentTarget.value } })
}
return (
<foreignObject
id={id}
x={0}
y={0}
width={bounds.width}
height={bounds.height}
pointerEvents="none"
>
<StyledText
key={id}
style={{
font,
color: styles.fill,
}}
value={text}
onChange={handleChange}
isEditing={isEditing}
onFocus={(e) => e.currentTarget.select()}
/>
</foreignObject>
)
},
getBounds(shape) {
const [minX, minY] = shape.point
let width: number
let height: number
if (shape.size === 'auto') {
// Calculate a size by rendering text into a div
mdiv.innerHTML = shape.text + '&nbsp;'
mdiv.style.font = getFontStyle(shape.fontSize, shape.style)
;[width, height] = [mdiv.offsetWidth, mdiv.offsetHeight]
} else {
// Use the shape's explicit size for width and height.
;[width, height] = shape.size
}
return {
minX,
maxX: minX + width,
minY,
maxY: minY + height,
width,
height,
}
},
hitTest(shape, test) {
return true
},
transform(shape, bounds, { initialShape, transformOrigin, scaleX, scaleY }) {
if (shape.rotation === 0 && !shape.isAspectRatioLocked) {
shape.size = [bounds.width, bounds.height]
shape.point = [bounds.minX, bounds.minY]
} else {
if (initialShape.size === 'auto') return
shape.size = vec.mul(
initialShape.size,
Math.min(Math.abs(scaleX), Math.abs(scaleY))
)
shape.point = [
bounds.minX +
(bounds.width - shape.size[0]) *
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
bounds.minY +
(bounds.height - shape.size[1]) *
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
]
shape.rotation =
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
? -initialShape.rotation
: initialShape.rotation
}
return this
},
transformSingle(shape, bounds) {
shape.size = [bounds.width, bounds.height]
shape.point = [bounds.minX, bounds.minY]
return this
},
onBoundsReset(shape) {
shape.size = 'auto'
return this
},
getShouldDelete(shape) {
return shape.text.length === 0
},
})
export default text
const StyledText = styled('textarea', {
width: '100%',
height: '100%',
border: 'none',
padding: '4px',
whiteSpace: 'pre',
resize: 'none',
minHeight: 1,
minWidth: 1,
outline: 'none',
backgroundColor: 'transparent',
overflow: 'hidden',
variants: {
isEditing: {
true: {
backgroundColor: '$boundsBg',
pointerEvents: 'all',
},
},
},
})

BIN
public/VerveineRegular.woff Normal file

Binary file not shown.

View file

@ -1,4 +0,0 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

40
state/commands/edit.ts Normal file
View file

@ -0,0 +1,40 @@
import Command from './command'
import history from '../history'
import { Data } from 'types'
import { getPage, getShape } from 'utils/utils'
import { EditSnapshot } from 'state/sessions/edit-session'
import { getShapeUtils } from 'lib/shape-utils'
export default function handleCommand(
data: Data,
before: EditSnapshot,
after: EditSnapshot
) {
history.execute(
data,
new Command({
name: 'edited_shape',
category: 'canvas',
do(data, isInitial) {
const { initialShape, currentPageId } = after
const page = getPage(data, currentPageId)
page.shapes[initialShape.id] = initialShape
const shape = page.shapes[initialShape.id]
if (getShapeUtils(shape).getShouldDelete(shape)) {
delete page.shapes[initialShape.id]
}
},
undo(data) {
const { initialShape, currentPageId } = before
const page = getPage(data, currentPageId)
page.shapes[initialShape.id] = initialShape
},
})
)
}

View file

@ -23,6 +23,8 @@ import transform from './transform'
import transformSingle from './transform-single'
import translate from './translate'
import ungroup from './ungroup'
import edit from './edit'
import resetBounds from './reset-bounds'
const commands = {
align,
@ -35,12 +37,14 @@ const commands = {
distribute,
draw,
duplicate,
edit,
generate,
group,
handle,
move,
moveToPage,
nudge,
resetBounds,
rotate,
rotateCcw,
stretch,

View file

@ -0,0 +1,31 @@
import Command from './command'
import history from '../history'
import { Data } from 'types'
import { getPage, getSelectedShapes } from 'utils/utils'
import { current } from 'immer'
import { getShapeUtils } from 'lib/shape-utils'
export default function resetBoundsCommand(data: Data) {
const initialShapes = Object.fromEntries(
getSelectedShapes(current(data)).map((shape) => [shape.id, shape])
)
history.execute(
data,
new Command({
name: 'reset_bounds',
category: 'canvas',
do(data) {
getSelectedShapes(data).forEach((shape) => {
getShapeUtils(shape).onBoundsReset(shape)
})
},
undo(data) {
const page = getPage(data)
getSelectedShapes(data).forEach((shape) => {
page.shapes[shape.id] = initialShapes[shape.id]
})
},
})
)
}

View file

@ -1,9 +1,9 @@
import { Data, ShapeType } from 'types'
import { Data, FontSize, ShapeType } from 'types'
import shapeUtils from 'lib/shape-utils'
export const defaultDocument: Data['document'] = {
id: '0001',
name: 'My Document',
name: 'My Default Document',
pages: {
page1: {
id: 'page1',
@ -11,6 +11,40 @@ export const defaultDocument: Data['document'] = {
name: 'Page 1',
childIndex: 0,
shapes: {
// textShape0: shapeUtils[ShapeType.Text].create({
// id: 'textShape0',
// point: [0, 0],
// text: 'Short',
// childIndex: 0,
// }),
// textShape1: shapeUtils[ShapeType.Text].create({
// id: 'textShape1',
// point: [100, 150],
// fontSize: FontSize.Small,
// text: 'Well, this is a pretty long title. I hope it all still works',
// childIndex: 1,
// }),
// textShape2: shapeUtils[ShapeType.Text].create({
// id: 'textShape2',
// point: [100, 200],
// fontSize: FontSize.Medium,
// text: 'Well, this is a pretty long title. I hope it all still works',
// childIndex: 2,
// }),
// textShape3: shapeUtils[ShapeType.Text].create({
// id: 'textShape3',
// point: [100, 250],
// fontSize: FontSize.Large,
// text: 'Well, this is a pretty long title. I hope it all still works',
// childIndex: 3,
// }),
// textShape4: shapeUtils[ShapeType.Text].create({
// id: 'textShape4',
// point: [100, 300],
// fontSize: FontSize.ExtraLarge,
// text: 'Well, this is a pretty long title. I hope it all still works',
// childIndex: 4,
// }),
// arrowShape0: shapeUtils[ShapeType.Arrow].create({
// id: 'arrowShape0',
// point: [200, 200],

View file

@ -1,12 +1,13 @@
import React from 'react'
import { PointerInfo } from 'types'
import * as vec from 'utils/vec'
import { isDarwin, getPoint } from 'utils/utils'
const DOUBLE_CLICK_DURATION = 300
class Inputs {
activePointerId?: number
lastPointerDownTime = 0
lastPointerUpTime = 0
points: Record<string, PointerInfo> = {}
touchStart(e: TouchEvent | React.TouchEvent, target: string) {
@ -119,7 +120,7 @@ class Inputs {
return info
}
pointerUp(e: PointerEvent | React.PointerEvent) {
pointerUp = (e: PointerEvent | React.PointerEvent) => {
const { shiftKey, ctrlKey, metaKey, altKey } = e
const prev = this.points[e.pointerId]
@ -137,24 +138,31 @@ class Inputs {
delete this.points[e.pointerId]
delete this.activePointerId
this.lastPointerDownTime = Date.now()
if (vec.dist(info.origin, info.point) < 8) {
this.lastPointerUpTime = Date.now()
}
return info
}
wheel(e: WheelEvent) {
wheel = (e: WheelEvent) => {
const { shiftKey, ctrlKey, metaKey, altKey } = e
return { point: getPoint(e), shiftKey, ctrlKey, metaKey, altKey }
}
canAccept(pointerId: PointerEvent['pointerId']) {
canAccept = (pointerId: PointerEvent['pointerId']) => {
return (
this.activePointerId === undefined || this.activePointerId === pointerId
)
}
isDoubleClick() {
return Date.now() - this.lastPointerDownTime < DOUBLE_CLICK_DURATION
const { origin, point } = this.pointer
return (
Date.now() - this.lastPointerUpTime < DOUBLE_CLICK_DURATION &&
vec.dist(origin, point) < 8
)
}
get pointer() {

View file

@ -0,0 +1,51 @@
import { Data, LineShape, RayShape, Shape } from 'types'
import * as vec from 'utils/vec'
import BaseSession from './base-session'
import commands from 'state/commands'
import { current } from 'immer'
import {
getPage,
getSelectedIds,
getSelectedShapes,
getShape,
} from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
export default class EditSession extends BaseSession {
snapshot: EditSnapshot
constructor(data: Data) {
super(data)
this.snapshot = getEditSnapshot(data)
}
update(data: Data, change: Partial<Shape>) {
const initialShape = this.snapshot.initialShape
const shape = getShape(data, initialShape.id)
const utils = getShapeUtils(shape)
Object.entries(change).forEach(([key, value]) => {
utils.setProperty(shape, key as keyof Shape, value as Shape[keyof Shape])
})
}
cancel(data: Data) {
const initialShape = this.snapshot.initialShape
const page = getPage(data)
page.shapes[initialShape.id] = initialShape
}
complete(data: Data) {
commands.edit(data, this.snapshot, getEditSnapshot(data))
}
}
export function getEditSnapshot(data: Data) {
const initialShape = getSelectedShapes(current(data))[0]
return {
currentPageId: data.currentPageId,
initialShape,
}
}
export type EditSnapshot = ReturnType<typeof getEditSnapshot>

View file

@ -8,6 +8,7 @@ import TransformSession from './transform-session'
import TransformSingleSession from './transform-single-session'
import TranslateSession from './translate-session'
import HandleSession from './handle-session'
import EditSession from './edit-session'
export {
ArrowSession,
@ -20,4 +21,5 @@ export {
TransformSingleSession,
TranslateSession,
HandleSession,
EditSession,
}

View file

@ -25,6 +25,7 @@ import {
getCameraZoom,
getSelectedIds,
setSelectedIds,
getPageState,
} from 'utils/utils'
import {
Data,
@ -42,6 +43,7 @@ import {
DashStyle,
SizeStyle,
ColorStyle,
FontSize,
} from 'types'
import session from './session'
import { pointInBounds } from 'utils/bounds'
@ -62,6 +64,7 @@ const initialData: Data = {
size: SizeStyle.Medium,
color: ColorStyle.Black,
dash: DashStyle.Solid,
fontSize: FontSize.Medium,
isFilled: false,
},
activeTool: 'select',
@ -69,6 +72,7 @@ const initialData: Data = {
boundsRotation: 0,
pointedId: null,
hoveredId: null,
editingId: null,
currentPageId: 'page1',
currentParentId: 'page1',
currentCodeFileId: 'file0',
@ -117,47 +121,16 @@ const state = createState({
else: ['zoomCameraToFit', 'zoomCameraToActual'],
},
on: {
ZOOMED_CAMERA: {
do: 'zoomCamera',
},
PANNED_CAMERA: {
do: 'panCamera',
},
ZOOMED_TO_ACTUAL: {
if: 'hasSelection',
do: 'zoomCameraToSelectionActual',
else: 'zoomCameraToActual',
},
ZOOMED_TO_SELECTION: {
if: 'hasSelection',
do: 'zoomCameraToSelection',
},
ZOOMED_TO_FIT: ['zoomCameraToFit', 'zoomCameraToActual'],
ZOOMED_IN: 'zoomIn',
ZOOMED_OUT: 'zoomOut',
RESET_CAMERA: 'resetCamera',
TOGGLED_SHAPE_LOCK: { if: 'hasSelection', do: 'lockSelection' },
TOGGLED_SHAPE_HIDE: { if: 'hasSelection', do: 'hideSelection' },
TOGGLED_SHAPE_ASPECT_LOCK: {
if: 'hasSelection',
do: 'aspectLockSelection',
},
SELECTED_SELECT_TOOL: { to: 'selecting' },
SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' },
SELECTED_ARROW_TOOL: { unless: 'isReadOnly', to: 'arrow' },
SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' },
SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' },
SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' },
SELECTED_RAY_TOOL: { unless: 'isReadOnly', to: 'ray' },
SELECTED_LINE_TOOL: { unless: 'isReadOnly', to: 'line' },
SELECTED_POLYLINE_TOOL: { unless: 'isReadOnly', to: 'polyline' },
SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
TOGGLED_CODE_PANEL_OPEN: 'toggleCodePanel',
TOGGLED_STYLE_PANEL_OPEN: 'toggleStylePanel',
POINTED_CANVAS: ['closeStylePanel', 'clearCurrentParentId'],
CHANGED_STYLE: ['updateStyles', 'applyStylesToSelection'],
SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
NUDGED: { do: 'nudgeSelection' },
USED_PEN_DEVICE: 'enablePenLock',
DISABLED_PEN_LOCK: 'disablePenLock',
CLEARED_PAGE: {
@ -169,6 +142,9 @@ const state = createState({
CREATED_PAGE: ['clearSelectedIds', 'createPage'],
DELETED_PAGE: { unless: 'hasOnlyOnePage', do: 'deletePage' },
LOADED_FROM_FILE: 'loadDocumentFromJson',
PANNED_CAMERA: {
do: 'panCamera',
},
},
initial: 'selecting',
states: {
@ -206,6 +182,34 @@ const state = createState({
if: ['hasSelection', 'selectionIncludesGroups'],
do: 'ungroupSelection',
},
SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
NUDGED: { do: 'nudgeSelection' },
SELECTED_SELECT_TOOL: { to: 'selecting' },
SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' },
SELECTED_ARROW_TOOL: { unless: 'isReadOnly', to: 'arrow' },
SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' },
SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' },
SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' },
SELECTED_RAY_TOOL: { unless: 'isReadOnly', to: 'ray' },
SELECTED_LINE_TOOL: { unless: 'isReadOnly', to: 'line' },
SELECTED_POLYLINE_TOOL: { unless: 'isReadOnly', to: 'polyline' },
SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
ZOOMED_CAMERA: {
do: 'zoomCamera',
},
ZOOMED_TO_ACTUAL: {
if: 'hasSelection',
do: 'zoomCameraToSelectionActual',
else: 'zoomCameraToActual',
},
ZOOMED_TO_SELECTION: {
if: 'hasSelection',
do: 'zoomCameraToSelection',
},
ZOOMED_TO_FIT: ['zoomCameraToFit', 'zoomCameraToActual'],
ZOOMED_IN: 'zoomIn',
ZOOMED_OUT: 'zoomOut',
RESET_CAMERA: 'resetCamera',
},
initial: 'notPointing',
states: {
@ -226,6 +230,16 @@ const state = createState({
to: 'rotatingSelection',
else: { to: 'transformingSelection' },
},
STARTED_EDITING_SHAPE: {
get: 'firstSelectedShape',
if: ['hasSingleSelection', 'canEditSelectedShape'],
do: 'setEditingId',
to: 'editingShape',
},
DOUBLE_POINTED_BOUNDS_HANDLE: {
if: 'hasSingleSelection',
do: 'resetShapeBounds',
},
POINTED_HANDLE: { to: 'translatingHandles' },
MOVED_OVER_SHAPE: {
if: 'pointHitsShape',
@ -240,6 +254,16 @@ const state = createState({
},
UNHOVERED_SHAPE: 'clearHoveredId',
DOUBLE_POINTED_SHAPE: [
'setPointedId',
{
if: 'isPointedShapeSelected',
then: {
get: 'firstSelectedShape',
if: 'canEditSelectedShape',
do: 'setEditingId',
to: 'editingShape',
},
},
{
unless: 'isPressingShiftKey',
do: [
@ -385,6 +409,15 @@ const state = createState({
},
},
},
editingShape: {
onEnter: 'startEditSession',
onExit: 'clearEditingId',
on: {
EDITED_SHAPE: { do: 'updateEditSession' },
BLURRED_SHAPE: { do: 'completeSession', to: 'selecting' },
CANCELLED: { do: 'cancelSession', to: 'selecting' },
},
},
pinching: {
on: {
// Pinching uses hacks.fastPinchCamera
@ -414,6 +447,35 @@ const state = createState({
to: 'pinching.toolPinching',
},
TOGGLED_TOOL_LOCK: 'toggleToolLock',
SELECTED_SELECT_TOOL: { to: 'selecting' },
SELECTED_DRAW_TOOL: { unless: 'isReadOnly', to: 'draw' },
SELECTED_ARROW_TOOL: { unless: 'isReadOnly', to: 'arrow' },
SELECTED_DOT_TOOL: { unless: 'isReadOnly', to: 'dot' },
SELECTED_CIRCLE_TOOL: { unless: 'isReadOnly', to: 'circle' },
SELECTED_ELLIPSE_TOOL: { unless: 'isReadOnly', to: 'ellipse' },
SELECTED_RAY_TOOL: { unless: 'isReadOnly', to: 'ray' },
SELECTED_LINE_TOOL: { unless: 'isReadOnly', to: 'line' },
SELECTED_POLYLINE_TOOL: { unless: 'isReadOnly', to: 'polyline' },
SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
ZOOMED_CAMERA: {
do: 'zoomCamera',
},
PANNED_CAMERA: {
do: 'panCamera',
},
ZOOMED_TO_ACTUAL: {
if: 'hasSelection',
do: 'zoomCameraToSelectionActual',
else: 'zoomCameraToActual',
},
ZOOMED_TO_SELECTION: {
if: 'hasSelection',
do: 'zoomCameraToSelection',
},
ZOOMED_TO_FIT: ['zoomCameraToFit', 'zoomCameraToActual'],
ZOOMED_IN: 'zoomIn',
ZOOMED_OUT: 'zoomOut',
RESET_CAMERA: 'resetCamera',
},
states: {
draw: {
@ -781,6 +843,9 @@ const state = createState({
newRectangle() {
return ShapeType.Rectangle
},
firstSelectedShape(data) {
return getSelectedShapes(data)[0]
},
},
conditions: {
isPointingCanvas(data, payload: PointerInfo) {
@ -799,6 +864,9 @@ const state = createState({
isReadOnly(data) {
return data.isReadOnly
},
canEditSelectedShape(data, payload, result: Shape) {
return getShapeUtils(result).canEdit
},
distanceImpliesDrag(data, payload: PointerInfo) {
return vec.dist2(payload.origin, payload.point) > 8
},
@ -842,6 +910,9 @@ const state = createState({
hasSelection(data) {
return getSelectedIds(data).size > 0
},
hasSingleSelection(data) {
return getSelectedIds(data).size === 1
},
hasMultipleSelection(data) {
return getSelectedIds(data).size > 1
},
@ -910,6 +981,14 @@ const state = createState({
session.clear()
},
// Editing
startEditSession(data) {
session.current = new Sessions.EditSession(data)
},
updateEditSession(data, payload: { change: Partial<Shape> }) {
session.current.update(data, payload.change)
},
// Brushing
startBrushSession(data, payload: PointerInfo) {
session.current = new Sessions.BrushSession(
@ -1197,6 +1276,23 @@ const state = createState({
ungroupSelection(data) {
commands.ungroup(data)
},
resetShapeBounds(data) {
commands.resetBounds(data)
},
/* --------------------- Editing -------------------- */
setEditingId(data) {
const selectedShape = getSelectedShapes(data)[0]
if (getShapeUtils(selectedShape).canEdit) {
data.editingId = selectedShape.id
}
getPageState(data).selectedIds = new Set([selectedShape.id])
},
clearEditingId(data) {
data.editingId = null
},
/* ---------------------- Tool ---------------------- */
@ -1478,6 +1574,10 @@ const state = createState({
/* ---------------------- Data ---------------------- */
restoreSavedData(data) {
storage.firstLoad(data)
},
saveToFileSystem(data) {
storage.saveToFileSystem(data)
},
@ -1511,10 +1611,6 @@ const state = createState({
storage.saveToLocalStorage(data)
},
restoreSavedData(data) {
storage.firstLoad(data)
},
clearBoundsRotation(data) {
data.boundsRotation = 0
},

View file

@ -1,6 +1,6 @@
import * as fa from 'browser-fs-access'
import { Data, Page, PageState, TLDocument } from 'types'
import { lzw_decode, lzw_encode, setToArray } from 'utils/utils'
import { decompress, compress, setToArray } from 'utils/utils'
import state from './state'
import { current } from 'immer'
import { v4 as uuid } from 'uuid'
@ -66,7 +66,7 @@ class Storage {
return false
}
const restoredData: any = JSON.parse(lzw_decode(savedData))
const restoredData: any = JSON.parse(decompress(savedData))
this.load(data, restoredData)
}
@ -80,7 +80,7 @@ class Storage {
)
if (savedPage !== null) {
const restored: Page = JSON.parse(lzw_decode(savedPage))
const restored: Page = JSON.parse(decompress(savedPage))
dataToSave.document.pages[pageId] = restored
}
@ -100,7 +100,7 @@ class Storage {
// Save current data to local storage
localStorage.setItem(
storageId(fileId, 'document', fileId),
lzw_encode(dataToSave)
compress(dataToSave)
)
}
@ -134,7 +134,7 @@ class Storage {
const page = data.document.pages[pageId]
const json = JSON.stringify(page)
localStorage.setItem(storageId(fileId, 'page', pageId), lzw_encode(json))
localStorage.setItem(storageId(fileId, 'page', pageId), compress(json))
// Save page state
@ -166,7 +166,7 @@ class Storage {
const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
if (savedPage !== null) {
data.document.pages[pageId] = JSON.parse(lzw_decode(savedPage))
data.document.pages[pageId] = JSON.parse(decompress(savedPage))
} else {
data.document.pages[pageId] = {
id: pageId,

View file

@ -1 +1,7 @@
@import url("https://fonts.googleapis.com/css2?family=Recursive:wght@500;700&display=swap");
@import url('https://fonts.googleapis.com/css2?family=Recursive:wght@500;700&display=swap');
@font-face {
font-family: 'Verveine Regular';
font-style: normal;
font-weight: normal;
src: local('Verveine Regular'), url('/VerveineRegular.woff') format('woff');
}

View file

@ -18,12 +18,13 @@ export interface Data {
isToolLocked: boolean
isPenLocked: boolean
}
currentStyle: ShapeStyles
currentStyle: ShapeStyles & TextStyles
activeTool: ShapeType | 'select'
brush?: Bounds
boundsRotation: number
pointedId?: string
hoveredId?: string
editingId?: string
currentPageId: string
currentParentId: string
currentCodeFileId: string
@ -100,6 +101,13 @@ export enum DashStyle {
Dotted = 'Dotted',
}
export enum FontSize {
Small = 'Small',
Medium = 'Medium',
Large = 'Large',
ExtraLarge = 'ExtraLarge',
}
export type ShapeStyles = {
color: ColorStyle
size: SizeStyle
@ -107,6 +115,10 @@ export type ShapeStyles = {
isFilled: boolean
}
export type TextStyles = {
fontSize: FontSize
}
export interface BaseShape {
id: string
seed: number
@ -182,6 +194,8 @@ export interface ArrowShape extends BaseShape {
export interface TextShape extends BaseShape {
type: ShapeType.Text
text: string
size: number[] | 'auto'
fontSize: FontSize
}
export interface GroupShape extends BaseShape {

View file

@ -1771,8 +1771,9 @@ export function getPoint(
]
}
export function lzw_encode(s: string) {
export function compress(s: string) {
return s
const dict = {}
const data = (s + '').split('')
@ -1805,7 +1806,7 @@ export function lzw_encode(s: string) {
}
// Decompress an LZW-encoded string
export function lzw_decode(s: string) {
export function decompress(s: string) {
return s
const dict = {}