diff --git a/components/canvas/bounds-bg.tsx b/components/canvas/bounds-bg.tsx index dbfba41a7..08eb8e9c2 100644 --- a/components/canvas/bounds-bg.tsx +++ b/components/canvas/bounds-bg.tsx @@ -1,7 +1,10 @@ +import { useRef } from "react" import state, { useSelector } from "state" +import inputs from "state/inputs" import styled from "styles" export default function BoundsBg() { + const rBounds = useRef(null) const bounds = useSelector((state) => state.values.selectedBounds) if (!bounds) return null @@ -10,19 +13,15 @@ export default function BoundsBg() { return ( { if (e.buttons !== 1) return - state.send("POINTED_BOUNDS", { - shiftKey: e.shiftKey, - optionKey: e.altKey, - metaKey: e.metaKey || e.ctrlKey, - ctrlKey: e.ctrlKey, - buttons: e.buttons, - }) + rBounds.current.setPointerCapture(e.pointerId) + state.send("POINTED_BOUNDS", inputs.pointerDown(e)) }} /> ) diff --git a/components/canvas/bounds.tsx b/components/canvas/bounds.tsx index d38775b39..9dc421018 100644 --- a/components/canvas/bounds.tsx +++ b/components/canvas/bounds.tsx @@ -1,6 +1,7 @@ import state, { useSelector } from "state" import { motion } from "framer-motion" import styled from "styles" +import inputs from "state/inputs" export default function Bounds() { const bounds = useSelector((state) => state.values.selectedBounds) @@ -23,38 +24,42 @@ export default function Bounds() { height={height} pointerEvents="none" /> - - - - + {width * zoom > 8 && ( + <> + + + + + + )} ) @@ -268,9 +248,9 @@ function EdgeVertical({ ) } -function restoreCursor() { +function restoreCursor(e: PointerEvent) { + state.send("STOPPED_POINTING", { id: "bounds", ...inputs.pointerUp(e) }) document.body.style.cursor = "default" - state.send("STOPPED_POINTING") } const StyledEdge = styled(motion.rect, { diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index df8c4164f..a5e025b0f 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -8,6 +8,7 @@ import Brush from "./brush" import state from "state" import Bounds from "./bounds" import BoundsBg from "./bounds-bg" +import inputs from "state/inputs" export default function Canvas() { const rCanvas = useRef(null) @@ -18,16 +19,16 @@ export default function Canvas() { const handlePointerDown = useCallback((e: React.PointerEvent) => { rCanvas.current.setPointerCapture(e.pointerId) - state.send("POINTED_CANVAS", getPointerEventInfo(e)) + state.send("POINTED_CANVAS", inputs.pointerDown(e)) }, []) const handlePointerMove = useCallback((e: React.PointerEvent) => { - state.send("MOVED_POINTER", getPointerEventInfo(e)) + state.send("MOVED_POINTER", inputs.pointerMove(e)) }, []) const handlePointerUp = useCallback((e: React.PointerEvent) => { rCanvas.current.releasePointerCapture(e.pointerId) - state.send("STOPPED_POINTING", getPointerEventInfo(e)) + state.send("STOPPED_POINTING", { id: "canvas", ...inputs.pointerUp(e) }) }, []) return ( diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index e6a845d08..d7be0d24d 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useRef, memo } from "react" import state, { useSelector } from "state" import { getPointerEventInfo } from "utils/utils" +import inputs from "state/inputs" import shapes from "lib/shapes" import styled from "styles" @@ -18,7 +19,7 @@ function Shape({ id }: { id: string }) { (e: React.PointerEvent) => { e.stopPropagation() rGroup.current.setPointerCapture(e.pointerId) - state.send("POINTED_SHAPE", { id, ...getPointerEventInfo(e) }) + state.send("POINTED_SHAPE", { id, ...inputs.pointerDown(e) }) }, [id] ) @@ -27,20 +28,18 @@ function Shape({ id }: { id: string }) { (e: React.PointerEvent) => { e.stopPropagation() rGroup.current.releasePointerCapture(e.pointerId) - state.send("STOPPED_POINTING_SHAPE", { id, ...getPointerEventInfo(e) }) + state.send("STOPPED_POINTING", { id, ...inputs.pointerUp(e) }) }, [id] ) const handlePointerEnter = useCallback( - (e: React.PointerEvent) => - state.send("HOVERED_SHAPE", { id, ...getPointerEventInfo(e) }), + (e: React.PointerEvent) => state.send("HOVERED_SHAPE", { id }), [id] ) const handlePointerLeave = useCallback( - (e: React.PointerEvent) => - state.send("UNHOVERED_SHAPE", { id, ...getPointerEventInfo(e) }), + (e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }), [id] ) diff --git a/lib/shapes/circle.tsx b/lib/shapes/circle.tsx index dcf528cdf..1265bc09d 100644 --- a/lib/shapes/circle.tsx +++ b/lib/shapes/circle.tsx @@ -56,7 +56,8 @@ const Circle: BaseLibShape = { return shape }, - translate(shape) { + translate(shape, delta) { + shape.point = vec.add(shape.point, delta) return shape }, diff --git a/lib/shapes/dot.tsx b/lib/shapes/dot.tsx index 75fc42ce7..3e17fc562 100644 --- a/lib/shapes/dot.tsx +++ b/lib/shapes/dot.tsx @@ -52,7 +52,8 @@ const Dot: BaseLibShape = { return shape }, - translate(shape) { + translate(shape, delta) { + shape.point = vec.add(shape.point, delta) return shape }, diff --git a/lib/shapes/polyline.tsx b/lib/shapes/polyline.tsx index 9cf2334d2..cacbddf6e 100644 --- a/lib/shapes/polyline.tsx +++ b/lib/shapes/polyline.tsx @@ -61,7 +61,8 @@ const Polyline: BaseLibShape = { return shape }, - translate(shape) { + translate(shape, delta) { + shape.point = vec.add(shape.point, delta) return shape }, diff --git a/lib/shapes/rectangle.tsx b/lib/shapes/rectangle.tsx index dc5216787..6c9c16bd4 100644 --- a/lib/shapes/rectangle.tsx +++ b/lib/shapes/rectangle.tsx @@ -54,7 +54,8 @@ const Rectangle: BaseLibShape = { return shape }, - translate(shape) { + translate(shape, delta) { + shape.point = vec.add(shape.point, delta) return shape }, diff --git a/state/commands/command.ts b/state/commands/command.ts index c36370ed9..df1dba8b2 100644 --- a/state/commands/command.ts +++ b/state/commands/command.ts @@ -4,30 +4,14 @@ import { Data } from "types" export type CommandFn = (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 { timestamp = Date.now() + name: string + category: string private undoFn: CommandFn private doFn: CommandFn protected restoreBeforeSelectionState: (data: T) => void @@ -36,11 +20,14 @@ export class BaseCommand { protected manualSelection: boolean constructor(options: { - type: CommandType do: CommandFn undo: CommandFn + name: string + category: string manualSelection?: boolean }) { + this.name = options.name + this.category = options.category this.doFn = options.do this.undoFn = options.undo this.manualSelection = options.manualSelection || false @@ -87,8 +74,11 @@ export class BaseCommand { * to mutate the state's data. Actions do not effect the "active states" in * the app. */ -export class Command extends BaseCommand { +export default class Command extends BaseCommand { saveSelectionState = (data: Data) => { - return (data: Data) => {} + const selectedIds = new Set(data.selectedIds) + return (data: Data) => { + data.selectedIds = selectedIds + } } } diff --git a/state/commands/history.ts b/state/commands/history.ts index fec6b1630..fa4d5d7b2 100644 --- a/state/commands/history.ts +++ b/state/commands/history.ts @@ -1,7 +1,9 @@ import { Data } from "types" import { BaseCommand } from "./command" -class BaseHistory { +// A singleton to manage history changes. + +class History { private stack: BaseCommand[] = [] private pointer = -1 private maxLength = 100 @@ -44,7 +46,7 @@ class BaseHistory { if (typeof window === "undefined") return if (typeof localStorage === "undefined") return - localStorage.setItem("glob_aldata_v6", JSON.stringify(data)) + localStorage.setItem("code_slate_0.0.1", JSON.stringify(data)) } disable = () => { @@ -60,4 +62,4 @@ class BaseHistory { } } -export default new BaseHistory() +export default new History() diff --git a/state/commands/index.ts b/state/commands/index.ts new file mode 100644 index 000000000..4fc69107a --- /dev/null +++ b/state/commands/index.ts @@ -0,0 +1,5 @@ +import translate from "./translate-command" + +const commands = { translate } + +export default commands diff --git a/state/commands/translate-command.ts b/state/commands/translate-command.ts new file mode 100644 index 000000000..8a5e55bd9 --- /dev/null +++ b/state/commands/translate-command.ts @@ -0,0 +1,32 @@ +import Command from "./command" +import history from "./history" +import { TranslateSnapshot } from "state/sessions/translate-session" +import { Data } from "types" + +export default function translateCommand( + data: Data, + before: TranslateSnapshot, + after: TranslateSnapshot +) { + history.execute( + data, + new Command({ + name: "translate_shapes", + category: "canvas", + do(data) { + const { shapes } = data.document.pages[after.currentPageId] + + for (let { id, point } of after.shapes) { + shapes[id].point = point + } + }, + undo(data) { + const { shapes } = data.document.pages[before.currentPageId] + + for (let { id, point } of before.shapes) { + shapes[id].point = point + } + }, + }) + ) +} diff --git a/state/inputs.tsx b/state/inputs.tsx new file mode 100644 index 000000000..dfdd83799 --- /dev/null +++ b/state/inputs.tsx @@ -0,0 +1,52 @@ +import { PointerInfo } from "types" + +class Inputs { + points: Record = {} + + pointerDown(e: PointerEvent | React.PointerEvent) { + const { shiftKey, ctrlKey, metaKey, altKey } = e + + this.points[e.pointerId] = { + pointerId: e.pointerId, + origin: [e.clientX, e.clientY], + point: [e.clientX, e.clientY], + shiftKey, + ctrlKey, + metaKey, + altKey, + } + + return this.points[e.pointerId] + } + + pointerMove(e: PointerEvent | React.PointerEvent) { + if (this.points[e.pointerId]) { + this.points[e.pointerId].point = [e.clientX, e.clientY] + return this.points[e.pointerId] + } + + const { shiftKey, ctrlKey, metaKey, altKey } = e + + return { + pointerId: e.pointerId, + origin: [e.clientX, e.clientY], + point: [e.clientX, e.clientY], + shiftKey, + ctrlKey, + metaKey, + altKey, + } + } + + pointerUp(e: PointerEvent | React.PointerEvent) { + this.points[e.pointerId].point = [e.clientX, e.clientY] + + const info = this.points[e.pointerId] + + delete this.points[e.pointerId] + + return info + } +} + +export default new Inputs() diff --git a/state/sessions/base-session.ts b/state/sessions/base-session.ts index b05b39fa3..582d69703 100644 --- a/state/sessions/base-session.ts +++ b/state/sessions/base-session.ts @@ -3,15 +3,15 @@ import { Data } from "types" export default class BaseSession { constructor(data: Data) {} - update = (data: Data, ...args: unknown[]) => { + update(data: Data, ...args: unknown[]) { // Update the state } - complete = (data: Data, ...args: unknown[]) => { + complete(data: Data, ...args: unknown[]) { // Create a command } - cancel = (data: Data) => { + cancel(data: Data) { // Clean up the change } } diff --git a/state/sessions/index.ts b/state/sessions/index.ts index 6ca47397d..f285bb2f8 100644 --- a/state/sessions/index.ts +++ b/state/sessions/index.ts @@ -1,4 +1,5 @@ -import BaseSession from "./brush-session" +import BaseSession from "./base-session" import BrushSession from "./brush-session" +import TranslateSession from "./translate-session" -export { BrushSession, BaseSession } +export { BrushSession, BaseSession, TranslateSession } diff --git a/state/sessions/translate-session.ts b/state/sessions/translate-session.ts new file mode 100644 index 000000000..61c57c03b --- /dev/null +++ b/state/sessions/translate-session.ts @@ -0,0 +1,62 @@ +import { Data } from "types" +import * as vec from "utils/vec" +import BaseSession from "./base-session" +import commands from "state/commands" +import { current } from "immer" + +export default class TranslateSession extends BaseSession { + delta = [0, 0] + origin: number[] + snapshot: TranslateSnapshot + + constructor(data: Data, point: number[]) { + super(data) + this.origin = point + this.snapshot = getTranslateSnapshot(data) + } + + update(data: Data, point: number[]) { + const { currentPageId, shapes } = this.snapshot + const { document } = data + + const delta = vec.vec(this.origin, point) + + for (let shape of shapes) { + document.pages[currentPageId].shapes[shape.id].point = vec.add( + shape.point, + delta + ) + } + } + + cancel(data: Data) { + const { document } = data + + for (let shape of this.snapshot.shapes) { + document.pages[this.snapshot.currentPageId].shapes[shape.id].point = + shape.point + } + } + + complete(data: Data) { + commands.translate(data, this.snapshot, getTranslateSnapshot(data)) + } +} + +export function getTranslateSnapshot(data: Data) { + const { + document: { pages }, + currentPageId, + } = current(data) + + const { shapes } = pages[currentPageId] + + return { + currentPageId, + shapes: Array.from(data.selectedIds.values()) + .map((id) => shapes[id]) + .map(({ id, point }) => ({ id, point })), + } +} + +export type TranslateSnapshot = ReturnType diff --git a/state/state.ts b/state/state.ts index f3e468577..368a1b9b5 100644 --- a/state/state.ts +++ b/state/state.ts @@ -1,12 +1,13 @@ import { createSelectorHook, createState } from "@state-designer/react" import { clamp, getCommonBounds, screenToWorld } from "utils/utils" import * as vec from "utils/vec" -import { Bounds, Data, Shape, ShapeType } from "types" +import { Bounds, Data, PointerInfo, Shape, ShapeType } from "types" import { defaultDocument } from "./data" import Shapes from "lib/shapes" import * as Sessions from "./sessions" const initialData: Data = { + isReadOnly: false, camera: { point: [0, 0], zoom: 1, @@ -31,36 +32,84 @@ const state = createState({ initial: "selecting", states: { selecting: { - on: { - POINTED_CANVAS: { to: "brushSelecting" }, - POINTED_SHAPE: [ - "setPointedId", - { - if: "isPressingShiftKey", - then: { - if: "isPointedShapeSelected", - do: "pullPointedIdFromSelectedIds", - else: "pushPointedIdToSelectedIds", - }, - else: ["clearSelectedIds", "pushPointedIdToSelectedIds"], + initial: "notPointing", + states: { + notPointing: { + on: { + POINTED_CANVAS: { to: "brushSelecting" }, + POINTED_BOUNDS: { to: "pointingBounds" }, + POINTED_SHAPE: [ + "setPointedId", + { + if: "isPressingShiftKey", + then: { + if: "isPointedShapeSelected", + do: "pullPointedIdFromSelectedIds", + else: { + do: "pushPointedIdToSelectedIds", + to: "pointingBounds", + }, + }, + else: [ + { + unless: "isPointedShapeSelected", + do: ["clearSelectedIds", "pushPointedIdToSelectedIds"], + }, + { + to: "pointingBounds", + }, + ], + }, + ], }, - ], - }, - }, - brushSelecting: { - onEnter: [ - { unless: "isPressingShiftKey", do: "clearSelectedIds" }, - "startBrushSession", - ], - on: { - MOVED_POINTER: "updateBrushSession", - PANNED_CAMERA: "updateBrushSession", - STOPPED_POINTING: { do: "completeSession", to: "selecting" }, - CANCELLED: { do: "cancelSession", to: "selecting" }, + }, + pointingBounds: { + on: { + STOPPED_POINTING: [ + { + unless: "isPressingShiftKey", + do: ["clearSelectedIds", "pushPointedIdToSelectedIds"], + }, + { to: "notPointing" }, + ], + MOVED_POINTER: { + unless: "isReadOnly", + if: "distanceImpliesDrag", + to: "draggingSelection", + }, + }, + }, + draggingSelection: { + onEnter: "startTranslateSession", + on: { + MOVED_POINTER: "updateTranslateSession", + PANNED_CAMERA: "updateTranslateSession", + STOPPED_POINTING: { do: "completeSession", to: "selecting" }, + CANCELLED: { do: "cancelSession", to: "selecting" }, + }, + }, + brushSelecting: { + onEnter: [ + { unless: "isPressingShiftKey", do: "clearSelectedIds" }, + "startBrushSession", + ], + on: { + MOVED_POINTER: "updateBrushSession", + PANNED_CAMERA: "updateBrushSession", + STOPPED_POINTING: { do: "completeSession", to: "selecting" }, + CANCELLED: { do: "cancelSession", to: "selecting" }, + }, + }, }, }, }, conditions: { + isReadOnly(data) { + return data.isReadOnly + }, + distanceImpliesDrag(data, payload: PointerInfo) { + return vec.dist2(payload.origin, payload.point) > 16 + }, isPointedShapeSelected(data) { return data.selectedIds.has(data.pointedId) }, @@ -77,6 +126,7 @@ const state = createState({ session.complete(data) session = undefined }, + // Brushing startBrushSession(data, payload: { point: number[] }) { session = new Sessions.BrushSession( data, @@ -86,6 +136,17 @@ const state = createState({ updateBrushSession(data, payload: { point: number[] }) { session.update(data, screenToWorld(payload.point, data)) }, + // Dragging / Translating + startTranslateSession(data, payload: { point: number[] }) { + session = new Sessions.TranslateSession( + data, + screenToWorld(payload.point, data) + ) + }, + updateTranslateSession(data, payload: { point: number[] }) { + session.update(data, screenToWorld(payload.point, data)) + }, + // Selection setPointedId(data, payload: { id: string }) { data.pointedId = payload.id diff --git a/types.ts b/types.ts index 4c8f940e6..0030f0b8c 100644 --- a/types.ts +++ b/types.ts @@ -1,6 +1,7 @@ import React from "react" export interface Data { + isReadOnly: boolean camera: { point: number[] zoom: number @@ -124,8 +125,18 @@ export type BaseLibShape = { getBounds(shape: Shapes[K]): Bounds hitTest(shape: Shapes[K], test: number[]): boolean rotate(shape: Shapes[K]): Shapes[K] - translate(shape: Shapes[K]): Shapes[K] + translate(shape: Shapes[K], delta: number[]): Shapes[K] scale(shape: Shapes[K], scale: number): Shapes[K] stretch(shape: Shapes[K], scaleX: number, scaleY: number): Shapes[K] render(shape: Shapes[K]): JSX.Element } + +export interface PointerInfo { + pointerId: number + origin: number[] + point: number[] + shiftKey: boolean + ctrlKey: boolean + metaKey: boolean + altKey: boolean +}