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..3eeb11847 100644 --- a/editor/grida-canvas-react/use-data-transfer.ts +++ b/editor/grida-canvas-react/use-data-transfer.ts @@ -13,6 +13,117 @@ import { iofigma } from "@grida/io-figma"; import { nanoid } from "nanoid"; import { datatransfer } from "@/grida-canvas/data-transfer"; import type { editor } from "@/grida-canvas"; +import type grida from "@grida/schema"; + +type ClientPosition = { + clientX: number; + 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", + 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 nodeId = nanoid(); + const paint = createImagePaint(args.src); + const prototype = { + type: "rectangle", + _$id: nodeId, + name: args.name, + layout_positioning: "absolute", + 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; + + args.editor.doc.dispatch({ + type: "insert", + id: nodeId, + prototype, + target: null, + placement: "none", + }); + args.editor.doc.select([nodeId], "reset"); +} /** * Hook that provides file insertion utilities for the Grida canvas editor. @@ -46,74 +157,40 @@ export function useInsertFile() { const instance = useCurrentEditor(); const insertImage = useCallback( - async ( - name: string, - file: File, - position?: { - clientX: number; - clientY: number; - } - ) => { - const [x, y] = instance.camera.clientPointToCanvasPoint( - 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)); - - // 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, - ]; + const [x, y] = centerCanvasPoint(canvasPoint, image); + + insertImageRectangle({ + editor: instance, + name, + x, + y, + width: image.width, + height: image.height, + src: image.url, + }); }, [instance] ); const insertSVG = useCallback( - async ( - name: string, - svg: string, - position?: { - clientX: number; - clientY: number; - } - ) => { + async (name: string, svg: string, position?: InsertionPosition) => { + const canvasPoint = getInsertionCanvasPoint(instance, position); 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] = 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; node.$.layout_inset_left = x; @@ -123,17 +200,8 @@ export function useInsertFile() { ); const insertMarkdown = useCallback( - async ( - name: string, - markdown: string, - position?: { - clientX: number; - clientY: number; - } - ) => { - 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; @@ -147,10 +215,7 @@ export function useInsertFile() { ( type: io.clipboard.ValidFileType, file: File, - position?: { - clientX: number; - clientY: number; - } + position?: InsertionPosition ) => { if (type === "image/svg+xml") { const reader = new FileReader(); @@ -570,6 +635,11 @@ export function useDataTransferEventTarget() { const ondrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); + const dropCanvasPoint = getDropCanvasPoint(instance, event); + const dropPosition: CanvasPosition = { + canvasX: dropCanvasPoint[0], + canvasY: dropCanvasPoint[1], + }; const knwondata = event.dataTransfer.getData("x-grida-data-transfer"); if (knwondata) { @@ -581,7 +651,7 @@ export function useDataTransferEventTarget() { cache: "no-store", }).then((res) => res.text().then((text) => { - insertSVG(name, text, event); + insertSVG(name, text, dropPosition); }) ); @@ -595,29 +665,21 @@ 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 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.$.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, - ]; + const imageWidth = width ?? imageRef.width; + const imageHeight = height ?? imageRef.height; + const [x, y] = centerCanvasPoint(dropCanvasPoint, { + width: imageWidth, + height: imageHeight, + }); + insertImageRectangle({ + editor: instance, + name: name || "Photo", + x, + y, + width: imageWidth, + height: imageHeight, + src: imageRef.url, + }); })(); toast.promise(task, { @@ -658,13 +720,13 @@ 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`); } } }, - [insertFromFile, insertSVG] + [insertFromFile, insertSVG, instance] ); // 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;