From bd556d5a308f1d8c60231b38da4fa64386c8578b Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Thu, 31 Oct 2024 20:25:43 +0900 Subject: [PATCH 01/67] feat: Add new property to shape model for attachment --- src/models/index.ts | 9 +++++++++ src/shapes/core.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/src/models/index.ts b/src/models/index.ts index b8425e6a..0049896f 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -30,6 +30,7 @@ export interface Shape extends Entity { clipping?: boolean; // When this is set true, it's prioritized over all child shapes. cropClipBorder?: boolean; // This is prioritized over all child shapes. alpha?: number; // "undefined" should mean 1. + attachment?: ShapeAttachment; } export type ClipRule = "out" | "in"; @@ -100,6 +101,14 @@ export interface ArcCurveControl { d: IVec2; } +export interface ShapeAttachment { + id: string; // id of target shape that is attached to + to: IVec2; // relative rete within the bounds of the target + anchor: IVec2; // relative rate within the bounds of attaching shape + rotationType: "relative" | "absolute"; + rotation: number; // this value becomes either relative or absolute based on "rotationType" +} + export interface BoxAlign { hAlign?: "left" | "center" | "right"; // "left" should be default vAlign?: "top" | "center" | "bottom"; // "top" should be default diff --git a/src/shapes/core.ts b/src/shapes/core.ts index 3d42d36a..740f128b 100644 --- a/src/shapes/core.ts +++ b/src/shapes/core.ts @@ -129,6 +129,7 @@ export function createBaseShape(arg: Partial = {}): Shape { clipping: arg.clipping, cropClipBorder: arg.cropClipBorder, alpha: arg.alpha, + attachment: arg.attachment, }; } From d6ce25fc581860bba7f7f5188cb63018cd63fe33 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Thu, 31 Oct 2024 22:45:40 +0900 Subject: [PATCH 02/67] refactor: Extract getClosestOutlineInfoOfLine function from line lable utils --- src/shapes/utils/line.spec.ts | 25 +++++++++- src/shapes/utils/line.ts | 91 ++++++++++++++++++++++++++++++++++- src/shapes/utils/lineLabel.ts | 47 +++++------------- 3 files changed, 125 insertions(+), 38 deletions(-) diff --git a/src/shapes/utils/line.spec.ts b/src/shapes/utils/line.spec.ts index 1d31fa9b..8e6a0351 100644 --- a/src/shapes/utils/line.spec.ts +++ b/src/shapes/utils/line.spec.ts @@ -1,8 +1,9 @@ import { describe, test, expect } from "vitest"; -import { getNakedLineShape, patchByFliplineH, patchByFliplineV } from "./line"; +import { getClosestOutlineInfoOfLine, getNakedLineShape, patchByFliplineH, patchByFliplineV } from "./line"; import { createShape, getCommonStruct } from ".."; import { LineShape } from "../line"; import { createLineHead } from "../lineHeads"; +import { struct as lineStruct } from "../line"; describe("getNakedLineShape", () => { test("should return the line with minimum styles", () => { @@ -89,3 +90,25 @@ describe("patchByFliplineV", () => { expect(result.q).toEqual({ x: 100, y: 0 }); }); }); + +describe("getClosestOutlineInfoOfLine", () => { + test("should return the closest outline info if exists", () => { + const line0 = lineStruct.create({ q: { x: 100, y: 0 } }); + expect(getClosestOutlineInfoOfLine(line0, { x: 40, y: 11 }, 10)).toEqual(undefined); + expect(getClosestOutlineInfoOfLine(line0, { x: 40, y: 9 }, 10)).toEqual([{ x: 40, y: 0 }, 0.4]); + }); + + test("should regard bezier segment", () => { + const line0 = lineStruct.create({ q: { x: 100, y: 0 }, curves: [{ c1: { x: 20, y: 20 }, c2: { x: 80, y: 20 } }] }); + const result0 = getClosestOutlineInfoOfLine(line0, { x: 40, y: 9 }, 10); + expect(result0?.[0]).toEqualPoint({ x: 39.72402250951937, y: 14.482752810881848 }); + expect(result0?.[1]).toBeCloseTo(0.4); + }); + + test("should regard arc segment", () => { + const line0 = lineStruct.create({ q: { x: 100, y: 0 }, curves: [{ d: { x: 0, y: 20 } }] }); + const result0 = getClosestOutlineInfoOfLine(line0, { x: 40, y: 18 }, 10); + expect(result0?.[0]).toEqualPoint({ x: 39.90618673949272, y: 19.230361600706782 }); + expect(result0?.[1]).toBeCloseTo(0.4081723); + }); +}); diff --git a/src/shapes/utils/line.ts b/src/shapes/utils/line.ts index c4b8052d..bc5d1ad5 100644 --- a/src/shapes/utils/line.ts +++ b/src/shapes/utils/line.ts @@ -1,6 +1,19 @@ -import { getRectCenter, getSymmetry, isSame, IVec2 } from "okageo"; -import { LineShape, struct } from "../line"; +import { + getApproPoints, + getDistance, + getPathPointAtLengthFromStructs, + getPedal, + getPolylineLength, + getRectCenter, + getSymmetry, + isOnSeg, + isSame, + IVec2, +} from "okageo"; +import { getLinePath, isCurveLine, LineShape, struct } from "../line"; import { isBezieirControl } from "../../utils/path"; +import { BEZIER_APPROX_SIZE, getCurveLerpFn, getSegments, ISegment } from "../../utils/geometry"; +import { pickMinItem } from "../../utils/commons"; /** * Returns the line with minimum styles. @@ -67,3 +80,77 @@ function patchByFlipline(line: LineShape, flipFn: (v: IVec2) => IVec2): Partial< return ret; } + +export function getClosestOutlineInfoOfLine( + line: LineShape, + p: IVec2, + threshold: number, +): [p: IVec2, rate: number] | undefined { + const edgeInfo = getEdgeInfo(line); + const edges = edgeInfo.edges; + + const values = edges + .map<[number, number, IVec2]>((edge, i) => { + let pedal = getPedal(p, edge); + if (!isOnSeg(pedal, edge)) { + pedal = getDistance(edge[0], p) <= getDistance(edge[1], p) ? edge[0] : edge[1]; + } + return [i, getDistance(p, pedal), pedal]; + }) + .filter((v) => v[1] < threshold); + const closestValue = pickMinItem(values, (v) => v[1]); + if (!closestValue) return; + + const closestEdgeIndex = closestValue[0]; + const closestPedal = closestValue[2]; + + const dList = edgeInfo.edgeLengths; + const totalD = edgeInfo.totalLength; + let d = 0; + for (let i = 0; i < closestEdgeIndex; i++) { + d += dList[i]; + } + d += getDistance(edges[closestEdgeIndex][0], closestPedal); + const rate = d / totalD; + + return [closestPedal, rate]; +} + +function getEdgeInfo(line: LineShape): { + edges: ISegment[]; + edgeLengths: number[]; + totalLength: number; + lerpFn?: (rate: number) => IVec2; +} { + const edges = getSegments(getLinePath(line)); + if (!isCurveLine(line)) { + const edgeLengths = edges.map((edge) => getDistance(edge[0], edge[1])); + return { + edges, + edgeLengths, + totalLength: edgeLengths.reduce((n, l) => n + l, 0), + }; + } + + const pathStructs = edges.map((edge, i) => { + const curve = line.curves[i]; + const lerpFn = getCurveLerpFn(edge, curve); + let points: IVec2[] = edge; + let edges = [edge]; + if (curve) { + points = getApproPoints(lerpFn, BEZIER_APPROX_SIZE); + edges = getSegments(points); + } + return { lerpFn, length: getPolylineLength(points), edges }; + }); + + const approxEdges = pathStructs.flatMap((s) => s.edges); + const edgeLengths = approxEdges.map((edge) => getDistance(edge[0], edge[1])); + const totalLength = pathStructs.reduce((n, s) => n + s.length, 0); + return { + edges: approxEdges, + edgeLengths, + totalLength, + lerpFn: (rate) => getPathPointAtLengthFromStructs(pathStructs, totalLength * rate), + }; +} diff --git a/src/shapes/utils/lineLabel.ts b/src/shapes/utils/lineLabel.ts index 002b3744..a4a9992a 100644 --- a/src/shapes/utils/lineLabel.ts +++ b/src/shapes/utils/lineLabel.ts @@ -3,68 +3,48 @@ import { getApproPoints, getDistance, getPathPointAtLengthFromStructs, - getPedal, getPolylineLength, getRectCenter, - isOnSeg, } from "okageo"; import { LineShape, getLinePath, isCurveLine, isLineShape } from "../line"; import { TextShape, isTextShape, patchPosition } from "../text"; import { BEZIER_APPROX_SIZE, ISegment, getCurveLerpFn, getRotateFn, getSegments } from "../../utils/geometry"; -import { pickMinItem } from "../../utils/commons"; import { Shape } from "../../models"; import { ShapeComposite } from "../../composables/shapeComposite"; +import { getClosestOutlineInfoOfLine } from "./line"; export function attachLabelToLine(line: LineShape, label: TextShape, margin = 0): Partial { const labelBounds = { x: label.p.x, y: label.p.y, width: label.width, height: label.height }; const labelCenter = getRectCenter(labelBounds); const rotateFn = getRotateFn(-label.rotation, labelCenter); - const edgeInfo = getEdgeInfo(line, rotateFn); - // const edges = getEdges(line).map(([a, b]) => [rotateFn(a), rotateFn(b)]); - const edges = edgeInfo.edges; - - const values = edges.map<[number, number, IVec2]>((edge, i) => { - let pedal = getPedal(labelCenter, edge); - if (!isOnSeg(pedal, edge)) { - pedal = getDistance(edge[0], labelCenter) <= getDistance(edge[1], labelCenter) ? edge[0] : edge[1]; - } - return [i, getDistance(labelCenter, pedal), pedal]; - }); - const closestValue = pickMinItem(values, (v) => v[1])!; - const closestEdgeIndex = closestValue[0]; - const closestPedal = closestValue[2]; + const closestInfo = getClosestOutlineInfoOfLine(line, labelCenter, Infinity)!; + const [closestPedal, rate] = closestInfo; let patch: Partial = {}; - if (closestPedal.x <= labelBounds.x) { + const rotatedClosestPedal = rotateFn(closestPedal); + if (rotatedClosestPedal.x <= labelBounds.x) { patch.hAlign = "left"; - } else if (labelBounds.x + labelBounds.width <= closestPedal.x) { + } else if (labelBounds.x + labelBounds.width <= rotatedClosestPedal.x) { patch.hAlign = "right"; } else { patch.hAlign = "center"; } - if (closestPedal.y <= labelBounds.y) { + if (rotatedClosestPedal.y <= labelBounds.y) { patch.vAlign = "top"; - } else if (labelBounds.y + labelBounds.height <= closestPedal.y) { + } else if (labelBounds.y + labelBounds.height <= rotatedClosestPedal.y) { patch.vAlign = "bottom"; } else { patch.vAlign = "center"; } - const dList = edgeInfo.edgeLengths; - const totalD = edgeInfo.totalLength; - let d = 0; - for (let i = 0; i < closestEdgeIndex; i++) { - d += dList[i]; - } - d += getDistance(edges[closestEdgeIndex][0], closestPedal); - const rate = d / totalD; patch.lineAttached = rate; + const edgeInfo = getEdgeInfo(line); const distP = edgeInfo.lerpFn?.(rate) ?? closestPedal; - patch = { ...patch, ...patchPosition({ ...label, ...patch }, rotateFn(distP, true), margin) }; + patch = { ...patch, ...patchPosition({ ...label, ...patch }, distP, margin) }; const ret = { ...patch }; if (ret.hAlign === label.hAlign) { @@ -80,16 +60,13 @@ export function attachLabelToLine(line: LineShape, label: TextShape, margin = 0) return ret; } -function getEdgeInfo( - line: LineShape, - rotateFn: ReturnType, -): { +function getEdgeInfo(line: LineShape): { edges: ISegment[]; edgeLengths: number[]; totalLength: number; lerpFn?: (rate: number) => IVec2; } { - const edges = getSegments(getLinePath(line).map((p) => rotateFn(p))); + const edges = getSegments(getLinePath(line)); if (!isCurveLine(line)) { const edgeLengths = edges.map((edge) => getDistance(edge[0], edge[1])); return { From 3ef7ea39307e0de4190e9dc88519dd18a924613f Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Thu, 31 Oct 2024 22:47:37 +0900 Subject: [PATCH 03/67] refactor: Remove duplicated code --- src/shapes/utils/line.ts | 4 +-- src/shapes/utils/lineLabel.ts | 56 ++++------------------------------- 2 files changed, 7 insertions(+), 53 deletions(-) diff --git a/src/shapes/utils/line.ts b/src/shapes/utils/line.ts index bc5d1ad5..aedb5ee5 100644 --- a/src/shapes/utils/line.ts +++ b/src/shapes/utils/line.ts @@ -86,7 +86,7 @@ export function getClosestOutlineInfoOfLine( p: IVec2, threshold: number, ): [p: IVec2, rate: number] | undefined { - const edgeInfo = getEdgeInfo(line); + const edgeInfo = getLineEdgeInfo(line); const edges = edgeInfo.edges; const values = edges @@ -116,7 +116,7 @@ export function getClosestOutlineInfoOfLine( return [closestPedal, rate]; } -function getEdgeInfo(line: LineShape): { +export function getLineEdgeInfo(line: LineShape): { edges: ISegment[]; edgeLengths: number[]; totalLength: number; diff --git a/src/shapes/utils/lineLabel.ts b/src/shapes/utils/lineLabel.ts index a4a9992a..4450a197 100644 --- a/src/shapes/utils/lineLabel.ts +++ b/src/shapes/utils/lineLabel.ts @@ -1,17 +1,10 @@ -import { - IVec2, - getApproPoints, - getDistance, - getPathPointAtLengthFromStructs, - getPolylineLength, - getRectCenter, -} from "okageo"; -import { LineShape, getLinePath, isCurveLine, isLineShape } from "../line"; +import { getRectCenter } from "okageo"; +import { LineShape, isLineShape } from "../line"; import { TextShape, isTextShape, patchPosition } from "../text"; -import { BEZIER_APPROX_SIZE, ISegment, getCurveLerpFn, getRotateFn, getSegments } from "../../utils/geometry"; +import { getRotateFn } from "../../utils/geometry"; import { Shape } from "../../models"; import { ShapeComposite } from "../../composables/shapeComposite"; -import { getClosestOutlineInfoOfLine } from "./line"; +import { getClosestOutlineInfoOfLine, getLineEdgeInfo } from "./line"; export function attachLabelToLine(line: LineShape, label: TextShape, margin = 0): Partial { const labelBounds = { x: label.p.x, y: label.p.y, width: label.width, height: label.height }; @@ -42,7 +35,7 @@ export function attachLabelToLine(line: LineShape, label: TextShape, margin = 0) patch.lineAttached = rate; - const edgeInfo = getEdgeInfo(line); + const edgeInfo = getLineEdgeInfo(line); const distP = edgeInfo.lerpFn?.(rate) ?? closestPedal; patch = { ...patch, ...patchPosition({ ...label, ...patch }, distP, margin) }; @@ -60,45 +53,6 @@ export function attachLabelToLine(line: LineShape, label: TextShape, margin = 0) return ret; } -function getEdgeInfo(line: LineShape): { - edges: ISegment[]; - edgeLengths: number[]; - totalLength: number; - lerpFn?: (rate: number) => IVec2; -} { - const edges = getSegments(getLinePath(line)); - if (!isCurveLine(line)) { - const edgeLengths = edges.map((edge) => getDistance(edge[0], edge[1])); - return { - edges, - edgeLengths, - totalLength: edgeLengths.reduce((n, l) => n + l, 0), - }; - } - - const pathStructs = edges.map((edge, i) => { - const curve = line.curves[i]; - const lerpFn = getCurveLerpFn(edge, curve); - let points: IVec2[] = edge; - let edges = [edge]; - if (curve) { - points = getApproPoints(lerpFn, BEZIER_APPROX_SIZE); - edges = getSegments(points); - } - return { lerpFn, length: getPolylineLength(points), edges }; - }); - - const approxEdges = pathStructs.flatMap((s) => s.edges); - const edgeLengths = approxEdges.map((edge) => getDistance(edge[0], edge[1])); - const totalLength = pathStructs.reduce((n, s) => n + s.length, 0); - return { - edges: approxEdges, - edgeLengths, - totalLength, - lerpFn: (rate) => getPathPointAtLengthFromStructs(pathStructs, totalLength * rate), - }; -} - export function isLineLabelShape(shapeComposite: ShapeComposite, shape: Shape): shape is TextShape { const parent = shapeComposite.shapeMap[shape.parentId ?? ""]; return !!parent && isLineShape(parent) && isTextShape(shape) && shape.lineAttached !== undefined; From 1e836e122f4eeb2a696528091938b80bd8fe70a3 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Thu, 31 Oct 2024 22:49:06 +0900 Subject: [PATCH 04/67] feat: Implement new states dealing with attaching shapes to a line --- .../appCanvas/lines/movingOnLineState.ts | 103 ++++++++++++++++++ .../appCanvas/movingShapeOnLineHandler.ts | 23 ++++ .../states/appCanvas/movingShapeState.ts | 13 ++- 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/composables/states/appCanvas/lines/movingOnLineState.ts create mode 100644 src/composables/states/appCanvas/movingShapeOnLineHandler.ts diff --git a/src/composables/states/appCanvas/lines/movingOnLineState.ts b/src/composables/states/appCanvas/lines/movingOnLineState.ts new file mode 100644 index 00000000..98e62b05 --- /dev/null +++ b/src/composables/states/appCanvas/lines/movingOnLineState.ts @@ -0,0 +1,103 @@ +import type { AppCanvasState } from "../core"; +import { applyFillStyle } from "../../../../utils/fillStyle"; +import { mapReduce, patchPipe } from "../../../../utils/commons"; +import { isLineShape } from "../../../../shapes/line"; +import { getClosestOutlineInfoOfLine } from "../../../../shapes/utils/line"; +import { TAU } from "../../../../utils/geometry"; +import { IVec2 } from "okageo"; + +export function newMovingOnLineState(option: { lineId: string }): AppCanvasState { + let keepMoving = false; + let lineAnchor: IVec2 | undefined; + + return { + getLabel: () => "MovingOnLine", + onStart: (ctx) => { + // ctx.setTmpShapeMap({}); + }, + onEnd: (ctx) => { + if (keepMoving) { + ctx.setTmpShapeMap(mapReduce(ctx.getTmpShapeMap(), (patch) => ({ ...patch, attachment: undefined }))); + } else { + ctx.setTmpShapeMap({}); + } + }, + handleEvent: (ctx, event) => { + switch (event.type) { + case "pointermove": { + if (event.data.ctrl) { + keepMoving = true; + return { type: "break" }; + } + + const p = event.data.current; + const shapeComposite = ctx.getShapeComposite(); + const shapeMap = shapeComposite.shapeMap; + const line = shapeMap[option.lineId]; + if (!isLineShape(line)) { + keepMoving = true; + return { type: "break" }; + } + + const anchor = { x: 0.5, y: 0.5 }; + const closestInfo = getClosestOutlineInfoOfLine(line, p, 40 * ctx.getScale()); + if (!closestInfo) { + keepMoving = true; + return { type: "break" }; + } + + const toP = closestInfo[0]; + lineAnchor = toP; + const to = { x: closestInfo[1], y: 0 }; + const selectedShapeMap = mapReduce(ctx.getSelectedShapeIdMap(), (_, id) => shapeMap[id]); + const patch = patchPipe( + [ + (src) => { + return mapReduce(src, (s) => { + const bounds = shapeComposite.getWrapperRect(s); + const anchorP = { x: bounds.x + bounds.width * anchor.x, y: bounds.y + bounds.height * anchor.y }; + return { + ...shapeComposite.transformShape(s, [1, 0, 0, 1, toP.x - anchorP.x, toP.y - anchorP.y]), + attachment: { + id: line.id, + to, + anchor: { x: 0.5, y: 0 }, + rotationType: "relative", + rotation: 0, + } as const, + }; + }); + }, + ], + selectedShapeMap, + ); + ctx.setTmpShapeMap(patch.patch); + + return; + } + case "pointerup": { + if (event.data.options.ctrl) return ctx.states.newSelectionHubState; + + ctx.patchShapes(ctx.getTmpShapeMap()); + return ctx.states.newSelectionHubState; + } + case "selection": { + return ctx.states.newSelectionHubState; + } + default: + return; + } + }, + render: (ctx, renderCtx) => { + const style = ctx.getStyleScheme(); + const scale = ctx.getScale(); + + if (lineAnchor) { + applyFillStyle(renderCtx, { color: style.selectionPrimary }); + renderCtx.beginPath(); + renderCtx.arc(lineAnchor.x, lineAnchor.y, 10 * scale, 0, TAU); + renderCtx.fill(); + } + }, + }; +} diff --git a/src/composables/states/appCanvas/movingShapeOnLineHandler.ts b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts new file mode 100644 index 00000000..d44b144a --- /dev/null +++ b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts @@ -0,0 +1,23 @@ +import { isLineShape } from "../../../shapes/line"; +import { PointerMoveEvent, TransitionValue } from "../core"; +import { AppCanvasStateContext } from "./core"; +import { newMovingOnLineState } from "./lines/movingOnLineState"; + +export function handlePointerMoveOnLine( + ctx: AppCanvasStateContext, + event: PointerMoveEvent, + movingIds: string[], +): TransitionValue { + if (event.data.ctrl) return; + if (movingIds.length === 0) return; + + const shapeComposite = ctx.getShapeComposite(); + const scope = shapeComposite.getSelectionScope(shapeComposite.shapeMap[movingIds[0]]); + const target = shapeComposite.findShapeAt(event.data.current, scope, movingIds, false, ctx.getScale()); + if (!target || !isLineShape(target)) return; + + return { + type: "stack-resume", + getState: () => newMovingOnLineState({ lineId: target.id }), + }; +} diff --git a/src/composables/states/appCanvas/movingShapeState.ts b/src/composables/states/appCanvas/movingShapeState.ts index 4199f4dd..4ad89464 100644 --- a/src/composables/states/appCanvas/movingShapeState.ts +++ b/src/composables/states/appCanvas/movingShapeState.ts @@ -18,6 +18,7 @@ import { getPatchAfterLayouts } from "../../shapeLayoutHandler"; import { isLineLabelShape } from "../../../shapes/utils/lineLabel"; import { mergeMap } from "../../../utils/commons"; import { applyStrokeStyle } from "../../../utils/strokeStyle"; +import { handlePointerMoveOnLine } from "./movingShapeOnLineHandler"; interface Option { boundingBox?: BoundingBox; @@ -32,6 +33,7 @@ export function newMovingShapeState(option?: Option): AppCanvasState { let lineHandler: ConnectedLineDetouchHandler; let targetIds: string[]; let connectionRenderer: ConnectionRenderer; + let resumedBeforeMove = false; return { getLabel: () => "MovingShape", @@ -85,6 +87,9 @@ export function newMovingShapeState(option?: Option): AppCanvasState { excludeIdSet: new Set(targetIds), }); }, + onResume() { + resumedBeforeMove = true; + }, onEnd: (ctx) => { ctx.stopDragging(); ctx.setTmpShapeMap({}); @@ -94,9 +99,13 @@ export function newMovingShapeState(option?: Option): AppCanvasState { handleEvent: (ctx, event) => { switch (event.type) { case "pointermove": { + resumedBeforeMove = false; const onLayoutResult = handlePointerMoveOnLayout(ctx, event, targetIds, option); if (onLayoutResult) return onLayoutResult; + const onLineResult = handlePointerMoveOnLine(ctx, event, targetIds); + if (onLineResult) return onLineResult; + const d = sub(event.data.current, event.data.start); snappingResult = event.data.ctrl ? undefined : shapeSnapping.test(moveRect(movingRect, d)); const translate = snappingResult ? add(d, snappingResult.diff) : d; @@ -133,8 +142,10 @@ export function newMovingShapeState(option?: Option): AppCanvasState { } }, render: (ctx, renderCtx) => { - const shapeComposite = ctx.getShapeComposite(); + // Avoid rendering in this case to prevent flickering + if (resumedBeforeMove) return; + const shapeComposite = ctx.getShapeComposite(); const scale = ctx.getScale(); const style = ctx.getStyleScheme(); applyStrokeStyle(renderCtx, { color: style.selectionPrimary, width: style.selectionLineWidth * scale }); From 4024a68143843397b58fb7573ffafebbe050ad57 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 00:19:44 +0900 Subject: [PATCH 05/67] fix: Let the function always return `lerpFn` - It's more versatile --- src/shapes/utils/line.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/shapes/utils/line.ts b/src/shapes/utils/line.ts index aedb5ee5..a1c6ae26 100644 --- a/src/shapes/utils/line.ts +++ b/src/shapes/utils/line.ts @@ -10,7 +10,7 @@ import { isSame, IVec2, } from "okageo"; -import { getLinePath, isCurveLine, LineShape, struct } from "../line"; +import { getLinePath, LineShape, struct } from "../line"; import { isBezieirControl } from "../../utils/path"; import { BEZIER_APPROX_SIZE, getCurveLerpFn, getSegments, ISegment } from "../../utils/geometry"; import { pickMinItem } from "../../utils/commons"; @@ -120,20 +120,11 @@ export function getLineEdgeInfo(line: LineShape): { edges: ISegment[]; edgeLengths: number[]; totalLength: number; - lerpFn?: (rate: number) => IVec2; + lerpFn: (rate: number) => IVec2; } { const edges = getSegments(getLinePath(line)); - if (!isCurveLine(line)) { - const edgeLengths = edges.map((edge) => getDistance(edge[0], edge[1])); - return { - edges, - edgeLengths, - totalLength: edgeLengths.reduce((n, l) => n + l, 0), - }; - } - const pathStructs = edges.map((edge, i) => { - const curve = line.curves[i]; + const curve = line.curves?.[i]; const lerpFn = getCurveLerpFn(edge, curve); let points: IVec2[] = edge; let edges = [edge]; From 6141dbcaed235d19454cf678084e9c68526c7cc9 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 00:21:06 +0900 Subject: [PATCH 06/67] feat: Spread shapes on the line when multiple shapes are attached to the line - There seems no ideal behavior though, it could make sense at least. --- .../appCanvas/lines/movingOnLineState.ts | 43 +++++++++++++++---- .../appCanvas/movingShapeOnLineHandler.ts | 9 ++-- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/composables/states/appCanvas/lines/movingOnLineState.ts b/src/composables/states/appCanvas/lines/movingOnLineState.ts index 98e62b05..a9c1e584 100644 --- a/src/composables/states/appCanvas/lines/movingOnLineState.ts +++ b/src/composables/states/appCanvas/lines/movingOnLineState.ts @@ -1,19 +1,29 @@ import type { AppCanvasState } from "../core"; import { applyFillStyle } from "../../../../utils/fillStyle"; -import { mapReduce, patchPipe } from "../../../../utils/commons"; -import { isLineShape } from "../../../../shapes/line"; -import { getClosestOutlineInfoOfLine } from "../../../../shapes/utils/line"; +import { mapReduce, patchPipe, toList } from "../../../../utils/commons"; +import { isLineShape, LineShape } from "../../../../shapes/line"; +import { getClosestOutlineInfoOfLine, getLineEdgeInfo } from "../../../../shapes/utils/line"; import { TAU } from "../../../../utils/geometry"; -import { IVec2 } from "okageo"; +import { IVec2, lerpPoint } from "okageo"; -export function newMovingOnLineState(option: { lineId: string }): AppCanvasState { +type Option = { + lineId: string; + shapeId: string; +}; + +export function newMovingOnLineState(option: Option): AppCanvasState { let keepMoving = false; let lineAnchor: IVec2 | undefined; + let lineLerpFn: (t: number) => IVec2; return { getLabel: () => "MovingOnLine", onStart: (ctx) => { // ctx.setTmpShapeMap({}); + const shapeComposite = ctx.getShapeComposite(); + const shapeMap = shapeComposite.shapeMap; + const line = shapeMap[option.lineId] as LineShape; + lineLerpFn = getLineEdgeInfo(line).lerpFn; }, onEnd: (ctx) => { if (keepMoving) { @@ -46,16 +56,33 @@ export function newMovingOnLineState(option: { lineId: string }): AppCanvasState return { type: "break" }; } - const toP = closestInfo[0]; - lineAnchor = toP; - const to = { x: closestInfo[1], y: 0 }; + const baseToP = closestInfo[0]; + lineAnchor = baseToP; + const baseTo = { x: closestInfo[1], y: 0 }; + const selectedShapeMap = mapReduce(ctx.getSelectedShapeIdMap(), (_, id) => shapeMap[id]); + const selectedShapes = toList(selectedShapeMap); + const attachInfoMap = new Map([[option.shapeId, [baseTo, baseToP]]]); + + if (selectedShapes.length > 1) { + const toLerpFn = (t: number) => lerpPoint(baseTo, { x: 1, y: 0 }, t); + const step = 1 / selectedShapes.length; + selectedShapes + .filter((s) => s.id !== option.shapeId) + .forEach((s, i) => { + const to = toLerpFn(step * (i + 1)); + attachInfoMap.set(s.id, [to, lineLerpFn(to.x)]); + }); + } + const patch = patchPipe( [ (src) => { return mapReduce(src, (s) => { const bounds = shapeComposite.getWrapperRect(s); const anchorP = { x: bounds.x + bounds.width * anchor.x, y: bounds.y + bounds.height * anchor.y }; + const to = attachInfoMap.get(s.id)![0]; + const toP = attachInfoMap.get(s.id)![1]; return { ...shapeComposite.transformShape(s, [1, 0, 0, 1, toP.x - anchorP.x, toP.y - anchorP.y]), attachment: { diff --git a/src/composables/states/appCanvas/movingShapeOnLineHandler.ts b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts index d44b144a..bd930c8d 100644 --- a/src/composables/states/appCanvas/movingShapeOnLineHandler.ts +++ b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts @@ -13,11 +13,14 @@ export function handlePointerMoveOnLine( const shapeComposite = ctx.getShapeComposite(); const scope = shapeComposite.getSelectionScope(shapeComposite.shapeMap[movingIds[0]]); - const target = shapeComposite.findShapeAt(event.data.current, scope, movingIds, false, ctx.getScale()); - if (!target || !isLineShape(target)) return; + const targetLine = shapeComposite.findShapeAt(event.data.current, scope, movingIds, false, ctx.getScale()); + if (!targetLine || !isLineShape(targetLine)) return; + + const movingShape = shapeComposite.findShapeAt(event.data.current, scope, [targetLine.id], false, ctx.getScale()); + if (!movingShape) return; return { type: "stack-resume", - getState: () => newMovingOnLineState({ lineId: target.id }), + getState: () => newMovingOnLineState({ lineId: targetLine.id, shapeId: movingShape.id }), }; } From 42a8ec14bafd3de75164b956a2eb4872ed632fd3 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 12:45:33 +0900 Subject: [PATCH 07/67] feat: Move attached shapes along with the line on modified --- src/composables/lineAttachmentHandler.spec.ts | 52 +++++++++++ src/composables/lineAttachmentHandler.ts | 87 +++++++++++++++++++ src/composables/shapeLayoutHandler.ts | 4 + .../appCanvas/lines/movingOnLineState.ts | 25 +++--- 4 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 src/composables/lineAttachmentHandler.spec.ts create mode 100644 src/composables/lineAttachmentHandler.ts diff --git a/src/composables/lineAttachmentHandler.spec.ts b/src/composables/lineAttachmentHandler.spec.ts new file mode 100644 index 00000000..cf1dc1b2 --- /dev/null +++ b/src/composables/lineAttachmentHandler.spec.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from "vitest"; +import { getLineAttachmentPatch, patchByMoveToAttachedPoint } from "./lineAttachmentHandler"; +import { newShapeComposite } from "./shapeComposite"; +import { createShape, getCommonStruct } from "../shapes"; +import { LineShape } from "../shapes/line"; + +describe("getLineAttachmentPatch", () => { + test("should return shape patch to move shapes to the attached points", () => { + const line = createShape(getCommonStruct, "line", { id: "line", q: { x: 100, y: 0 } }); + const shapeA = createShape(getCommonStruct, "rectangle", { id: "a" }); + const shapeB = createShape(getCommonStruct, "rectangle", { + id: "b", + attachment: { + id: line.id, + to: { x: 0.2, y: 0 }, + anchor: { x: 0.5, y: 0.5 }, + rotationType: "relative", + rotation: 0, + }, + }); + const shapeComposite = newShapeComposite({ + shapes: [line, shapeA, shapeB], + getStruct: getCommonStruct, + }); + const result0 = getLineAttachmentPatch(shapeComposite, { + update: { + [line.id]: { q: { x: 0, y: 100 } } as Partial, + }, + }); + expect(result0).toEqual({ + [shapeB.id]: { + p: { x: -50, y: -30 }, + }, + }); + }); +}); + +describe("patchByMoveToAttachedPoint", () => { + test("should return shape patch to move to the point", () => { + const shape = createShape(getCommonStruct, "rectangle", { id: "a" }); + const shapeComposite = newShapeComposite({ + shapes: [shape], + getStruct: getCommonStruct, + }); + + const result0 = patchByMoveToAttachedPoint(shapeComposite, shape, { x: 0.5, y: 0.5 }, { x: 100, y: 100 }); + expect(result0?.p).toEqualPoint({ x: 50, y: 50 }); + + const result1 = patchByMoveToAttachedPoint(shapeComposite, shape, { x: 0.2, y: 0.8 }, { x: 100, y: 100 }); + expect(result1?.p).toEqualPoint({ x: 80, y: 20 }); + }); +}); diff --git a/src/composables/lineAttachmentHandler.ts b/src/composables/lineAttachmentHandler.ts new file mode 100644 index 00000000..68c50711 --- /dev/null +++ b/src/composables/lineAttachmentHandler.ts @@ -0,0 +1,87 @@ +import { IVec2 } from "okageo"; +import { EntityPatchInfo, Shape } from "../models"; +import { isLineShape, LineShape } from "../shapes/line"; +import { getLineEdgeInfo } from "../shapes/utils/line"; +import { ShapeComposite } from "./shapeComposite"; +import { AppCanvasStateContext } from "./states/appCanvas/core"; + +export interface LineAttachmentHandler { + onModified(updatedMap: { [id: string]: Partial }): { [id: string]: Partial }; +} + +interface Option { + ctx: Pick; +} + +export function newLineAttachmentHandler(option: Option): LineAttachmentHandler { + function onModified(updatedMap: { [id: string]: Partial }): { [id: string]: Partial } { + const shapeComposite = option.ctx.getShapeComposite(); + const shapeMap = shapeComposite.shapeMap; + const shapeList = Object.values(shapeMap); + const updatedEntries = Object.entries(updatedMap); + const ret: { [id: string]: Partial } = {}; + + const updatedLineIds = new Set(updatedEntries.filter(([id]) => isLineShape(shapeMap[id])).map(([id]) => id)); + const attachedMap = new Map>(); + shapeList.forEach((s) => { + if (!s.attachment) return; + if (!updatedLineIds.has(s.attachment.id)) return; + + const lineId = s.attachment.id; + const idSet = attachedMap.get(lineId); + if (idSet) { + idSet.add(s.id); + } else { + attachedMap.set(lineId, new Set([s.id])); + } + }); + + updatedLineIds.forEach((lineId) => { + const attachedIdSet = attachedMap.get(lineId); + if (!attachedIdSet || attachedIdSet.size === 0) return; + + const nextLine = { ...shapeMap[lineId], ...updatedMap[lineId] } as LineShape; + const nextLineLerpFn = getLineEdgeInfo(nextLine).lerpFn; + attachedIdSet.forEach((attachedId) => { + const attached = shapeMap[attachedId]; + if (!attached.attachment) return; + + const toP = nextLineLerpFn(attached.attachment.to.x); + const patch = patchByMoveToAttachedPoint(shapeComposite, attached, attached.attachment.anchor, toP); + if (!patch) return; + + ret[attachedId] = patch; + }); + }); + + return ret; + } + + return { onModified }; +} + +export function getLineAttachmentPatch( + srcComposite: ShapeComposite, + patchInfo: EntityPatchInfo, +): { [id: string]: Partial } { + if (!patchInfo.update) return {}; + + const handler = newLineAttachmentHandler({ + ctx: { getShapeComposite: () => srcComposite }, + }); + return handler.onModified(patchInfo.update); +} + +export function patchByMoveToAttachedPoint( + shapeComposite: ShapeComposite, + shape: Shape, + anchor: IVec2, + attachedPoint: IVec2, +): Partial | undefined { + const bounds = shapeComposite.getWrapperRect(shape); + const anchorP = { + x: bounds.x + bounds.width * anchor.x, + y: bounds.y + bounds.height * anchor.y, + }; + return shapeComposite.transformShape(shape, [1, 0, 0, 1, attachedPoint.x - anchorP.x, attachedPoint.y - anchorP.y]); +} diff --git a/src/composables/shapeLayoutHandler.ts b/src/composables/shapeLayoutHandler.ts index dea19ad1..a18bae37 100644 --- a/src/composables/shapeLayoutHandler.ts +++ b/src/composables/shapeLayoutHandler.ts @@ -5,6 +5,7 @@ import { getAlignLayoutPatchFunctions } from "./alignHandler"; import { getBoardLayoutPatchFunctions } from "./boardHandler"; import { getConnectedLinePatch } from "./connectedLineHandler"; import { getCurveLinePatch } from "./curveLineHandler"; +import { getLineAttachmentPatch } from "./lineAttachmentHandler"; import { getLineLabelPatch } from "./lineLabelHandler"; import { ShapeComposite, getNextShapeComposite } from "./shapeComposite"; import { getTreeLayoutPatchFunctions } from "./shapeHandlers/treeHandler"; @@ -39,6 +40,7 @@ export function getPatchByLayouts( (_, patch) => getConnectedLinePatch(updatedComposite, { update: patch }), (_, patch) => getCurveLinePatch(updatedComposite, { update: patch }), (_, patch) => getLineLabelPatch(updatedComposite, { update: patch }), + (_, patch) => getLineAttachmentPatch(updatedComposite, { update: patch }), ], shapeComposite.shapeMap, ); @@ -89,6 +91,7 @@ export function getPatchInfoByLayouts( (_, patch) => getConnectedLinePatch(updatedComposite, { update: patch }), (_, patch) => getCurveLinePatch(updatedComposite, { update: patch }), (_, patch) => getLineLabelPatch(updatedComposite, { update: patch }), + (_, patch) => getLineAttachmentPatch(updatedComposite, { update: patch }), ], shapeComposite.shapeMap, ); @@ -117,6 +120,7 @@ export function getPatchAfterLayouts( (_, patch) => getConnectedLinePatch(updatedComposite, { update: patch }), (_, patch) => getCurveLinePatch(updatedComposite, { update: patch }), (_, patch) => getLineLabelPatch(updatedComposite, { update: patch }), + (_, patch) => getLineAttachmentPatch(updatedComposite, { update: patch }), ], shapeComposite.shapeMap, ); diff --git a/src/composables/states/appCanvas/lines/movingOnLineState.ts b/src/composables/states/appCanvas/lines/movingOnLineState.ts index a9c1e584..408dd78d 100644 --- a/src/composables/states/appCanvas/lines/movingOnLineState.ts +++ b/src/composables/states/appCanvas/lines/movingOnLineState.ts @@ -5,6 +5,8 @@ import { isLineShape, LineShape } from "../../../../shapes/line"; import { getClosestOutlineInfoOfLine, getLineEdgeInfo } from "../../../../shapes/utils/line"; import { TAU } from "../../../../utils/geometry"; import { IVec2, lerpPoint } from "okageo"; +import { patchByMoveToAttachedPoint } from "../../../lineAttachmentHandler"; +import { ShapeAttachment } from "../../../../models"; type Option = { lineId: string; @@ -49,7 +51,6 @@ export function newMovingOnLineState(option: Option): AppCanvasState { return { type: "break" }; } - const anchor = { x: 0.5, y: 0.5 }; const closestInfo = getClosestOutlineInfoOfLine(line, p, 40 * ctx.getScale()); if (!closestInfo) { keepMoving = true; @@ -79,19 +80,17 @@ export function newMovingOnLineState(option: Option): AppCanvasState { [ (src) => { return mapReduce(src, (s) => { - const bounds = shapeComposite.getWrapperRect(s); - const anchorP = { x: bounds.x + bounds.width * anchor.x, y: bounds.y + bounds.height * anchor.y }; - const to = attachInfoMap.get(s.id)![0]; - const toP = attachInfoMap.get(s.id)![1]; + const info = attachInfoMap.get(s.id)!; + const attachment: ShapeAttachment = { + id: line.id, + to: info[0], + anchor: { x: 0.5, y: 0.5 }, + rotationType: "relative", + rotation: 0, + }; return { - ...shapeComposite.transformShape(s, [1, 0, 0, 1, toP.x - anchorP.x, toP.y - anchorP.y]), - attachment: { - id: line.id, - to, - anchor: { x: 0.5, y: 0 }, - rotationType: "relative", - rotation: 0, - } as const, + ...patchByMoveToAttachedPoint(shapeComposite, s, attachment.anchor, info[1]), + attachment, }; }); }, From 8a173c088f82a378e3a4de00359c62fe39369074 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 12:58:53 +0900 Subject: [PATCH 08/67] feat: Adjust attached shapes on modified --- src/composables/lineAttachmentHandler.spec.ts | 13 ++++++++++++ src/composables/lineAttachmentHandler.ts | 20 +++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/composables/lineAttachmentHandler.spec.ts b/src/composables/lineAttachmentHandler.spec.ts index cf1dc1b2..96768ca1 100644 --- a/src/composables/lineAttachmentHandler.spec.ts +++ b/src/composables/lineAttachmentHandler.spec.ts @@ -3,6 +3,7 @@ import { getLineAttachmentPatch, patchByMoveToAttachedPoint } from "./lineAttach import { newShapeComposite } from "./shapeComposite"; import { createShape, getCommonStruct } from "../shapes"; import { LineShape } from "../shapes/line"; +import { RectangleShape } from "../shapes/rectangle"; describe("getLineAttachmentPatch", () => { test("should return shape patch to move shapes to the attached points", () => { @@ -22,6 +23,7 @@ describe("getLineAttachmentPatch", () => { shapes: [line, shapeA, shapeB], getStruct: getCommonStruct, }); + const result0 = getLineAttachmentPatch(shapeComposite, { update: { [line.id]: { q: { x: 0, y: 100 } } as Partial, @@ -32,6 +34,17 @@ describe("getLineAttachmentPatch", () => { p: { x: -50, y: -30 }, }, }); + + const result1 = getLineAttachmentPatch(shapeComposite, { + update: { + [shapeB.id]: { width: 200 } as Partial, + }, + }); + expect(result1).toEqual({ + [shapeB.id]: { + p: { x: -80, y: -50 }, + }, + }); }); }); diff --git a/src/composables/lineAttachmentHandler.ts b/src/composables/lineAttachmentHandler.ts index 68c50711..51020e57 100644 --- a/src/composables/lineAttachmentHandler.ts +++ b/src/composables/lineAttachmentHandler.ts @@ -13,7 +13,7 @@ interface Option { ctx: Pick; } -export function newLineAttachmentHandler(option: Option): LineAttachmentHandler { +function newLineAttachmentHandler(option: Option): LineAttachmentHandler { function onModified(updatedMap: { [id: string]: Partial }): { [id: string]: Partial } { const shapeComposite = option.ctx.getShapeComposite(); const shapeMap = shapeComposite.shapeMap; @@ -21,11 +21,15 @@ export function newLineAttachmentHandler(option: Option): LineAttachmentHandler const updatedEntries = Object.entries(updatedMap); const ret: { [id: string]: Partial } = {}; - const updatedLineIds = new Set(updatedEntries.filter(([id]) => isLineShape(shapeMap[id])).map(([id]) => id)); + const targetLineIds = new Set(updatedEntries.filter(([id]) => isLineShape(shapeMap[id])).map(([id]) => id)); const attachedMap = new Map>(); shapeList.forEach((s) => { if (!s.attachment) return; - if (!updatedLineIds.has(s.attachment.id)) return; + if (!targetLineIds.has(s.attachment.id)) { + if (!updatedMap[s.id]) return; // neither the shape nor the line changes + + targetLineIds.add(s.attachment.id); + } const lineId = s.attachment.id; const idSet = attachedMap.get(lineId); @@ -36,18 +40,18 @@ export function newLineAttachmentHandler(option: Option): LineAttachmentHandler } }); - updatedLineIds.forEach((lineId) => { + targetLineIds.forEach((lineId) => { const attachedIdSet = attachedMap.get(lineId); if (!attachedIdSet || attachedIdSet.size === 0) return; const nextLine = { ...shapeMap[lineId], ...updatedMap[lineId] } as LineShape; const nextLineLerpFn = getLineEdgeInfo(nextLine).lerpFn; attachedIdSet.forEach((attachedId) => { - const attached = shapeMap[attachedId]; - if (!attached.attachment) return; + const nextAttached = { ...shapeMap[attachedId], ...updatedMap[attachedId] }; + if (!nextAttached.attachment) return; - const toP = nextLineLerpFn(attached.attachment.to.x); - const patch = patchByMoveToAttachedPoint(shapeComposite, attached, attached.attachment.anchor, toP); + const toP = nextLineLerpFn(nextAttached.attachment.to.x); + const patch = patchByMoveToAttachedPoint(shapeComposite, nextAttached, nextAttached.attachment.anchor, toP); if (!patch) return; ret[attachedId] = patch; From 0ed4bd247d0d984d2649ab13721e4d8956df5a02 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 13:09:59 +0900 Subject: [PATCH 09/67] refactor: Avoid importing MovingHub state directly - It likely causes circular reference --- .../states/appCanvas/align/alignBoxSelectedState.ts | 3 +-- .../states/appCanvas/board/boardEntitySelectedState.ts | 3 +-- src/composables/states/appCanvas/index.ts | 2 ++ src/composables/states/appCanvas/lines/lineSelectedState.ts | 3 +-- src/composables/states/appCanvas/multipleSelectedState.ts | 5 ++--- .../states/appCanvas/singleSelectedByPointerOnState.ts | 3 +-- .../states/appCanvas/singleSelectedHandlerState.ts | 3 +-- .../states/appCanvas/tree/treeRootSelectedState.ts | 3 +-- 8 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/composables/states/appCanvas/align/alignBoxSelectedState.ts b/src/composables/states/appCanvas/align/alignBoxSelectedState.ts index 28d94aa6..9ed42004 100644 --- a/src/composables/states/appCanvas/align/alignBoxSelectedState.ts +++ b/src/composables/states/appCanvas/align/alignBoxSelectedState.ts @@ -18,7 +18,6 @@ import { newAlignBoxPaddingState } from "./alignBoxPaddingState"; import { newAlignBoxGapState } from "./alignBoxGapState"; import { defineIntransientState } from "../intransientState"; import { newPointerDownEmptyState } from "../pointerDownEmptyState"; -import { newMovingHubState } from "../movingHubState"; export const newAlignBoxSelectedState = defineIntransientState(() => { let targetId: string; @@ -139,7 +138,7 @@ export const newAlignBoxSelectedState = defineIntransientState(() => { case "rotation": return () => newRotatingState({ boundingBox }); case "move": - return () => newMovingHubState({ boundingBox }); + return () => ctx.states.newMovingHubState({ boundingBox }); } } diff --git a/src/composables/states/appCanvas/board/boardEntitySelectedState.ts b/src/composables/states/appCanvas/board/boardEntitySelectedState.ts index 64cd1d45..cf750e36 100644 --- a/src/composables/states/appCanvas/board/boardEntitySelectedState.ts +++ b/src/composables/states/appCanvas/board/boardEntitySelectedState.ts @@ -21,7 +21,6 @@ import { getPatchByLayouts } from "../../../shapeLayoutHandler"; import { defineIntransientState } from "../intransientState"; import { newPointerDownEmptyState } from "../pointerDownEmptyState"; import { newRotatingState } from "../rotatingState"; -import { newMovingHubState } from "../movingHubState"; /** * General selected state for any board entity @@ -153,7 +152,7 @@ export const newBoardEntitySelectedState = defineIntransientState(() => { case "rotation": return () => newRotatingState({ boundingBox }); case "move": - return () => newMovingHubState({ boundingBox }); + return () => ctx.states.newMovingHubState({ boundingBox }); } } diff --git a/src/composables/states/appCanvas/index.ts b/src/composables/states/appCanvas/index.ts index 03541153..95d05ab3 100644 --- a/src/composables/states/appCanvas/index.ts +++ b/src/composables/states/appCanvas/index.ts @@ -1,7 +1,9 @@ +import { newMovingHubState } from "./movingHubState"; import { newSelectionHubState } from "./selectionHubState"; // TODO: Should hoist all states here to avoid circular dependencies. export const stateGenerators = { newSelectionHubState, + newMovingHubState, }; export type StateGenerators = typeof stateGenerators; diff --git a/src/composables/states/appCanvas/lines/lineSelectedState.ts b/src/composables/states/appCanvas/lines/lineSelectedState.ts index 2afaedf3..3bd66d67 100644 --- a/src/composables/states/appCanvas/lines/lineSelectedState.ts +++ b/src/composables/states/appCanvas/lines/lineSelectedState.ts @@ -22,7 +22,6 @@ import { TextShape, patchPosition } from "../../../../shapes/text"; import { newTextEditingState } from "../text/textEditingState"; import { COMMAND_EXAM_SRC } from "../commandExams"; import { CONTEXT_MENU_ITEM_SRC, getMenuItemsForSelectedShapes } from "../contextMenuItems"; -import { newMovingHubState } from "../movingHubState"; import { getPatchAfterLayouts, getPatchByLayouts } from "../../../shapeLayoutHandler"; import { newMovingLineSegmentState } from "./movingLineSegmentState"; import { newMovingLineArcState } from "./movingLineArcState"; @@ -89,7 +88,7 @@ export const newLineSelectedState = defineIntransientState(() => { return ctx.states.newSelectionHubState; } case "move-anchor": - return newMovingHubState; + return ctx.states.newMovingHubState; case "rotate-anchor": { const shapeComposite = ctx.getShapeComposite(); const rectPath = shapeComposite.getLocalRectPolygon(lineShape); diff --git a/src/composables/states/appCanvas/multipleSelectedState.ts b/src/composables/states/appCanvas/multipleSelectedState.ts index ec0d0021..96b83964 100644 --- a/src/composables/states/appCanvas/multipleSelectedState.ts +++ b/src/composables/states/appCanvas/multipleSelectedState.ts @@ -8,7 +8,6 @@ import { newRotatingState } from "./rotatingState"; import { newDuplicatingShapesState } from "./duplicatingShapesState"; import { CONTEXT_MENU_ITEM_SRC, getMenuItemsForSelectedShapes } from "./contextMenuItems"; import { canGroupShapes, findBetterShapeAt, getRotatedTargetBounds } from "../../shapeComposite"; -import { newMovingHubState } from "./movingHubState"; import { ShapeSelectionScope, isSameShapeSelectionScope } from "../../../shapes/core"; import { defineIntransientState } from "./intransientState"; import { newPointerDownEmptyState } from "./pointerDownEmptyState"; @@ -108,7 +107,7 @@ export const newMultipleSelectedState = defineIntransientState((option?: Option) case "rotation": return () => newRotatingState({ boundingBox }); case "move": - return () => newMovingHubState({ boundingBox }); + return () => ctx.states.newMovingHubState({ boundingBox }); } } @@ -134,7 +133,7 @@ export const newMultipleSelectedState = defineIntransientState((option?: Option) if (event.data.options.alt) { return newDuplicatingShapesState; } else { - return () => newMovingHubState({ boundingBox }); + return () => ctx.states.newMovingHubState({ boundingBox }); } } else { ctx.selectShape(shape.id, false); diff --git a/src/composables/states/appCanvas/singleSelectedByPointerOnState.ts b/src/composables/states/appCanvas/singleSelectedByPointerOnState.ts index 4249572c..c167ddd9 100644 --- a/src/composables/states/appCanvas/singleSelectedByPointerOnState.ts +++ b/src/composables/states/appCanvas/singleSelectedByPointerOnState.ts @@ -1,6 +1,5 @@ import type { AppCanvasState } from "./core"; import { getDistance } from "okageo"; -import { newMovingHubState } from "./movingHubState"; import { startTextEditingIfPossible } from "./commons"; interface Option { @@ -33,7 +32,7 @@ export function newSingleSelectedByPointerOnState(option?: Option): AppCanvasSta return ctx.states.newSelectionHubState; } - return newMovingHubState; + return ctx.states.newMovingHubState; } case "pointerup": { if (option?.concurrent && Date.now() - timestamp < 200) { diff --git a/src/composables/states/appCanvas/singleSelectedHandlerState.ts b/src/composables/states/appCanvas/singleSelectedHandlerState.ts index 3822341e..9ed9f010 100644 --- a/src/composables/states/appCanvas/singleSelectedHandlerState.ts +++ b/src/composables/states/appCanvas/singleSelectedHandlerState.ts @@ -14,7 +14,6 @@ import { newPointerDownEmptyState } from "./pointerDownEmptyState"; import { AppCanvasState, AppCanvasStateContext } from "./core"; import { Shape } from "../../../models"; import { newDummyHandler, ShapeHandler } from "../../shapeHandlers/core"; -import { newMovingHubState } from "./movingHubState"; import { newSmartBranchHandler, SmartBranchHandler } from "../../smartBranchHandler"; import { canAttachSmartBranch } from "../../../shapes"; @@ -100,7 +99,7 @@ export function defineSingleSelectedHandlerState newRotatingState({ boundingBox }); case "move": - return () => newMovingHubState({ boundingBox }); + return () => ctx.states.newMovingHubState({ boundingBox }); } } diff --git a/src/composables/states/appCanvas/tree/treeRootSelectedState.ts b/src/composables/states/appCanvas/tree/treeRootSelectedState.ts index 1837d1f8..d144c9d3 100644 --- a/src/composables/states/appCanvas/tree/treeRootSelectedState.ts +++ b/src/composables/states/appCanvas/tree/treeRootSelectedState.ts @@ -13,7 +13,6 @@ import { applyStrokeStyle } from "../../../../utils/strokeStyle"; import { BoundingBox, newBoundingBox } from "../../../boundingBox"; import { newResizingState } from "../resizingState"; import { newRotatingState } from "../rotatingState"; -import { newMovingHubState } from "../movingHubState"; import { defineIntransientState } from "../intransientState"; import { newPointerDownEmptyState } from "../pointerDownEmptyState"; import { getPatchByLayouts } from "../../../shapeLayoutHandler"; @@ -167,7 +166,7 @@ export const newTreeRootSelectedState = defineIntransientState(() => { case "rotation": return () => newRotatingState({ boundingBox }); case "move": - return () => newMovingHubState({ boundingBox }); + return () => ctx.states.newMovingHubState({ boundingBox }); } } From 8d5996e79bc9258b56c5d7dd10e7612f07bdc1d3 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 19:46:51 +0900 Subject: [PATCH 10/67] feat: Move a shape on the line based on the anchor point rather than the cursor position --- src/composables/lineAttachmentHandler.spec.ts | 31 ++++++++++++++++++- src/composables/lineAttachmentHandler.ts | 9 ++++++ .../appCanvas/lines/movingOnLineState.ts | 18 +++++++---- .../appCanvas/movingShapeOnLineHandler.ts | 13 ++++++-- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/composables/lineAttachmentHandler.spec.ts b/src/composables/lineAttachmentHandler.spec.ts index 96768ca1..a6d82500 100644 --- a/src/composables/lineAttachmentHandler.spec.ts +++ b/src/composables/lineAttachmentHandler.spec.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { getLineAttachmentPatch, patchByMoveToAttachedPoint } from "./lineAttachmentHandler"; +import { getAttachmentAnchorPoint, getLineAttachmentPatch, patchByMoveToAttachedPoint } from "./lineAttachmentHandler"; import { newShapeComposite } from "./shapeComposite"; import { createShape, getCommonStruct } from "../shapes"; import { LineShape } from "../shapes/line"; @@ -63,3 +63,32 @@ describe("patchByMoveToAttachedPoint", () => { expect(result1?.p).toEqualPoint({ x: 80, y: 20 }); }); }); + +describe("getAttachmentAnchorPoint", () => { + test("should return initial anchor point when the shape doesn't have attachment", () => { + const shape = createShape(getCommonStruct, "rectangle", { id: "a" }); + const shapeComposite = newShapeComposite({ + shapes: [shape], + getStruct: getCommonStruct, + }); + expect(getAttachmentAnchorPoint(shapeComposite, shape)).toEqualPoint({ x: 50, y: 50 }); + }); + + test("should return anchor point when the shape have attachment", () => { + const shape = createShape(getCommonStruct, "rectangle", { + id: "a", + attachment: { + id: "line", + to: { x: 0.2, y: 0 }, + anchor: { x: 0.3, y: 0.6 }, + rotationType: "relative", + rotation: 0, + }, + }); + const shapeComposite = newShapeComposite({ + shapes: [shape], + getStruct: getCommonStruct, + }); + expect(getAttachmentAnchorPoint(shapeComposite, shape)).toEqualPoint({ x: 30, y: 60 }); + }); +}); diff --git a/src/composables/lineAttachmentHandler.ts b/src/composables/lineAttachmentHandler.ts index 51020e57..344eb84f 100644 --- a/src/composables/lineAttachmentHandler.ts +++ b/src/composables/lineAttachmentHandler.ts @@ -89,3 +89,12 @@ export function patchByMoveToAttachedPoint( }; return shapeComposite.transformShape(shape, [1, 0, 0, 1, attachedPoint.x - anchorP.x, attachedPoint.y - anchorP.y]); } + +export function getAttachmentAnchorPoint(shapeComposite: ShapeComposite, shape: Shape): IVec2 { + const bounds = shapeComposite.getWrapperRect(shape); + const anchor = shape.attachment?.anchor ?? { x: 0.5, y: 0.5 }; + return { + x: bounds.x + bounds.width * anchor.x, + y: bounds.y + bounds.height * anchor.y, + }; +} diff --git a/src/composables/states/appCanvas/lines/movingOnLineState.ts b/src/composables/states/appCanvas/lines/movingOnLineState.ts index 408dd78d..ae50a1ad 100644 --- a/src/composables/states/appCanvas/lines/movingOnLineState.ts +++ b/src/composables/states/appCanvas/lines/movingOnLineState.ts @@ -4,8 +4,8 @@ import { mapReduce, patchPipe, toList } from "../../../../utils/commons"; import { isLineShape, LineShape } from "../../../../shapes/line"; import { getClosestOutlineInfoOfLine, getLineEdgeInfo } from "../../../../shapes/utils/line"; import { TAU } from "../../../../utils/geometry"; -import { IVec2, lerpPoint } from "okageo"; -import { patchByMoveToAttachedPoint } from "../../../lineAttachmentHandler"; +import { add, IVec2, lerpPoint, sub } from "okageo"; +import { getAttachmentAnchorPoint, patchByMoveToAttachedPoint } from "../../../lineAttachmentHandler"; import { ShapeAttachment } from "../../../../models"; type Option = { @@ -42,7 +42,6 @@ export function newMovingOnLineState(option: Option): AppCanvasState { return { type: "break" }; } - const p = event.data.current; const shapeComposite = ctx.getShapeComposite(); const shapeMap = shapeComposite.shapeMap; const line = shapeMap[option.lineId]; @@ -51,7 +50,13 @@ export function newMovingOnLineState(option: Option): AppCanvasState { return { type: "break" }; } - const closestInfo = getClosestOutlineInfoOfLine(line, p, 40 * ctx.getScale()); + const indexShape = shapeMap[option.shapeId]; + const indexAnchorP = getAttachmentAnchorPoint(shapeComposite, indexShape); + + const diff = sub(event.data.current, event.data.start); + const movedIndexAnchorP = add(indexAnchorP, diff); + + const closestInfo = getClosestOutlineInfoOfLine(line, movedIndexAnchorP, 40 * ctx.getScale()); if (!closestInfo) { keepMoving = true; return { type: "break" }; @@ -82,11 +87,12 @@ export function newMovingOnLineState(option: Option): AppCanvasState { return mapReduce(src, (s) => { const info = attachInfoMap.get(s.id)!; const attachment: ShapeAttachment = { - id: line.id, - to: info[0], anchor: { x: 0.5, y: 0.5 }, rotationType: "relative", rotation: 0, + ...s.attachment, + id: line.id, + to: info[0], }; return { ...patchByMoveToAttachedPoint(shapeComposite, s, attachment.anchor, info[1]), diff --git a/src/composables/states/appCanvas/movingShapeOnLineHandler.ts b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts index bd930c8d..d33f696f 100644 --- a/src/composables/states/appCanvas/movingShapeOnLineHandler.ts +++ b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts @@ -1,4 +1,6 @@ +import { add, sub } from "okageo"; import { isLineShape } from "../../../shapes/line"; +import { getAttachmentAnchorPoint } from "../../lineAttachmentHandler"; import { PointerMoveEvent, TransitionValue } from "../core"; import { AppCanvasStateContext } from "./core"; import { newMovingOnLineState } from "./lines/movingOnLineState"; @@ -13,12 +15,17 @@ export function handlePointerMoveOnLine( const shapeComposite = ctx.getShapeComposite(); const scope = shapeComposite.getSelectionScope(shapeComposite.shapeMap[movingIds[0]]); - const targetLine = shapeComposite.findShapeAt(event.data.current, scope, movingIds, false, ctx.getScale()); - if (!targetLine || !isLineShape(targetLine)) return; - const movingShape = shapeComposite.findShapeAt(event.data.current, scope, [targetLine.id], false, ctx.getScale()); + const movingShape = shapeComposite.findShapeAt(event.data.current, scope, [], false, ctx.getScale()); if (!movingShape) return; + const anchorP = getAttachmentAnchorPoint(shapeComposite, movingShape); + const diff = sub(event.data.current, event.data.start); + const movedAnchorP = add(anchorP, diff); + + const targetLine = shapeComposite.findShapeAt(movedAnchorP, scope, movingIds, false, ctx.getScale()); + if (!targetLine || !isLineShape(targetLine)) return; + return { type: "stack-resume", getState: () => newMovingOnLineState({ lineId: targetLine.id, shapeId: movingShape.id }), From c84c6e05c459875fa7e293f45a550b889b468fb8 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 19:56:41 +0900 Subject: [PATCH 11/67] feat: Clear attachment property when shapes move via MovingShape state --- .../states/appCanvas/movingShapeState.spec.ts | 34 +++++++++++++++++++ .../states/appCanvas/movingShapeState.ts | 5 ++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/composables/states/appCanvas/movingShapeState.spec.ts b/src/composables/states/appCanvas/movingShapeState.spec.ts index 832e67a9..6883a10f 100644 --- a/src/composables/states/appCanvas/movingShapeState.spec.ts +++ b/src/composables/states/appCanvas/movingShapeState.spec.ts @@ -72,6 +72,40 @@ describe("newMovingShapeState", () => { expect(ctx.setTmpShapeMap).toHaveBeenNthCalledWith(1, { a: { p: { x: 10, y: 0 } } }); expect(result).toBe(undefined); }); + + test("should clear attachment when exists", () => { + const ctx = getMockCtx(); + ctx.getShapeComposite = () => + newShapeComposite({ + shapes: [ + createShape(getCommonStruct, "rectangle", { + id: "a", + width: 50, + height: 50, + attachment: { + id: "line", + to: { x: 0.5, y: 0 }, + anchor: { x: 0.5, y: 0.5 }, + rotationType: "relative", + rotation: 0, + }, + }), + ], + getStruct: getCommonStruct, + }); + let tmpMap: any = {}; + ctx.setTmpShapeMap.mockImplementation((v) => { + tmpMap = v; + }); + const target = newMovingShapeState(); + target.onStart?.(ctx as any); + target.handleEvent(ctx as any, { + type: "pointermove", + data: { start: { x: 0, y: 0 }, current: { x: 10, y: 0 }, scale: 1 }, + }); + expect(ctx.setTmpShapeMap).toHaveBeenNthCalledWith(1, { a: { p: { x: 10, y: 0 } } }); + expect(tmpMap["a"]).toHaveProperty("attachment"); + }); }); describe("handle pointerup", () => { diff --git a/src/composables/states/appCanvas/movingShapeState.ts b/src/composables/states/appCanvas/movingShapeState.ts index 4ad89464..fcf24feb 100644 --- a/src/composables/states/appCanvas/movingShapeState.ts +++ b/src/composables/states/appCanvas/movingShapeState.ts @@ -115,7 +115,10 @@ export function newMovingShapeState(option?: Option): AppCanvasState { const shapeMap = ctx.getShapeComposite().shapeMap; let patchMap = targetIds.reduce<{ [id: string]: Partial }>((m, id) => { const s = shapeMap[id]; - if (s) m[id] = shapeComposite.transformShape(s, affine); + if (s) { + m[id] = shapeComposite.transformShape(s, affine); + if (s.attachment) m[id].attachment = undefined; + } return m; }, {}); From e8cc27d52273f445b1e368c0e7f0842b774c0218 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 19:58:23 +0900 Subject: [PATCH 12/67] fix: Find the index shape based on the start position of the moving cursor - Relying on the latest moving cursor can cause unexpected change of the index shape --- .../states/appCanvas/movingShapeOnLineHandler.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/composables/states/appCanvas/movingShapeOnLineHandler.ts b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts index d33f696f..50028fa4 100644 --- a/src/composables/states/appCanvas/movingShapeOnLineHandler.ts +++ b/src/composables/states/appCanvas/movingShapeOnLineHandler.ts @@ -4,6 +4,7 @@ import { getAttachmentAnchorPoint } from "../../lineAttachmentHandler"; import { PointerMoveEvent, TransitionValue } from "../core"; import { AppCanvasStateContext } from "./core"; import { newMovingOnLineState } from "./lines/movingOnLineState"; +import { newShapeComposite } from "../../shapeComposite"; export function handlePointerMoveOnLine( ctx: AppCanvasStateContext, @@ -14,16 +15,20 @@ export function handlePointerMoveOnLine( if (movingIds.length === 0) return; const shapeComposite = ctx.getShapeComposite(); - const scope = shapeComposite.getSelectionScope(shapeComposite.shapeMap[movingIds[0]]); + const movingIdSet = new Set(movingIds); + const subShapeComposite = newShapeComposite({ + shapes: shapeComposite.shapes.filter((s) => movingIdSet.has(s.id)), + getStruct: shapeComposite.getShapeStruct, + }); - const movingShape = shapeComposite.findShapeAt(event.data.current, scope, [], false, ctx.getScale()); + const movingShape = subShapeComposite.findShapeAt(event.data.start, undefined, [], false, ctx.getScale()); if (!movingShape) return; - const anchorP = getAttachmentAnchorPoint(shapeComposite, movingShape); + const anchorP = getAttachmentAnchorPoint(subShapeComposite, movingShape); const diff = sub(event.data.current, event.data.start); const movedAnchorP = add(anchorP, diff); - const targetLine = shapeComposite.findShapeAt(movedAnchorP, scope, movingIds, false, ctx.getScale()); + const targetLine = shapeComposite.findShapeAt(movedAnchorP, undefined, movingIds, false, ctx.getScale()); if (!targetLine || !isLineShape(targetLine)) return; return { From 4c7474d9a4b8d3b194ec2550117fafc453840564 Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 20:02:09 +0900 Subject: [PATCH 13/67] feat: Highlight the line when shapes move on it --- .../appCanvas/lines/movingOnLineState.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/composables/states/appCanvas/lines/movingOnLineState.ts b/src/composables/states/appCanvas/lines/movingOnLineState.ts index ae50a1ad..f8033ead 100644 --- a/src/composables/states/appCanvas/lines/movingOnLineState.ts +++ b/src/composables/states/appCanvas/lines/movingOnLineState.ts @@ -1,12 +1,14 @@ import type { AppCanvasState } from "../core"; import { applyFillStyle } from "../../../../utils/fillStyle"; import { mapReduce, patchPipe, toList } from "../../../../utils/commons"; -import { isLineShape, LineShape } from "../../../../shapes/line"; +import { getLinePath, isLineShape, LineShape } from "../../../../shapes/line"; import { getClosestOutlineInfoOfLine, getLineEdgeInfo } from "../../../../shapes/utils/line"; import { TAU } from "../../../../utils/geometry"; import { add, IVec2, lerpPoint, sub } from "okageo"; import { getAttachmentAnchorPoint, patchByMoveToAttachedPoint } from "../../../lineAttachmentHandler"; import { ShapeAttachment } from "../../../../models"; +import { applyCurvePath } from "../../../../utils/renderer"; +import { applyStrokeStyle } from "../../../../utils/strokeStyle"; type Option = { lineId: string; @@ -16,6 +18,7 @@ type Option = { export function newMovingOnLineState(option: Option): AppCanvasState { let keepMoving = false; let lineAnchor: IVec2 | undefined; + let line: LineShape; let lineLerpFn: (t: number) => IVec2; return { @@ -24,7 +27,12 @@ export function newMovingOnLineState(option: Option): AppCanvasState { // ctx.setTmpShapeMap({}); const shapeComposite = ctx.getShapeComposite(); const shapeMap = shapeComposite.shapeMap; - const line = shapeMap[option.lineId] as LineShape; + line = shapeMap[option.lineId] as LineShape; + if (!isLineShape(line)) { + keepMoving = true; + return { type: "break" }; + } + lineLerpFn = getLineEdgeInfo(line).lerpFn; }, onEnd: (ctx) => { @@ -44,11 +52,6 @@ export function newMovingOnLineState(option: Option): AppCanvasState { const shapeComposite = ctx.getShapeComposite(); const shapeMap = shapeComposite.shapeMap; - const line = shapeMap[option.lineId]; - if (!isLineShape(line)) { - keepMoving = true; - return { type: "break" }; - } const indexShape = shapeMap[option.shapeId]; const indexAnchorP = getAttachmentAnchorPoint(shapeComposite, indexShape); @@ -125,9 +128,14 @@ export function newMovingOnLineState(option: Option): AppCanvasState { const scale = ctx.getScale(); if (lineAnchor) { - applyFillStyle(renderCtx, { color: style.selectionPrimary }); + applyStrokeStyle(renderCtx, { color: style.selectionPrimary, width: 2 * scale }); + renderCtx.beginPath(); + applyCurvePath(renderCtx, getLinePath(line), line.curves); + renderCtx.stroke(); + + applyFillStyle(renderCtx, { color: style.selectionSecondaly }); renderCtx.beginPath(); - renderCtx.arc(lineAnchor.x, lineAnchor.y, 10 * scale, 0, TAU); + renderCtx.arc(lineAnchor.x, lineAnchor.y, 6 * scale, 0, TAU); renderCtx.fill(); } }, From 59740bf9b9877e725522a2e4a9b7632b8dd5800c Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Fri, 1 Nov 2024 21:17:26 +0900 Subject: [PATCH 14/67] feat: Add new field for attachment properties to the inspector panel --- .../AttachmentInspector.tsx | 32 +++++++++++++++++++ .../ShapeInspectorPanel.tsx | 26 +++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/components/shapeInspectorPanel/AttachmentInspector.tsx diff --git a/src/components/shapeInspectorPanel/AttachmentInspector.tsx b/src/components/shapeInspectorPanel/AttachmentInspector.tsx new file mode 100644 index 00000000..0af7bb24 --- /dev/null +++ b/src/components/shapeInspectorPanel/AttachmentInspector.tsx @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { BlockGroupField } from "../atoms/BlockGroupField"; +import { InlineField } from "../atoms/InlineField"; +import { ToggleInput } from "../atoms/inputs/ToggleInput"; +import { Shape } from "../../models"; + +interface Props { + targetShape: Shape; + updateTargetShape: (patch: Partial) => void; +} + +export const AttachmentInspector: React.FC = ({ targetShape, updateTargetShape }) => { + const attachment = targetShape.attachment; + + const handleRelativeRotationChange = useCallback( + (val: boolean) => { + if (!attachment) return; + updateTargetShape({ attachment: { ...attachment, rotationType: val ? "relative" : "absolute" } }); + }, + [attachment, updateTargetShape], + ); + + if (!attachment) return; + + return ( + + + + + + ); +}; diff --git a/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx b/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx index c1663134..54185653 100644 --- a/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx +++ b/src/components/shapeInspectorPanel/ShapeInspectorPanel.tsx @@ -14,6 +14,7 @@ import { GroupShape, isGroupShape } from "../../shapes/group"; import { ClipInspector } from "./ClipInspector"; import { AlphaField } from "./AlphaField"; import { HighlightShapeMeta } from "../../composables/states/appCanvas/core"; +import { AttachmentInspector } from "./AttachmentInspector"; export const ShapeInspectorPanel: React.FC = () => { const targetShape = useSelectedShape(); @@ -147,6 +148,30 @@ const ShapeInspectorPanelWithShape: React.FC [targetShapes, getShapeComposite, patchShapes], ); + // Only shapes already having "attachment" will be updated. + const updateAttachmentBySamePatch = useCallback( + (patch: Partial, draft = false) => { + const shapeComposite = getShapeComposite(); + + const layoutPatch = getPatchByLayouts(shapeComposite, { + update: targetShapes.reduce<{ [id: string]: Partial }>((p, s) => { + if (s.attachment) { + p[s.id] = patch; + } + return p; + }, {}), + }); + + if (draft) { + setTmpShapeMap(layoutPatch); + } else { + setTmpShapeMap({}); + patchShapes(layoutPatch); + } + }, + [targetShapes, getShapeComposite, patchShapes, setTmpShapeMap], + ); + const alphaField = ; return ( @@ -194,6 +219,7 @@ const ShapeInspectorPanelWithShape: React.FC updateTargetGroupShape={updateGroupShapesBySamePatch} /> ) : undefined} +