From a373563be1b69b6e382456cf7b371be13530e587 Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:22:21 +0900 Subject: [PATCH 1/5] fix: center dropped image and svg insertions --- .../__tests__/data-transfer-position.test.ts | 24 +++++++ .../data-transfer-position.ts | 22 ++++++ .../grida-canvas-react/use-data-transfer.ts | 70 +++++++++++-------- 3 files changed, 87 insertions(+), 29 deletions(-) create mode 100644 editor/grida-canvas-react/__tests__/data-transfer-position.test.ts create mode 100644 editor/grida-canvas-react/data-transfer-position.ts diff --git a/editor/grida-canvas-react/__tests__/data-transfer-position.test.ts b/editor/grida-canvas-react/__tests__/data-transfer-position.test.ts new file mode 100644 index 000000000..bc04555ba --- /dev/null +++ b/editor/grida-canvas-react/__tests__/data-transfer-position.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { getCenteredCanvasInsertionPoint } from "../data-transfer-position"; + +describe("getCenteredCanvasInsertionPoint", () => { + test("subtracts known canvas-space size after client-to-canvas conversion", () => { + const point = getCenteredCanvasInsertionPoint({ + clientPosition: [100, 120], + size: { width: 40, height: 20 }, + clientPointToCanvasPoint: ([x, y]) => [x / 2, y / 2], + }); + + expect(point).toEqual([30, 50]); + }); + + test("does not offset dimensions that cannot be measured", () => { + const point = getCenteredCanvasInsertionPoint({ + clientPosition: [100, 120], + size: { width: 0, height: Number.NaN }, + clientPointToCanvasPoint: ([x, y]) => [x / 2, y / 2], + }); + + expect(point).toEqual([50, 60]); + }); +}); diff --git a/editor/grida-canvas-react/data-transfer-position.ts b/editor/grida-canvas-react/data-transfer-position.ts new file mode 100644 index 000000000..680321f88 --- /dev/null +++ b/editor/grida-canvas-react/data-transfer-position.ts @@ -0,0 +1,22 @@ +type Vector2 = [number, number]; + +type Size = { + width: number; + height: number; +}; + +export function getCenteredCanvasInsertionPoint(args: { + clientPosition: Vector2; + size: Size; + clientPointToCanvasPoint: (point: Vector2) => Vector2; +}): Vector2 { + const [x, y] = args.clientPointToCanvasPoint(args.clientPosition); + return [ + x - getPositiveHalf(args.size.width), + y - getPositiveHalf(args.size.height), + ]; +} + +function getPositiveHalf(value: number): number { + return Number.isFinite(value) && value > 0 ? value / 2 : 0; +} diff --git a/editor/grida-canvas-react/use-data-transfer.ts b/editor/grida-canvas-react/use-data-transfer.ts index cefbcbb1f..670845df4 100644 --- a/editor/grida-canvas-react/use-data-transfer.ts +++ b/editor/grida-canvas-react/use-data-transfer.ts @@ -13,6 +13,7 @@ import { iofigma } from "@grida/io-figma"; import { nanoid } from "nanoid"; import { datatransfer } from "@/grida-canvas/data-transfer"; import type { editor } from "@/grida-canvas"; +import { getCenteredCanvasInsertionPoint } from "./data-transfer-position"; /** * Hook that provides file insertion utilities for the Grida canvas editor. @@ -54,12 +55,17 @@ export function useInsertFile() { clientY: number; } ) => { - const [x, y] = instance.camera.clientPointToCanvasPoint( - position ? [position.clientX, position.clientY] : [0, 0] - ); - const bytes = await file.arrayBuffer(); const image = await instance.createImage(new Uint8Array(bytes)); + const [x, y] = getCenteredCanvasInsertionPoint({ + clientPosition: position + ? [position.clientX, position.clientY] + : [0, 0], + size: image, + clientPointToCanvasPoint: instance.camera.clientPointToCanvasPoint.bind( + instance.camera + ), + }); // Create rectangle node with image paint instead of image node const node = instance.commands.createRectangleNode(); @@ -96,24 +102,24 @@ export function useInsertFile() { ) => { const node = await instance.commands.createNodeFromSvg(svg); - const center_dx = - typeof node.$.layout_target_width === "number" && - node.$.layout_target_width > 0 - ? node.$.layout_target_width / 2 - : 0; - - const center_dy = - typeof node.$.layout_target_height === "number" && - node.$.layout_target_height > 0 - ? node.$.layout_target_height / 2 - : 0; - - const [x, y] = instance.camera.clientPointToCanvasPoint( - cmath.vector2.sub( - position ? [position.clientX, position.clientY] : [0, 0], - [center_dx, center_dy] - ) - ); + const [x, y] = getCenteredCanvasInsertionPoint({ + clientPosition: position + ? [position.clientX, position.clientY] + : [0, 0], + size: { + width: + typeof node.$.layout_target_width === "number" + ? node.$.layout_target_width + : 0, + height: + typeof node.$.layout_target_height === "number" + ? node.$.layout_target_height + : 0, + }, + clientPointToCanvasPoint: instance.camera.clientPointToCanvasPoint.bind( + instance.camera + ), + }); node.$.name = name; node.$.layout_inset_left = x; @@ -595,17 +601,23 @@ export function useDataTransferEventTarget() { const { name, src, width, height } = data; const task = (async () => { const imageRef = await instance.createImageAsync(src); - const [x, y] = instance.camera.clientPointToCanvasPoint([ - event.clientX, - event.clientY, - ]); + const imageWidth = width ?? imageRef.width; + const imageHeight = height ?? imageRef.height; + const [x, y] = getCenteredCanvasInsertionPoint({ + clientPosition: [event.clientX, event.clientY], + size: { width: imageWidth, height: imageHeight }, + clientPointToCanvasPoint: + instance.camera.clientPointToCanvasPoint.bind( + instance.camera + ), + }); const node = instance.commands.createRectangleNode(); node.$.layout_positioning = "absolute"; node.$.name = name || "Photo"; node.$.layout_inset_left = x; node.$.layout_inset_top = y; - node.$.layout_target_width = width || imageRef.width; - node.$.layout_target_height = height || imageRef.height; + node.$.layout_target_width = imageWidth; + node.$.layout_target_height = imageHeight; node.$.fill_paints = [ { type: "image", @@ -664,7 +676,7 @@ export function useDataTransferEventTarget() { } } }, - [insertFromFile, insertSVG] + [insertFromFile, insertSVG, instance] ); // From 71607ee920b8e2ab2a17a4633364faebb04e112f Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:56:17 +0900 Subject: [PATCH 2/5] fix: insert dropped images with image paint --- .../grida-canvas-react/use-data-transfer.ts | 114 ++++++++++++------ 1 file changed, 75 insertions(+), 39 deletions(-) diff --git a/editor/grida-canvas-react/use-data-transfer.ts b/editor/grida-canvas-react/use-data-transfer.ts index 670845df4..38e526966 100644 --- a/editor/grida-canvas-react/use-data-transfer.ts +++ b/editor/grida-canvas-react/use-data-transfer.ts @@ -14,6 +14,63 @@ import { nanoid } from "nanoid"; import { datatransfer } from "@/grida-canvas/data-transfer"; import type { editor } from "@/grida-canvas"; import { getCenteredCanvasInsertionPoint } from "./data-transfer-position"; +import type grida from "@grida/schema"; + +function createImagePaint(src: string): cg.ImagePaint { + return { + type: "image", + src, + fit: "cover", + transform: cmath.transform.identity, + filters: cg.def.IMAGE_FILTERS, + blend_mode: cg.def.BLENDMODE, + opacity: 1, + active: true, + }; +} + +function insertImageRectangle(args: { + editor: Editor; + name: string; + x: number; + y: number; + width: number; + height: number; + src: string; +}) { + const paint = createImagePaint(args.src); + const prototype = { + type: "rectangle", + name: args.name, + layout_positioning: "absolute", + layout_inset_left: 0, + layout_inset_top: 0, + layout_target_width: args.width, + layout_target_height: args.height, + fill: paint, + fill_paints: [paint], + } satisfies grida.program.nodes.NodePrototype; + + const [nodeId] = args.editor.insert( + { prototype }, + args.editor.state.scene_id ?? null + ); + + if (!nodeId) return; + + args.editor.doc.dispatch({ + type: "node/change/*", + node_id: nodeId, + layout_positioning: "absolute", + name: args.name, + layout_inset_left: args.x, + layout_inset_top: args.y, + layout_target_width: args.width, + layout_target_height: args.height, + fill_paints: [paint], + }); + args.editor.doc.select([nodeId], "reset"); +} /** * Hook that provides file insertion utilities for the Grida canvas editor. @@ -67,26 +124,15 @@ export function useInsertFile() { ), }); - // Create rectangle node with image paint instead of image node - const node = instance.commands.createRectangleNode(); - node.$.layout_positioning = "absolute"; - node.$.name = name; - node.$.layout_inset_left = x; - node.$.layout_inset_top = y; - node.$.layout_target_width = image.width; - node.$.layout_target_height = image.height; - node.$.fill_paints = [ - { - type: "image", - src: image.url, - fit: "cover", - transform: cmath.transform.identity, - filters: cg.def.IMAGE_FILTERS, - blend_mode: cg.def.BLENDMODE, - opacity: 1, - active: true, - } satisfies cg.ImagePaint, - ]; + insertImageRectangle({ + editor: instance, + name, + x, + y, + width: image.width, + height: image.height, + src: image.url, + }); }, [instance] ); @@ -611,25 +657,15 @@ export function useDataTransferEventTarget() { instance.camera ), }); - const node = instance.commands.createRectangleNode(); - node.$.layout_positioning = "absolute"; - node.$.name = name || "Photo"; - node.$.layout_inset_left = x; - node.$.layout_inset_top = y; - node.$.layout_target_width = imageWidth; - node.$.layout_target_height = imageHeight; - node.$.fill_paints = [ - { - type: "image", - src: imageRef.url, - fit: "cover", - transform: cmath.transform.identity, - filters: cg.def.IMAGE_FILTERS, - blend_mode: cg.def.BLENDMODE, - opacity: 1, - active: true, - } satisfies cg.ImagePaint, - ]; + insertImageRectangle({ + editor: instance, + name: name || "Photo", + x, + y, + width: imageWidth, + height: imageHeight, + src: imageRef.url, + }); })(); toast.promise(task, { From b5e8fad0e43f2751ba9b886b16fbb07aae0f2b46 Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Fri, 1 May 2026 00:09:45 +0900 Subject: [PATCH 3/5] fix: snapshot drop coordinates before image load --- .../grida-canvas-react/use-data-transfer.ts | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/editor/grida-canvas-react/use-data-transfer.ts b/editor/grida-canvas-react/use-data-transfer.ts index 38e526966..b2cda2541 100644 --- a/editor/grida-canvas-react/use-data-transfer.ts +++ b/editor/grida-canvas-react/use-data-transfer.ts @@ -16,6 +16,13 @@ import type { editor } from "@/grida-canvas"; import { getCenteredCanvasInsertionPoint } from "./data-transfer-position"; import type grida from "@grida/schema"; +type ClientPosition = { + clientX: number; + clientY: number; +}; + +type Vector2 = [number, number]; + function createImagePaint(src: string): cg.ImagePaint { return { type: "image", @@ -104,20 +111,14 @@ export function useInsertFile() { const instance = useCurrentEditor(); const insertImage = useCallback( - async ( - name: string, - file: File, - position?: { - clientX: number; - clientY: number; - } - ) => { + async (name: string, file: File, position?: ClientPosition) => { + const clientPosition: Vector2 = position + ? [position.clientX, position.clientY] + : [0, 0]; const bytes = await file.arrayBuffer(); const image = await instance.createImage(new Uint8Array(bytes)); const [x, y] = getCenteredCanvasInsertionPoint({ - clientPosition: position - ? [position.clientX, position.clientY] - : [0, 0], + clientPosition, size: image, clientPointToCanvasPoint: instance.camera.clientPointToCanvasPoint.bind( instance.camera @@ -138,20 +139,14 @@ export function useInsertFile() { ); const insertSVG = useCallback( - async ( - name: string, - svg: string, - position?: { - clientX: number; - clientY: number; - } - ) => { + async (name: string, svg: string, position?: ClientPosition) => { + const clientPosition: Vector2 = position + ? [position.clientX, position.clientY] + : [0, 0]; const node = await instance.commands.createNodeFromSvg(svg); const [x, y] = getCenteredCanvasInsertionPoint({ - clientPosition: position - ? [position.clientX, position.clientY] - : [0, 0], + clientPosition, size: { width: typeof node.$.layout_target_width === "number" @@ -175,14 +170,7 @@ export function useInsertFile() { ); const insertMarkdown = useCallback( - async ( - name: string, - markdown: string, - position?: { - clientX: number; - clientY: number; - } - ) => { + async (name: string, markdown: string, position?: ClientPosition) => { const [x, y] = instance.camera.clientPointToCanvasPoint( position ? [position.clientX, position.clientY] : [0, 0] ); @@ -199,10 +187,7 @@ export function useInsertFile() { ( type: io.clipboard.ValidFileType, file: File, - position?: { - clientX: number; - clientY: number; - } + position?: ClientPosition ) => { if (type === "image/svg+xml") { const reader = new FileReader(); @@ -622,6 +607,10 @@ export function useDataTransferEventTarget() { const ondrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); + const dropPosition: ClientPosition = { + clientX: event.clientX, + clientY: event.clientY, + }; const knwondata = event.dataTransfer.getData("x-grida-data-transfer"); if (knwondata) { @@ -633,7 +622,7 @@ export function useDataTransferEventTarget() { cache: "no-store", }).then((res) => res.text().then((text) => { - insertSVG(name, text, event); + insertSVG(name, text, dropPosition); }) ); @@ -650,7 +639,7 @@ export function useDataTransferEventTarget() { const imageWidth = width ?? imageRef.width; const imageHeight = height ?? imageRef.height; const [x, y] = getCenteredCanvasInsertionPoint({ - clientPosition: [event.clientX, event.clientY], + clientPosition: [dropPosition.clientX, dropPosition.clientY], size: { width: imageWidth, height: imageHeight }, clientPointToCanvasPoint: instance.camera.clientPointToCanvasPoint.bind( @@ -706,7 +695,7 @@ export function useDataTransferEventTarget() { const [valid, type] = io.clipboard.filetype(file); if (valid) { - insertFromFile(type, file, event); + insertFromFile(type, file, dropPosition); } else { toast.error(`file type '${type}' is not supported`); } From 8e521f3fdf6cfaaac0d9df6e637851dd3a936bc1 Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Fri, 1 May 2026 00:15:43 +0900 Subject: [PATCH 4/5] fix: resolve drop position from drop target --- .../grida-canvas-react/use-data-transfer.ts | 126 +++++++++++------- 1 file changed, 80 insertions(+), 46 deletions(-) diff --git a/editor/grida-canvas-react/use-data-transfer.ts b/editor/grida-canvas-react/use-data-transfer.ts index b2cda2541..8ac0b6299 100644 --- a/editor/grida-canvas-react/use-data-transfer.ts +++ b/editor/grida-canvas-react/use-data-transfer.ts @@ -13,7 +13,6 @@ import { iofigma } from "@grida/io-figma"; import { nanoid } from "nanoid"; import { datatransfer } from "@/grida-canvas/data-transfer"; import type { editor } from "@/grida-canvas"; -import { getCenteredCanvasInsertionPoint } from "./data-transfer-position"; import type grida from "@grida/schema"; type ClientPosition = { @@ -21,8 +20,64 @@ type ClientPosition = { clientY: number; }; +type CanvasPosition = { + canvasX: number; + canvasY: number; +}; + +type InsertionPosition = ClientPosition | CanvasPosition; + type Vector2 = [number, number]; +type Size = { + width: number; + height: number; +}; + +function isCanvasPosition( + position: InsertionPosition +): position is CanvasPosition { + return "canvasX" in position && "canvasY" in position; +} + +function getInsertionCanvasPoint( + editor: Editor, + position?: InsertionPosition +): Vector2 { + if (!position) return [0, 0]; + if (isCanvasPosition(position)) return [position.canvasX, position.canvasY]; + return editor.camera.clientPointToCanvasPoint([ + position.clientX, + position.clientY, + ]); +} + +function getDropCanvasPoint( + editor: Editor, + event: React.DragEvent +): Vector2 { + const rect = event.currentTarget.getBoundingClientRect(); + const viewportPoint: Vector2 = [ + event.clientX - rect.left, + event.clientY - rect.top, + ]; + return cmath.vector2.transform( + viewportPoint, + cmath.transform.invert(editor.state.transform) + ); +} + +function centerCanvasPoint(canvasPoint: Vector2, size: Size): Vector2 { + return [ + canvasPoint[0] - getPositiveHalf(size.width), + canvasPoint[1] - getPositiveHalf(size.height), + ]; +} + +function getPositiveHalf(value: number): number { + return Number.isFinite(value) && value > 0 ? value / 2 : 0; +} + function createImagePaint(src: string): cg.ImagePaint { return { type: "image", @@ -111,19 +166,11 @@ export function useInsertFile() { const instance = useCurrentEditor(); const insertImage = useCallback( - async (name: string, file: File, position?: ClientPosition) => { - const clientPosition: Vector2 = position - ? [position.clientX, position.clientY] - : [0, 0]; + async (name: string, file: File, position?: InsertionPosition) => { + const canvasPoint = getInsertionCanvasPoint(instance, position); const bytes = await file.arrayBuffer(); const image = await instance.createImage(new Uint8Array(bytes)); - const [x, y] = getCenteredCanvasInsertionPoint({ - clientPosition, - size: image, - clientPointToCanvasPoint: instance.camera.clientPointToCanvasPoint.bind( - instance.camera - ), - }); + const [x, y] = centerCanvasPoint(canvasPoint, image); insertImageRectangle({ editor: instance, @@ -139,27 +186,19 @@ export function useInsertFile() { ); const insertSVG = useCallback( - async (name: string, svg: string, position?: ClientPosition) => { - const clientPosition: Vector2 = position - ? [position.clientX, position.clientY] - : [0, 0]; + async (name: string, svg: string, position?: InsertionPosition) => { + const canvasPoint = getInsertionCanvasPoint(instance, position); const node = await instance.commands.createNodeFromSvg(svg); - const [x, y] = getCenteredCanvasInsertionPoint({ - clientPosition, - size: { - width: - typeof node.$.layout_target_width === "number" - ? node.$.layout_target_width - : 0, - height: - typeof node.$.layout_target_height === "number" - ? node.$.layout_target_height - : 0, - }, - clientPointToCanvasPoint: instance.camera.clientPointToCanvasPoint.bind( - instance.camera - ), + const [x, y] = centerCanvasPoint(canvasPoint, { + width: + typeof node.$.layout_target_width === "number" + ? node.$.layout_target_width + : 0, + height: + typeof node.$.layout_target_height === "number" + ? node.$.layout_target_height + : 0, }); node.$.name = name; @@ -170,10 +209,8 @@ export function useInsertFile() { ); const insertMarkdown = useCallback( - async (name: string, markdown: string, position?: ClientPosition) => { - const [x, y] = instance.camera.clientPointToCanvasPoint( - position ? [position.clientX, position.clientY] : [0, 0] - ); + async (name: string, markdown: string, position?: InsertionPosition) => { + const [x, y] = getInsertionCanvasPoint(instance, position); const node = instance.commands.createMarkdownNode(markdown); node.$.name = name; @@ -187,7 +224,7 @@ export function useInsertFile() { ( type: io.clipboard.ValidFileType, file: File, - position?: ClientPosition + position?: InsertionPosition ) => { if (type === "image/svg+xml") { const reader = new FileReader(); @@ -607,9 +644,10 @@ export function useDataTransferEventTarget() { const ondrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); - const dropPosition: ClientPosition = { - clientX: event.clientX, - clientY: event.clientY, + const dropCanvasPoint = getDropCanvasPoint(instance, event); + const dropPosition: CanvasPosition = { + canvasX: dropCanvasPoint[0], + canvasY: dropCanvasPoint[1], }; const knwondata = event.dataTransfer.getData("x-grida-data-transfer"); @@ -638,13 +676,9 @@ export function useDataTransferEventTarget() { const imageRef = await instance.createImageAsync(src); const imageWidth = width ?? imageRef.width; const imageHeight = height ?? imageRef.height; - const [x, y] = getCenteredCanvasInsertionPoint({ - clientPosition: [dropPosition.clientX, dropPosition.clientY], - size: { width: imageWidth, height: imageHeight }, - clientPointToCanvasPoint: - instance.camera.clientPointToCanvasPoint.bind( - instance.camera - ), + const [x, y] = centerCanvasPoint(dropCanvasPoint, { + width: imageWidth, + height: imageHeight, }); insertImageRectangle({ editor: instance, From 231f3268f111603e26651c9aabafc6412c4dfb77 Mon Sep 17 00:00:00 2001 From: yongrean <78528865+k08200@users.noreply.github.com> Date: Fri, 1 May 2026 00:28:48 +0900 Subject: [PATCH 5/5] fix: preserve explicit drop insertion placement --- .../grida-canvas-react/use-data-transfer.ts | 27 ++--- .../headless/node-properties.test.ts | 29 +++++ editor/grida-canvas/action.ts | 6 + .../grida-canvas/reducers/document.reducer.ts | 107 +++++++++--------- 4 files changed, 99 insertions(+), 70 deletions(-) diff --git a/editor/grida-canvas-react/use-data-transfer.ts b/editor/grida-canvas-react/use-data-transfer.ts index 8ac0b6299..3eeb11847 100644 --- a/editor/grida-canvas-react/use-data-transfer.ts +++ b/editor/grida-canvas-react/use-data-transfer.ts @@ -100,36 +100,27 @@ function insertImageRectangle(args: { height: number; src: string; }) { + const nodeId = nanoid(); const paint = createImagePaint(args.src); const prototype = { type: "rectangle", + _$id: nodeId, name: args.name, layout_positioning: "absolute", - layout_inset_left: 0, - layout_inset_top: 0, + layout_inset_left: args.x, + layout_inset_top: args.y, layout_target_width: args.width, layout_target_height: args.height, fill: paint, fill_paints: [paint], } satisfies grida.program.nodes.NodePrototype; - const [nodeId] = args.editor.insert( - { prototype }, - args.editor.state.scene_id ?? null - ); - - if (!nodeId) return; - args.editor.doc.dispatch({ - type: "node/change/*", - node_id: nodeId, - layout_positioning: "absolute", - name: args.name, - layout_inset_left: args.x, - layout_inset_top: args.y, - layout_target_width: args.width, - layout_target_height: args.height, - fill_paints: [paint], + type: "insert", + id: nodeId, + prototype, + target: null, + placement: "none", }); args.editor.doc.select([nodeId], "reset"); } diff --git a/editor/grida-canvas/__tests__/headless/node-properties.test.ts b/editor/grida-canvas/__tests__/headless/node-properties.test.ts index 3099fe431..62c4bfa18 100644 --- a/editor/grida-canvas/__tests__/headless/node-properties.test.ts +++ b/editor/grida-canvas/__tests__/headless/node-properties.test.ts @@ -107,6 +107,35 @@ describe("Node Properties (headless)", () => { expect(node.layout_target_width).toBe(300); }); + test("insert can preserve absolute payload placement", () => { + ed.doc.dispatch({ + type: "insert", + id: "absolute-insert", + target: null, + placement: "none", + prototype: { + type: "rectangle", + _$id: "absolute-insert", + layout_positioning: "absolute", + layout_inset_left: 123, + layout_inset_top: 456, + layout_target_width: 20, + layout_target_height: 30, + fill: { + type: "solid", + color: color.colorformats.RGBA32F.BLACK, + active: true, + }, + }, + }); + + const node = ed.state.document.nodes[ + "absolute-insert" + ] as grida.program.nodes.RectangleNode; + expect(node.layout_inset_left).toBe(123); + expect(node.layout_inset_top).toBe(456); + }); + test("NodeProxy get/set roundtrip", () => { const proxy = ed.doc.getNodeById("rect-0"); expect(proxy.id).toBe("rect-0"); diff --git a/editor/grida-canvas/action.ts b/editor/grida-canvas/action.ts index 074cfb2f8..1ef227655 100644 --- a/editor/grida-canvas/action.ts +++ b/editor/grida-canvas/action.ts @@ -1001,6 +1001,12 @@ export type DocumentEditorInsertNodeAction = { * - `NodeID`: Insert into this parent container */ target: NodeID | null; + /** + * Controls whether the reducer applies viewport-aware auto placement. + * - `"auto"` / undefined: place the inserted subtree in the current viewport + * - `"none"`: preserve absolute coordinates from the payload + */ + placement?: "auto" | "none"; } & ( | { id?: string; diff --git a/editor/grida-canvas/reducers/document.reducer.ts b/editor/grida-canvas/reducers/document.reducer.ts index 5d8d8ac1b..e28101eca 100644 --- a/editor/grida-canvas/reducers/document.reducer.ts +++ b/editor/grida-canvas/reducers/document.reducer.ts @@ -843,63 +843,66 @@ export default function documentReducer( ); } - const box = getPackedSubtreeBoundingRect(sub); - - // [root rect for calculating next placement] - // if the insertion parent is null (root), use viewport rect (canvas space) - // otherwise, use the parent's bounding rect (canvas space) (TODO:) - const { width, height } = context.viewport; - - // apply the inset before convering to canvas space - const _inset_rect = cmath.rect.inset( - { - x: 0, - y: 0, - width, - height, - }, - PLACEMENT_VIEWPORT_INSET - ); + if (action.placement !== "none") { + const box = getPackedSubtreeBoundingRect(sub); + + // [root rect for calculating next placement] + // if the insertion parent is null (root), use viewport rect (canvas space) + // otherwise, use the parent's bounding rect (canvas space) (TODO:) + const { width, height } = context.viewport; + + // apply the inset before convering to canvas space + const _inset_rect = cmath.rect.inset( + { + x: 0, + y: 0, + width, + height, + }, + PLACEMENT_VIEWPORT_INSET + ); - const viewport_rect = cmath.rect.transform( - _inset_rect, - cmath.transform.invert(state.transform) - ); + const viewport_rect = cmath.rect.transform( + _inset_rect, + cmath.transform.invert(state.transform) + ); - // use target's children as siblings (if null, root children) // TODO: parent siblings are not supported - assert(state.scene_id, "scene_id is required for insertion"); - const siblings = state.document.links[state.scene_id] || []; - const anchors = siblings - .map((node_id) => { - const r = context.geometry.getNodeAbsoluteBoundingRect(node_id); - if (!r) return null; - return cmath.rect.pad( - { x: r.x, y: r.y, width: r.width, height: r.height }, - PLACEMENT_ANCHORS_PADDING - ); - }) - .filter((r) => r !== null) as cmath.Rectangle[]; + // use target's children as siblings (if null, root children) // TODO: parent siblings are not supported + assert(state.scene_id, "scene_id is required for insertion"); + const siblings = state.document.links[state.scene_id] || []; + const anchors = siblings + .map((node_id) => { + const r = context.geometry.getNodeAbsoluteBoundingRect(node_id); + if (!r) return null; + return cmath.rect.pad( + { x: r.x, y: r.y, width: r.width, height: r.height }, + PLACEMENT_ANCHORS_PADDING + ); + }) + .filter((r) => r !== null) as cmath.Rectangle[]; - const placement = cmath.packing.ext.walk_to_fit( - viewport_rect, - box, - anchors - ); + const placement = cmath.packing.ext.walk_to_fit( + viewport_rect, + box, + anchors + ); - assert(placement); // placement is always expected since allowOverflow is true + assert(placement); // placement is always expected since allowOverflow is true - sub.scene.children_refs.forEach((node_id) => { - const node = sub.nodes[node_id]; - if ( - "layout_positioning" in node && - node.layout_positioning === "absolute" && - "layout_inset_left" in node && - "layout_inset_top" in node - ) { - node.layout_inset_left = (node.layout_inset_left ?? 0) + placement.x; - node.layout_inset_top = (node.layout_inset_top ?? 0) + placement.y; - } - }); + sub.scene.children_refs.forEach((node_id) => { + const node = sub.nodes[node_id]; + if ( + "layout_positioning" in node && + node.layout_positioning === "absolute" && + "layout_inset_left" in node && + "layout_inset_top" in node + ) { + node.layout_inset_left = + (node.layout_inset_left ?? 0) + placement.x; + node.layout_inset_top = (node.layout_inset_top ?? 0) + placement.y; + } + }); + } const parent: string | null = action.target;