adds commands, brush, history
This commit is contained in:
parent
f38481efee
commit
a5659380c4
14 changed files with 412 additions and 47 deletions
1
components/canvas/bounds-bg.tsx
Normal file
1
components/canvas/bounds-bg.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export default function BoundsBg() {}
|
1
components/canvas/bounds.tsx
Normal file
1
components/canvas/bounds.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export default function Bounds() {}
|
23
components/canvas/brush.tsx
Normal file
23
components/canvas/brush.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { useSelector } from "state"
|
||||
import styled from "styles"
|
||||
|
||||
export default function Brush() {
|
||||
const brush = useSelector(({ data }) => data.brush)
|
||||
|
||||
if (!brush) return null
|
||||
|
||||
return (
|
||||
<BrushRect
|
||||
x={brush.minX}
|
||||
y={brush.minY}
|
||||
width={brush.width}
|
||||
height={brush.height}
|
||||
className="brush"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const BrushRect = styled("rect", {
|
||||
fill: "$brushFill",
|
||||
stroke: "$brushStroke",
|
||||
})
|
|
@ -1,8 +1,11 @@
|
|||
import styled from "styles"
|
||||
import { useRef } from "react"
|
||||
import { getPointerEventInfo } from "utils/utils"
|
||||
import React, { useCallback, useRef } from "react"
|
||||
import useZoomEvents from "hooks/useZoomEvents"
|
||||
import useCamera from "hooks/useCamera"
|
||||
import Page from "./page"
|
||||
import Brush from "./brush"
|
||||
import state from "state"
|
||||
|
||||
export default function Canvas() {
|
||||
const rCanvas = useRef<SVGSVGElement>(null)
|
||||
|
@ -11,10 +14,31 @@ export default function Canvas() {
|
|||
|
||||
useCamera(rGroup)
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
rCanvas.current.setPointerCapture(e.pointerId)
|
||||
state.send("POINTED_CANVAS", getPointerEventInfo(e))
|
||||
}, [])
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
state.send("MOVED_POINTER", getPointerEventInfo(e))
|
||||
}, [])
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
rCanvas.current.releasePointerCapture(e.pointerId)
|
||||
state.send("STOPPED_POINTING", getPointerEventInfo(e))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<MainSVG ref={rCanvas} {...events}>
|
||||
<MainSVG
|
||||
ref={rCanvas}
|
||||
{...events}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
<MainGroup ref={rGroup}>
|
||||
<Page />
|
||||
<Brush />
|
||||
</MainGroup>
|
||||
</MainSVG>
|
||||
)
|
||||
|
|
94
state/commands/command.ts
Normal file
94
state/commands/command.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { Data } from "types"
|
||||
|
||||
/* ------------------ Command Class ----------------- */
|
||||
|
||||
export type CommandFn<T> = (data: T, initial?: boolean) => void
|
||||
|
||||
export enum CommandType {
|
||||
ChangeBounds,
|
||||
CreateGlob,
|
||||
CreateNode,
|
||||
Delete,
|
||||
Split,
|
||||
Move,
|
||||
MoveAnchor,
|
||||
ReorderGlobs,
|
||||
ReorderNodes,
|
||||
Paste,
|
||||
ToggleCap,
|
||||
ToggleLocked,
|
||||
SetProperty,
|
||||
SetItems,
|
||||
Transform,
|
||||
}
|
||||
|
||||
/**
|
||||
* A command makes changes to some applicate state. Every command has an "undo"
|
||||
* method to reverse its changes. The apps history is a series of commands.
|
||||
*/
|
||||
export class BaseCommand<T extends any> {
|
||||
timestamp = Date.now()
|
||||
private undoFn: CommandFn<T>
|
||||
private doFn: CommandFn<T>
|
||||
protected restoreBeforeSelectionState: (data: T) => void
|
||||
protected restoreAfterSelectionState: (data: T) => void
|
||||
protected saveSelectionState: (data: T) => (data: T) => void
|
||||
protected manualSelection: boolean
|
||||
|
||||
constructor(options: {
|
||||
type: CommandType
|
||||
do: CommandFn<T>
|
||||
undo: CommandFn<T>
|
||||
manualSelection?: boolean
|
||||
}) {
|
||||
this.doFn = options.do
|
||||
this.undoFn = options.undo
|
||||
this.manualSelection = options.manualSelection || false
|
||||
this.restoreBeforeSelectionState = () => () => {
|
||||
null
|
||||
}
|
||||
this.restoreAfterSelectionState = () => () => {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
undo = (data: T) => {
|
||||
if (this.manualSelection) {
|
||||
this.undoFn(data)
|
||||
return
|
||||
}
|
||||
|
||||
// We need to set the selection state to what it was before we after we did the command
|
||||
this.restoreAfterSelectionState(data)
|
||||
this.undoFn(data)
|
||||
this.restoreBeforeSelectionState(data)
|
||||
}
|
||||
|
||||
redo = (data: T, initial = false) => {
|
||||
if (initial) {
|
||||
this.restoreBeforeSelectionState = this.saveSelectionState(data)
|
||||
} else {
|
||||
this.restoreBeforeSelectionState(data)
|
||||
}
|
||||
|
||||
// We need to set the selection state to what it was before we did the command
|
||||
this.doFn(data, initial)
|
||||
|
||||
if (initial) {
|
||||
this.restoreAfterSelectionState = this.saveSelectionState(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Project Specific ---------------- */
|
||||
|
||||
/**
|
||||
* A subclass of BaseCommand that sends events to our state. In our case, we want our actions
|
||||
* to mutate the state's data. Actions do not effect the "active states" in
|
||||
* the app.
|
||||
*/
|
||||
export class Command extends BaseCommand<Data> {
|
||||
saveSelectionState = (data: Data) => {
|
||||
return (data: Data) => {}
|
||||
}
|
||||
}
|
63
state/commands/history.ts
Normal file
63
state/commands/history.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { Data } from "types"
|
||||
import { BaseCommand } from "./command"
|
||||
|
||||
class BaseHistory<T> {
|
||||
private stack: BaseCommand<T>[] = []
|
||||
private pointer = -1
|
||||
private maxLength = 100
|
||||
private _enabled = true
|
||||
|
||||
execute = (data: T, command: BaseCommand<T>) => {
|
||||
if (this.disabled) return
|
||||
this.stack = this.stack.slice(0, this.pointer + 1)
|
||||
this.stack.push(command)
|
||||
command.redo(data, true)
|
||||
this.pointer++
|
||||
|
||||
if (this.stack.length > this.maxLength) {
|
||||
this.stack = this.stack.slice(this.stack.length - this.maxLength)
|
||||
this.pointer = this.maxLength - 1
|
||||
}
|
||||
|
||||
this.save(data)
|
||||
}
|
||||
|
||||
undo = (data: T) => {
|
||||
if (this.disabled) return
|
||||
if (this.pointer === -1) return
|
||||
const command = this.stack[this.pointer]
|
||||
command.undo(data)
|
||||
this.pointer--
|
||||
this.save(data)
|
||||
}
|
||||
|
||||
redo = (data: T) => {
|
||||
if (this.disabled) return
|
||||
if (this.pointer === this.stack.length - 1) return
|
||||
const command = this.stack[this.pointer + 1]
|
||||
command.redo(data, false)
|
||||
this.pointer++
|
||||
this.save(data)
|
||||
}
|
||||
|
||||
save = (data: T) => {
|
||||
if (typeof window === "undefined") return
|
||||
if (typeof localStorage === "undefined") return
|
||||
|
||||
localStorage.setItem("glob_aldata_v6", JSON.stringify(data))
|
||||
}
|
||||
|
||||
disable = () => {
|
||||
this._enabled = false
|
||||
}
|
||||
|
||||
enable = () => {
|
||||
this._enabled = true
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
return !this._enabled
|
||||
}
|
||||
}
|
||||
|
||||
export default new BaseHistory<Data>()
|
44
state/data.ts
Normal file
44
state/data.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Data, ShapeType } from "types"
|
||||
|
||||
export const defaultDocument: Data["document"] = {
|
||||
pages: {
|
||||
page0: {
|
||||
id: "page0",
|
||||
type: "page",
|
||||
name: "Page 0",
|
||||
childIndex: 0,
|
||||
shapes: {
|
||||
shape0: {
|
||||
id: "shape0",
|
||||
type: ShapeType.Circle,
|
||||
name: "Shape 0",
|
||||
parentId: "page0",
|
||||
childIndex: 1,
|
||||
point: [100, 100],
|
||||
radius: 50,
|
||||
rotation: 0,
|
||||
},
|
||||
shape1: {
|
||||
id: "shape1",
|
||||
type: ShapeType.Rectangle,
|
||||
name: "Shape 1",
|
||||
parentId: "page0",
|
||||
childIndex: 1,
|
||||
point: [300, 300],
|
||||
size: [200, 200],
|
||||
rotation: 0,
|
||||
},
|
||||
shape2: {
|
||||
id: "shape2",
|
||||
type: ShapeType.Circle,
|
||||
name: "Shape 2",
|
||||
parentId: "page0",
|
||||
childIndex: 2,
|
||||
point: [200, 800],
|
||||
radius: 25,
|
||||
rotation: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
17
state/sessions/base-session.ts
Normal file
17
state/sessions/base-session.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Data } from "types"
|
||||
|
||||
export default class BaseSession {
|
||||
constructor(data: Data) {}
|
||||
|
||||
update = (data: Data, ...args: unknown[]) => {
|
||||
// Update the state
|
||||
}
|
||||
|
||||
complete = (data: Data, ...args: unknown[]) => {
|
||||
// Create a command
|
||||
}
|
||||
|
||||
cancel = (data: Data) => {
|
||||
// Clean up the change
|
||||
}
|
||||
}
|
64
state/sessions/brush-session.ts
Normal file
64
state/sessions/brush-session.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { current } from "immer"
|
||||
import { Bounds, Data, Shape } from "types"
|
||||
import BaseSession from "./base-session"
|
||||
import { screenToWorld, getBoundsFromPoints } from "utils/utils"
|
||||
import * as vec from "utils/vec"
|
||||
|
||||
interface BrushSnapshot {
|
||||
selectedIds: string[]
|
||||
shapes: Shape[]
|
||||
}
|
||||
|
||||
export default class BrushSession extends BaseSession {
|
||||
origin: number[]
|
||||
snapshot: BrushSnapshot
|
||||
|
||||
constructor(data: Data, point: number[]) {
|
||||
super(data)
|
||||
|
||||
this.origin = vec.round(point)
|
||||
|
||||
this.snapshot = BrushSession.getSnapshot(data)
|
||||
}
|
||||
|
||||
update = (data: Data, point: number[]) => {
|
||||
const { origin, snapshot } = this
|
||||
|
||||
const bounds = getBoundsFromPoints(origin, point)
|
||||
|
||||
data.brush = bounds
|
||||
|
||||
const { minX: x, minY: y, width: w, height: h } = bounds
|
||||
|
||||
data.selectedIds = [
|
||||
...snapshot.selectedIds,
|
||||
...snapshot.shapes.map((shape) => {
|
||||
return shape.id
|
||||
}),
|
||||
]
|
||||
|
||||
// Narrow the the items on the screen
|
||||
data.brush = bounds
|
||||
}
|
||||
|
||||
cancel = (data: Data) => {
|
||||
data.brush = undefined
|
||||
data.selectedIds = this.snapshot.selectedIds
|
||||
}
|
||||
|
||||
complete = (data: Data) => {
|
||||
data.brush = undefined
|
||||
}
|
||||
|
||||
static getSnapshot(data: Data) {
|
||||
const {
|
||||
document: { pages },
|
||||
currentPageId,
|
||||
} = current(data)
|
||||
|
||||
return {
|
||||
selectedIds: [...data.selectedIds],
|
||||
shapes: Object.values(pages[currentPageId].shapes),
|
||||
}
|
||||
}
|
||||
}
|
4
state/sessions/index.ts
Normal file
4
state/sessions/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import BaseSession from "./brush-session"
|
||||
import BrushSession from "./brush-session"
|
||||
|
||||
export { BrushSession, BaseSession }
|
|
@ -1,56 +1,20 @@
|
|||
import { createSelectorHook, createState } from "@state-designer/react"
|
||||
import { clamp, screenToWorld } from "utils/utils"
|
||||
import * as vec from "utils/vec"
|
||||
import { Data, ShapeType } from "types"
|
||||
import { Data } from "types"
|
||||
import { defaultDocument } from "./data"
|
||||
import * as Sessions from "./sessions"
|
||||
|
||||
const initialData: Data = {
|
||||
camera: {
|
||||
point: [0, 0],
|
||||
zoom: 1,
|
||||
},
|
||||
brush: undefined,
|
||||
pointedId: null,
|
||||
selectedIds: [],
|
||||
currentPageId: "page0",
|
||||
document: {
|
||||
pages: {
|
||||
page0: {
|
||||
id: "page0",
|
||||
type: "page",
|
||||
name: "Page 0",
|
||||
childIndex: 0,
|
||||
shapes: {
|
||||
shape0: {
|
||||
id: "shape0",
|
||||
type: ShapeType.Circle,
|
||||
name: "Shape 0",
|
||||
parentId: "page0",
|
||||
childIndex: 1,
|
||||
point: [100, 100],
|
||||
radius: 50,
|
||||
rotation: 0,
|
||||
},
|
||||
shape1: {
|
||||
id: "shape1",
|
||||
type: ShapeType.Rectangle,
|
||||
name: "Shape 1",
|
||||
parentId: "page0",
|
||||
childIndex: 1,
|
||||
point: [300, 300],
|
||||
size: [200, 200],
|
||||
rotation: 0,
|
||||
},
|
||||
shape2: {
|
||||
id: "shape2",
|
||||
type: ShapeType.Circle,
|
||||
name: "Shape 2",
|
||||
parentId: "page0",
|
||||
childIndex: 2,
|
||||
point: [200, 800],
|
||||
radius: 25,
|
||||
rotation: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
document: defaultDocument,
|
||||
}
|
||||
|
||||
const state = createState({
|
||||
|
@ -63,7 +27,37 @@ const state = createState({
|
|||
do: "panCamera",
|
||||
},
|
||||
},
|
||||
initial: "selecting",
|
||||
states: {
|
||||
selecting: {
|
||||
on: {
|
||||
POINTED_CANVAS: { to: "brushSelecting" },
|
||||
},
|
||||
},
|
||||
brushSelecting: {
|
||||
onEnter: "startBrushSession",
|
||||
on: {
|
||||
MOVED_POINTER: "updateBrushSession",
|
||||
STOPPED_POINTING: { do: "completeSession", to: "selecting" },
|
||||
CANCELLED: { do: "cancelSession", to: "selecting" },
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
cancelSession(data) {
|
||||
session.cancel(data)
|
||||
session = undefined
|
||||
},
|
||||
completeSession(data) {
|
||||
session.complete(data)
|
||||
session = undefined
|
||||
},
|
||||
startBrushSession(data, { point }) {
|
||||
session = new Sessions.BrushSession(data, point)
|
||||
},
|
||||
updateBrushSession(data, { point }) {
|
||||
session.update(data, point)
|
||||
},
|
||||
zoomCamera(data, payload: { delta: number; point: number[] }) {
|
||||
const { camera } = data
|
||||
const p0 = screenToWorld(payload.point, data)
|
||||
|
@ -85,6 +79,8 @@ const state = createState({
|
|||
},
|
||||
})
|
||||
|
||||
let session: Sessions.BaseSession
|
||||
|
||||
export default state
|
||||
|
||||
export const useSelector = createSelectorHook(state)
|
||||
|
|
|
@ -5,7 +5,10 @@ const { styled, global, css, theme, getCssString } = createCss({
|
|||
...defaultThemeMap,
|
||||
},
|
||||
theme: {
|
||||
colors: {},
|
||||
colors: {
|
||||
brushFill: "rgba(0,0,0,.1)",
|
||||
brushStroke: "rgba(0,0,0,.5)",
|
||||
},
|
||||
space: {},
|
||||
fontSizes: {
|
||||
0: "10px",
|
||||
|
|
12
types.ts
12
types.ts
|
@ -3,9 +3,10 @@ export interface Data {
|
|||
point: number[]
|
||||
zoom: number
|
||||
}
|
||||
brush?: Bounds
|
||||
currentPageId: string
|
||||
selectedIds: string[]
|
||||
pointedId: string
|
||||
pointedId?: string
|
||||
document: {
|
||||
pages: Record<string, Page>
|
||||
}
|
||||
|
@ -93,3 +94,12 @@ export type Shape =
|
|||
| RayShape
|
||||
| LineSegmentShape
|
||||
| RectangleShape
|
||||
|
||||
export interface Bounds {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
|
|
@ -6,6 +6,22 @@ export function screenToWorld(point: number[], data: Data) {
|
|||
return vec.add(vec.div(point, data.camera.zoom), data.camera.point)
|
||||
}
|
||||
|
||||
export function getBoundsFromPoints(a: number[], b: number[]) {
|
||||
const minX = Math.min(a[0], b[0])
|
||||
const maxX = Math.max(a[0], b[0])
|
||||
const minY = Math.min(a[1], b[1])
|
||||
const maxY = Math.max(a[1], b[1])
|
||||
|
||||
return {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
}
|
||||
}
|
||||
|
||||
// A helper for getting tangents.
|
||||
export function getCircleTangentToPoint(
|
||||
A: number[],
|
||||
|
@ -827,3 +843,8 @@ export async function postJsonToEndpoint(
|
|||
|
||||
return await d.json()
|
||||
}
|
||||
|
||||
export function getPointerEventInfo(e: React.PointerEvent) {
|
||||
const { shiftKey, ctrlKey, metaKey, altKey } = e
|
||||
return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey }
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue