diff --git a/components/canvas/bounds.tsx b/components/canvas/bounds.tsx index fe64907e3..6032ef276 100644 --- a/components/canvas/bounds.tsx +++ b/components/canvas/bounds.tsx @@ -27,7 +27,7 @@ export default function Bounds() { height={height} pointerEvents="none" /> - {width * zoom > 8 && ( + {width * zoom > 8 && height * zoom > 8 && ( <> (null) - const shape = useSelector( - ({ data: { currentPageId, document } }) => - document.pages[currentPageId].shapes[id] - ) - const isSelected = useSelector((state) => state.values.selectedIds.has(id)) + const shape = useSelector( + ({ data }) => data.document.pages[data.currentPageId].shapes[id] + ) + const handlePointerDown = useCallback( (e: React.PointerEvent) => { e.stopPropagation() @@ -33,12 +32,12 @@ function Shape({ id }: { id: string }) { ) const handlePointerEnter = useCallback( - (e: React.PointerEvent) => state.send("HOVERED_SHAPE", { id }), + () => state.send("HOVERED_SHAPE", { id }), [id] ) const handlePointerLeave = useCallback( - (e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }), + () => state.send("UNHOVERED_SHAPE", { id }), [id] ) return ( diff --git a/lib/shapes/base-shape.tsx b/lib/shapes/base-shape.tsx new file mode 100644 index 000000000..904b3735d --- /dev/null +++ b/lib/shapes/base-shape.tsx @@ -0,0 +1,19 @@ +import { Bounds, Shape } from "types" + +export default interface ShapeUtil { + create(props: Partial): K + getBounds(this: ShapeUtil, shape: K): Bounds + hitTest(this: ShapeUtil, shape: K, test: number[]): boolean + hitTestBounds(this: ShapeUtil, shape: K, bounds: Bounds): boolean + rotate(this: ShapeUtil, shape: K): K + translate(this: ShapeUtil, shape: K, delta: number[]): K + scale(this: ShapeUtil, shape: K, scale: number): K + stretch(this: ShapeUtil, shape: K, scaleX: number, scaleY: number): K + render(this: ShapeUtil, shape: K): JSX.Element +} + +export function createShape( + shape: ShapeUtil +): ShapeUtil { + return shape +} diff --git a/lib/shapes/circle.tsx b/lib/shapes/circle.tsx index b2adc6f17..1956409a3 100644 --- a/lib/shapes/circle.tsx +++ b/lib/shapes/circle.tsx @@ -88,8 +88,12 @@ const circle = createShape({ }, transform(shape, bounds) { - shape.point = [bounds.minX, bounds.minY] + // shape.point = [bounds.minX, bounds.minY] shape.radius = Math.min(bounds.width, bounds.height) / 2 + shape.point = [ + bounds.minX + bounds.width / 2 - shape.radius, + bounds.minY + bounds.height / 2 - shape.radius, + ] return shape }, diff --git a/lib/shapes/index.tsx b/lib/shapes/index.tsx index 4d61394d9..9ca162f06 100644 --- a/lib/shapes/index.tsx +++ b/lib/shapes/index.tsx @@ -41,7 +41,14 @@ export interface ShapeUtility { translate(this: ShapeUtility, shape: K, delta: number[]): K // Transform to fit a new bounding box. - transform(this: ShapeUtility, shape: K, bounds: Bounds): K + transform( + this: ShapeUtility, + shape: K, + bounds: Bounds & { isFlippedX: boolean; isFlippedY: boolean }, + initialShape: K, + initialShapeBounds: BoundsSnapshot, + initialBounds: Bounds + ): K // Apply a scale to a shape. scale(this: ShapeUtility, shape: K, scale: number): K diff --git a/lib/shapes/line.tsx b/lib/shapes/line.tsx index 54c2db6b5..e3ebcea4f 100644 --- a/lib/shapes/line.tsx +++ b/lib/shapes/line.tsx @@ -16,15 +16,23 @@ const line = createShape({ parentId: "page0", childIndex: 0, point: [0, 0], - vector: [0, 0], + direction: [0, 0], rotation: 0, style: {}, ...props, } }, - render({ id }) { - return + render({ id, direction }) { + const [x1, y1] = vec.add([0, 0], vec.mul(direction, 100000)) + const [x2, y2] = vec.sub([0, 0], vec.mul(direction, 100000)) + + return ( + + + + + ) }, getBounds(shape) { @@ -38,11 +46,11 @@ const line = createShape({ const bounds = { minX: x, - maxX: x + 8, + maxX: x + 1, minY: y, - maxY: y + 8, - width: 8, - height: 8, + maxY: y + 1, + width: 1, + height: 1, } this.boundsCache.set(shape, bounds) @@ -55,11 +63,7 @@ const line = createShape({ }, hitTestBounds(this, shape, brushBounds) { - const shapeBounds = this.getBounds(shape) - return ( - boundsContained(shapeBounds, brushBounds) || - intersectCircleBounds(shape.point, 4, brushBounds).length > 0 - ) + return true }, rotate(shape) { @@ -80,6 +84,8 @@ const line = createShape({ }, transform(shape, bounds) { + shape.point = [bounds.minX, bounds.minY] + return shape }, }) diff --git a/lib/shapes/polyline.tsx b/lib/shapes/polyline.tsx index d00ea8e2b..efb5cbab9 100644 --- a/lib/shapes/polyline.tsx +++ b/lib/shapes/polyline.tsx @@ -90,15 +90,20 @@ const polyline = createShape({ return shape }, - transform(shape, bounds) { - const currentBounds = this.getBounds(shape) + transform(shape, bounds, initialShape, initialShapeBounds) { + shape.points = shape.points.map((_, i) => { + const [x, y] = initialShape.points[i] - const scaleX = bounds.width / currentBounds.width - const scaleY = bounds.height / currentBounds.height - - shape.points = shape.points.map((point) => { - let pt = vec.mulV(point, [scaleX, scaleY]) - return pt + return [ + bounds.width * + (bounds.isFlippedX + ? 1 - x / initialShapeBounds.width + : x / initialShapeBounds.width), + bounds.height * + (bounds.isFlippedY + ? 1 - y / initialShapeBounds.height + : y / initialShapeBounds.height), + ] }) shape.point = [bounds.minX, bounds.minY] diff --git a/lib/shapes/ray.tsx b/lib/shapes/ray.tsx index 1f9895a5d..3d9b97a54 100644 --- a/lib/shapes/ray.tsx +++ b/lib/shapes/ray.tsx @@ -16,7 +16,7 @@ const ray = createShape({ parentId: "page0", childIndex: 0, point: [0, 0], - vector: [0, 0], + direction: [0, 0], rotation: 0, style: {}, ...props, diff --git a/state/commands/index.ts b/state/commands/index.ts index 4fc69107a..77bba06a4 100644 --- a/state/commands/index.ts +++ b/state/commands/index.ts @@ -1,5 +1,6 @@ import translate from "./translate-command" +import transform from "./transform-command" -const commands = { translate } +const commands = { translate, transform } export default commands diff --git a/state/commands/transform-command.ts b/state/commands/transform-command.ts new file mode 100644 index 000000000..18bb125b5 --- /dev/null +++ b/state/commands/transform-command.ts @@ -0,0 +1,67 @@ +import Command from "./command" +import history from "../history" +import { Data } from "types" +import { TransformSnapshot } from "state/sessions/transform-session" +import { getShapeUtils } from "lib/shapes" + +export default function translateCommand( + data: Data, + before: TransformSnapshot, + after: TransformSnapshot +) { + history.execute( + data, + new Command({ + name: "translate_shapes", + category: "canvas", + do(data) { + const { shapeBounds, initialBounds, currentPageId, selectedIds } = after + const { shapes } = data.document.pages[currentPageId] + + selectedIds.forEach((id) => { + const { initialShape, initialShapeBounds } = shapeBounds[id] + const shape = shapes[id] + + getShapeUtils(shape).transform( + shape, + { + ...initialShapeBounds, + isFlippedX: false, + isFlippedY: false, + }, + initialShape, + initialShapeBounds, + initialBounds + ) + }) + }, + undo(data) { + const { + shapeBounds, + initialBounds, + currentPageId, + selectedIds, + } = before + + const { shapes } = data.document.pages[currentPageId] + + selectedIds.forEach((id) => { + const { initialShape, initialShapeBounds } = shapeBounds[id] + const shape = shapes[id] + + getShapeUtils(shape).transform( + shape, + { + ...initialShapeBounds, + isFlippedX: false, + isFlippedY: false, + }, + initialShape, + initialShapeBounds, + initialBounds + ) + }) + }, + }) + ) +} diff --git a/state/data.ts b/state/data.ts index 7a83525fb..98356c1b2 100644 --- a/state/data.ts +++ b/state/data.ts @@ -15,7 +15,7 @@ export const defaultDocument: Data["document"] = { childIndex: 3, point: [500, 100], style: { - fill: "#aaa", + fill: "#AAA", stroke: "#777", strokeWidth: 1, }, @@ -27,7 +27,7 @@ export const defaultDocument: Data["document"] = { point: [100, 100], radius: 50, style: { - fill: "#aaa", + fill: "#AAA", stroke: "#777", strokeWidth: 1, }, @@ -40,7 +40,7 @@ export const defaultDocument: Data["document"] = { radiusX: 50, radiusY: 30, style: { - fill: "#aaa", + fill: "#AAA", stroke: "#777", strokeWidth: 1, }, @@ -70,7 +70,19 @@ export const defaultDocument: Data["document"] = { point: [300, 300], size: [200, 200], style: { - fill: "#aaa", + fill: "#AAA", + stroke: "#777", + strokeWidth: 1, + }, + }), + shape6: shapeUtils[ShapeType.Line].create({ + id: "shape6", + name: "Shape 6", + childIndex: 1, + point: [400, 400], + direction: [0.2, 0.2], + style: { + fill: "#AAA", stroke: "#777", strokeWidth: 1, }, diff --git a/state/sessions/brush-session.ts b/state/sessions/brush-session.ts index 80e962fb4..d1d02987c 100644 --- a/state/sessions/brush-session.ts +++ b/state/sessions/brush-session.ts @@ -1,7 +1,7 @@ import { current } from "immer" import { ShapeUtil, Bounds, Data, Shapes } from "types" import BaseSession from "./base-session" -import shapes from "lib/shapes" +import shapes, { getShapeUtils } from "lib/shapes" import { getBoundsFromPoints } from "utils/utils" import * as vec from "utils/vec" @@ -68,9 +68,7 @@ export default class BrushSession extends BaseSession { .map((shape) => ({ id: shape.id, test: (brushBounds: Bounds): boolean => - (shapes[shape.type] as ShapeUtil< - Shapes[typeof shape.type] - >).hitTestBounds(shape, brushBounds), + getShapeUtils(shape).hitTestBounds(shape, brushBounds), })), } } diff --git a/state/sessions/transform-session.ts b/state/sessions/transform-session.ts index 01ed7b3c6..b4ca026b8 100644 --- a/state/sessions/transform-session.ts +++ b/state/sessions/transform-session.ts @@ -1,4 +1,10 @@ -import { Data, TransformEdge, TransformCorner, Bounds } from "types" +import { + Data, + TransformEdge, + TransformCorner, + Bounds, + BoundsSnapshot, +} from "types" import * as vec from "utils/vec" import BaseSession from "./base-session" import commands from "state/commands" @@ -11,7 +17,6 @@ export default class TransformSession extends BaseSession { transformType: TransformEdge | TransformCorner origin: number[] snapshot: TransformSnapshot - currentBounds: Bounds corners: { a: number[] b: number[] @@ -29,8 +34,6 @@ export default class TransformSession extends BaseSession { const { minX, minY, maxX, maxY } = this.snapshot.initialBounds - this.currentBounds = { ...this.snapshot.initialBounds } - this.corners = { a: [minX, minY], b: [maxX, maxY], @@ -38,130 +41,144 @@ export default class TransformSession extends BaseSession { } update(data: Data, point: number[]) { - const { shapeBounds, currentPageId, selectedIds } = this.snapshot const { - document: { pages }, - } = data + shapeBounds, + initialBounds, + currentPageId, + selectedIds, + } = this.snapshot + + const { shapes } = data.document.pages[currentPageId] let [x, y] = point - const { corners, transformType } = this + + const { + corners: { a, b }, + transformType, + } = this // Edge Transform switch (transformType) { case TransformEdge.Top: { - corners.a[1] = y + a[1] = y break } case TransformEdge.Right: { - corners.b[0] = x + b[0] = x break } case TransformEdge.Bottom: { - corners.b[1] = y + b[1] = y break } case TransformEdge.Left: { - corners.a[0] = x + a[0] = x break } case TransformCorner.TopLeft: { - corners.a[1] = y - corners.a[0] = x + a[1] = y + a[0] = x break } case TransformCorner.TopRight: { - corners.b[0] = x - corners.a[1] = y + b[0] = x + a[1] = y break } case TransformCorner.BottomRight: { - corners.b[1] = y - corners.b[0] = x + b[1] = y + b[0] = x break } case TransformCorner.BottomLeft: { - corners.a[0] = x - corners.b[1] = y + a[0] = x + b[1] = y break } } + // Calculate new common (externior) bounding box const newBounds = { - minX: Math.min(corners.a[0], corners.b[0]), - minY: Math.min(corners.a[1], corners.b[1]), - maxX: Math.max(corners.a[0], corners.b[0]), - maxY: Math.max(corners.a[1], corners.b[1]), - width: Math.abs(corners.b[0] - corners.a[0]), - height: Math.abs(corners.b[1] - corners.a[1]), + minX: Math.min(a[0], b[0]), + minY: Math.min(a[1], b[1]), + maxX: Math.max(a[0], b[0]), + maxY: Math.max(a[1], b[1]), + width: Math.abs(b[0] - a[0]), + height: Math.abs(b[1] - a[1]), } - const isFlippedX = corners.b[0] - corners.a[0] < 0 - const isFlippedY = corners.b[1] - corners.a[1] < 0 + const isFlippedX = b[0] < a[0] + const isFlippedY = b[1] < a[1] - // const dx = newBounds.minX - currentBounds.minX - // const dy = newBounds.minY - currentBounds.minY - // const scaleX = newBounds.width / currentBounds.width - // const scaleY = newBounds.height / currentBounds.height - - this.currentBounds = newBounds + // Now work backward to calculate a new bounding box for each of the shapes. selectedIds.forEach((id) => { - const { nx, nmx, nw, ny, nmy, nh } = shapeBounds[id] + const { initialShape, initialShapeBounds } = shapeBounds[id] + const { nx, nmx, nw, ny, nmy, nh } = initialShapeBounds + const shape = shapes[id] const minX = newBounds.minX + (isFlippedX ? nmx : nx) * newBounds.width const minY = newBounds.minY + (isFlippedY ? nmy : ny) * newBounds.height const width = nw * newBounds.width const height = nh * newBounds.height - const shape = pages[currentPageId].shapes[id] - - getShapeUtils(shape).transform(shape, { + const newShapeBounds = { minX, minY, maxX: minX + width, maxY: minY + height, width, height, - }) - // utils.stretch(shape, scaleX, scaleY) + isFlippedX, + isFlippedY, + } + + // Pass the new data to the shape's transform utility for mutation. + // Most shapes should be able to transform using only the bounding box, + // however some shapes (e.g. those with internal points) will need more + // data here too. + + getShapeUtils(shape).transform( + shape, + newShapeBounds, + initialShape, + initialShapeBounds, + initialBounds + ) }) - - // switch (this.transformHandle) { - // case TransformEdge.Top: - // case TransformEdge.Left: - // case TransformEdge.Right: - // case TransformEdge.Bottom: { - // for (let id in shapeBounds) { - // const { ny, nmy, nh } = shapeBounds[id] - // const minY = v.my + (v.y1 < v.y0 ? nmy : ny) * v.mh - // const height = nh * v.mh - - // const shape = pages[currentPageId].shapes[id] - - // getShapeUtils(shape).transform(shape) - // } - // } - // case TransformCorner.TopLeft: - // case TransformCorner.TopRight: - // case TransformCorner.BottomLeft: - // case TransformCorner.BottomRight: { - // } - // } } cancel(data: Data) { - const { currentPageId } = this.snapshot - const { document } = data + const { + shapeBounds, + initialBounds, + currentPageId, + selectedIds, + } = this.snapshot - // for (let id in shapes) { - // Restore shape using original bounds - // document.pages[currentPageId].shapes[id] - // } + const { shapes } = data.document.pages[currentPageId] + + selectedIds.forEach((id) => { + const shape = shapes.shapes[id] + const { initialShape, initialShapeBounds } = shapeBounds[id] + + getShapeUtils(shape).transform( + shape, + { + ...initialShapeBounds, + isFlippedX: false, + isFlippedY: false, + }, + initialShape, + initialShapeBounds, + initialBounds + ) + }) } complete(data: Data) { - // commands.translate(data, this.snapshot, getTransformSnapshot(data)) + commands.transform(data, this.snapshot, getTransformSnapshot(data)) } } @@ -172,21 +189,18 @@ export function getTransformSnapshot(data: Data) { currentPageId, } = current(data) + const pageShapes = pages[currentPageId].shapes + // A mapping of selected shapes and their bounds const shapesBounds = Object.fromEntries( Array.from(selectedIds.values()).map((id) => { - const shape = pages[currentPageId].shapes[id] + const shape = pageShapes[id] return [shape.id, getShapeUtils(shape).getBounds(shape)] }) ) // The common (exterior) bounds of the selected shapes - const bounds = getCommonBounds( - ...Array.from(selectedIds.values()).map((id) => { - const shape = pages[currentPageId].shapes[id] - return getShapeUtils(shape).getBounds(shape) - }) - ) + const bounds = getCommonBounds(...Object.values(shapesBounds)) // Return a mapping of shapes to bounds together with the relative // positions of the shape's bounds within the common bounds shape. @@ -200,13 +214,16 @@ export function getTransformSnapshot(data: Data) { return [ id, { - ...bounds, - nx: (minX - bounds.minX) / bounds.width, - ny: (minY - bounds.minY) / bounds.height, - nmx: 1 - (minX + width - bounds.minX) / bounds.width, - nmy: 1 - (minY + height - bounds.minY) / bounds.height, - nw: width / bounds.width, - nh: height / bounds.height, + initialShape: pageShapes[id], + initialShapeBounds: { + ...shapesBounds[id], + nx: (minX - bounds.minX) / bounds.width, + ny: (minY - bounds.minY) / bounds.height, + nmx: 1 - (minX + width - bounds.minX) / bounds.width, + nmy: 1 - (minY + height - bounds.minY) / bounds.height, + nw: width / bounds.width, + nh: height / bounds.height, + }, }, ] }) diff --git a/types.ts b/types.ts index bc898e23f..7cd7849c1 100644 --- a/types.ts +++ b/types.ts @@ -65,12 +65,12 @@ export interface EllipseShape extends BaseShape { export interface LineShape extends BaseShape { type: ShapeType.Line - vector: number[] + direction: number[] } export interface RayShape extends BaseShape { type: ShapeType.Ray - vector: number[] + direction: number[] } export interface PolylineShape extends BaseShape {