Adds most of text feature, except creation
This commit is contained in:
parent
94fcca1685
commit
027815f199
40 changed files with 718 additions and 124 deletions
|
@ -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'))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
@ -26,6 +26,7 @@ export default function Page() {
|
|||
[window.innerWidth, window.innerHeight],
|
||||
s.data
|
||||
)
|
||||
|
||||
viewportCache.set(pageState, {
|
||||
minX,
|
||||
minY,
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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]
|
||||
)
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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>> {
|
||||
|
|
|
@ -70,7 +70,7 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
type: ShapeType.Arrow,
|
||||
isGenerated: false,
|
||||
name: 'Arrow',
|
||||
parentId: 'page0',
|
||||
parentId: 'page1',
|
||||
childIndex: 0,
|
||||
point,
|
||||
rotation: 0,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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]],
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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
192
lib/shape-utils/text.tsx
Normal 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 + ' '
|
||||
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
BIN
public/VerveineRegular.woff
Normal file
Binary file not shown.
|
@ -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
40
state/commands/edit.ts
Normal 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
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
|
|
31
state/commands/reset-bounds.ts
Normal file
31
state/commands/reset-bounds.ts
Normal 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]
|
||||
})
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
|
@ -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],
|
||||
|
|
|
@ -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() {
|
||||
|
|
51
state/sessions/edit-session.ts
Normal file
51
state/sessions/edit-session.ts
Normal 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>
|
|
@ -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,
|
||||
}
|
||||
|
|
166
state/state.ts
166
state/state.ts
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
16
types.ts
16
types.ts
|
@ -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 {
|
||||
|
|
|
@ -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 = {}
|
||||
|
|
Loading…
Reference in a new issue