From b8cc9a185f3449ed6a5fc5c403c1ba29e3a80707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Fri, 7 Nov 2025 13:28:52 +0100 Subject: [PATCH 1/7] chore: wip --- .../actions/image-template-tool/constants.ts | 12 + .../image-template-tool.ts | 219 +++++++++++ .../actions/image-template-tool/types.ts | 13 + .../nodes/image-template/image-template.ts | 365 ++++++++++++++++++ .../hooks/use-context-menu.tsx | 139 ++++++- .../node-properties/fill-properties.tsx | 4 +- .../image-template-properties.tsx | 100 +++++ .../node-properties/stroke-properties.tsx | 12 +- .../overlay/hooks/use-node-action-name.tsx | 2 + .../overlay/node-properties.tsx | 2 + .../room-components/overlay/node-toolbar.tsx | 131 +++++++ .../overlay/tools-overlay.mouse.tsx | 17 + code/components/utils/constants.ts | 4 + code/components/utils/templates.ts | 171 ++++++++ code/store/store.ts | 8 + code/weave.d.ts | 4 + 16 files changed, 1197 insertions(+), 6 deletions(-) create mode 100644 code/components/actions/image-template-tool/constants.ts create mode 100644 code/components/actions/image-template-tool/image-template-tool.ts create mode 100644 code/components/actions/image-template-tool/types.ts create mode 100644 code/components/nodes/image-template/image-template.ts create mode 100644 code/components/room-components/node-properties/image-template-properties.tsx create mode 100644 code/components/utils/templates.ts diff --git a/code/components/actions/image-template-tool/constants.ts b/code/components/actions/image-template-tool/constants.ts new file mode 100644 index 0000000..155ea4c --- /dev/null +++ b/code/components/actions/image-template-tool/constants.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const IMAGE_TEMPLATE_ACTION_NAME = "imageTemplateTool"; + +export const IMAGE_TEMPLATE_TOOL_STATE = { + ["IDLE"]: "idle", + ["ADDING"]: "adding", + ["DEFINING_SIZE"]: "definingSize", + ["ADDED"]: "added", +} as const; diff --git a/code/components/actions/image-template-tool/image-template-tool.ts b/code/components/actions/image-template-tool/image-template-tool.ts new file mode 100644 index 0000000..f74f662 --- /dev/null +++ b/code/components/actions/image-template-tool/image-template-tool.ts @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { v4 as uuidv4 } from "uuid"; +import { Vector2d } from "konva/lib/types"; +import { + ImageTemplateToolActionOnAddedEvent, + ImageTemplateToolActionOnAddingEvent, + ImageTemplateToolActionState, +} from "./types"; +import { + IMAGE_TEMPLATE_ACTION_NAME, + IMAGE_TEMPLATE_TOOL_STATE, +} from "./constants"; +import { WeaveAction, WeaveNodesSelectionPlugin } from "@inditextech/weave-sdk"; +import Konva from "konva"; +import { ImageTemplateNode } from "@/components/nodes/image-template/image-template"; + +export class ImageTemplateToolAction extends WeaveAction { + protected initialized: boolean = false; + protected state: ImageTemplateToolActionState; + protected pointers: Map; + protected imageTemplateId: string | null; + protected container: Konva.Layer | Konva.Group | undefined; + protected clickPoint: Vector2d | null; + protected cancelAction!: () => void; + onPropsChange = undefined; + + constructor() { + super(); + + this.pointers = new Map(); + this.initialized = false; + this.state = IMAGE_TEMPLATE_TOOL_STATE.IDLE; + this.imageTemplateId = null; + this.container = undefined; + this.clickPoint = null; + } + + getName(): string { + return IMAGE_TEMPLATE_ACTION_NAME; + } + + initProps() { + return { + colorToken: "#000000", + width: 300, + height: 300, + opacity: 1, + }; + } + + onInit() { + this.instance.addEventListener("onStageDrop", (e) => { + if (window.colorTokenDragColor) { + this.instance.getStage().setPointersPositions(e); + const position = this.instance.getStage().getRelativePointerPosition(); + this.instance.triggerAction("colorTokenTool", { + color: window.colorTokenDragColor, + position, + }); + window.colorTokenDragColor = undefined; + } + }); + } + + private setupEvents() { + const stage = this.instance.getStage(); + + stage.container().addEventListener("keydown", (e) => { + if (e.key === "Escape") { + this.cancelAction(); + return; + } + }); + + stage.on("pointerdown", (e) => { + this.setTapStart(e); + + this.pointers.set(e.evt.pointerId, { + x: e.evt.clientX, + y: e.evt.clientY, + }); + + if ( + this.pointers.size === 2 && + this.instance.getActiveAction() === IMAGE_TEMPLATE_ACTION_NAME + ) { + this.state = IMAGE_TEMPLATE_TOOL_STATE.ADDING; + return; + } + + if (this.state === IMAGE_TEMPLATE_TOOL_STATE.ADDING) { + this.state = IMAGE_TEMPLATE_TOOL_STATE.DEFINING_SIZE; + } + }); + + stage.on("pointermove", (e) => { + if (this.state === IMAGE_TEMPLATE_TOOL_STATE.IDLE) return; + + this.setCursor(); + + if (!this.isPressed(e)) return; + + if (!this.pointers.has(e.evt.pointerId)) return; + + if ( + this.pointers.size === 2 && + this.instance.getActiveAction() === IMAGE_TEMPLATE_ACTION_NAME + ) { + this.state = IMAGE_TEMPLATE_TOOL_STATE.ADDING; + return; + } + }); + + stage.on("pointerup", (e) => { + this.pointers.delete(e.evt.pointerId); + + if (this.state === IMAGE_TEMPLATE_TOOL_STATE.DEFINING_SIZE) { + this.handleAdding(); + } + }); + + this.initialized = true; + } + + private setState(state: ImageTemplateToolActionState) { + this.state = state; + } + + private addImageTemplate() { + this.setCursor(); + + this.instance.emitEvent( + "onAddingImageTemplate" + ); + + this.imageTemplateId = null; + this.clickPoint = null; + this.setState(IMAGE_TEMPLATE_TOOL_STATE.ADDING); + } + + private handleAdding(position?: Vector2d) { + const { mousePoint, container } = this.instance.getMousePointer(position); + + this.clickPoint = mousePoint; + this.container = container as Konva.Layer | Konva.Group; + + this.imageTemplateId = uuidv4(); + + const nodeHandler = + this.instance.getNodeHandler("image-template"); + + if (nodeHandler) { + const node = nodeHandler.create(this.imageTemplateId, { + ...this.props, + x: this.clickPoint.x, + y: this.clickPoint.y, + width: 100, + height: 100, + }); + + this.instance.addNode(node, this.container?.getAttrs().id); + + this.instance.emitEvent( + "onAddedImageTemplate" + ); + } + + this.cancelAction(); + } + + trigger(cancelAction: () => void) { + if (!this.instance) { + throw new Error("Instance not defined"); + } + + if (!this.initialized) { + this.setupEvents(); + } + const stage = this.instance.getStage(); + + stage.container().tabIndex = 1; + stage.container().focus(); + + this.cancelAction = cancelAction; + + this.props = this.initProps(); + + this.addImageTemplate(); + } + + cleanup() { + const stage = this.instance.getStage(); + + stage.container().style.cursor = "default"; + + const selectionPlugin = + this.instance.getPlugin("nodesSelection"); + if (selectionPlugin) { + const node = stage.findOne(`#${this.imageTemplateId}`); + if (node) { + selectionPlugin.setSelectedNodes([node]); + } + this.instance.triggerAction("selectionTool"); + } + + this.imageTemplateId = null; + this.container = undefined; + this.clickPoint = null; + this.setState(IMAGE_TEMPLATE_TOOL_STATE.IDLE); + } + + private setCursor() { + const stage = this.instance.getStage(); + stage.container().style.cursor = "crosshair"; + } +} diff --git a/code/components/actions/image-template-tool/types.ts b/code/components/actions/image-template-tool/types.ts new file mode 100644 index 0000000..d787067 --- /dev/null +++ b/code/components/actions/image-template-tool/types.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { IMAGE_TEMPLATE_TOOL_STATE } from "./constants"; + +export type ImageTemplateToolActionStateKeys = + keyof typeof IMAGE_TEMPLATE_TOOL_STATE; +export type ImageTemplateToolActionState = + (typeof IMAGE_TEMPLATE_TOOL_STATE)[ImageTemplateToolActionStateKeys]; + +export type ImageTemplateToolActionOnAddingEvent = undefined; +export type ImageTemplateToolActionOnAddedEvent = undefined; diff --git a/code/components/nodes/image-template/image-template.ts b/code/components/nodes/image-template/image-template.ts new file mode 100644 index 0000000..7cd97da --- /dev/null +++ b/code/components/nodes/image-template/image-template.ts @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +import { + moveNodeToContainer, + WeaveImageNode, + WeaveNode, +} from "@inditextech/weave-sdk"; +import { + WEAVE_NODE_CUSTOM_EVENTS, + WeaveElementAttributes, + WeaveElementInstance, + WeaveStateElement, +} from "@inditextech/weave-types"; +import Konva from "konva"; + +export const IMAGE_TEMPLATE_NODE_TYPE = "image-template"; + +export class ImageTemplateNode extends WeaveNode { + protected nodeType = IMAGE_TEMPLATE_NODE_TYPE; + protected padding = 20; + protected borderWidth = 1; + protected templateIdDefault = "Template ID"; + + onRender(props: WeaveElementAttributes) { + const { id } = props; + + const imageTemplateParams = { + ...props, + }; + delete imageTemplateParams.zIndex; + + const imageTemplateNode = new Konva.Group({ + ...imageTemplateParams, + isContainerPrincipal: true, + containerId: `${id}-imageTemplate-group`, + name: "node containerCapable", + }); + + this.setupDefaultNodeAugmentation(imageTemplateNode); + + const internalRect = new Konva.Rect({ + name: "fillShape", + groupId: id, + nodeId: id, + id: `${id}-imageTemplate`, + x: 0, + y: 0, + fill: "#666666", + width: imageTemplateParams.width, + height: imageTemplateParams.height, + strokeWidth: imageTemplateParams.isUsed ? 0 : this.borderWidth, + stroke: "#000000", + hitFunc: function (ctx, shape) { + ctx.beginPath(); + ctx.rect(0, 0, shape.width(), shape.height()); + ctx.fillStrokeShape(shape); + }, + }); + + const internalGroup = new Konva.Group({ + name: "fillShape", + groupId: id, + nodeId: id, + id: `${id}-imageTemplate-group`, + x: 0, + y: 0, + clipWidth: imageTemplateParams.width, + clipHeight: imageTemplateParams.height, + }); + + const internalText = new Konva.Text({ + id: `${id}-imageTemplateId`, + groupId: id, + nodeId: id, + x: this.padding, + y: this.padding, + fontSize: 48, + fontFamily: "Arial, sans-serif", + fontStyle: "bold", + fill: "#FFFFFFFF", + strokeEnabled: false, + stroke: "#FFFFFFFF", + strokeWidth: 1, + text: imageTemplateParams.templateId ?? this.templateIdDefault, + width: imageTemplateParams.width - this.padding * 2, + height: imageTemplateParams.height - this.padding * 2, + align: "center", + verticalAlign: "middle", + listening: false, + draggable: false, + }); + + imageTemplateNode.add(internalRect); + imageTemplateNode.add(internalText); + imageTemplateNode.add(internalGroup); + + imageTemplateNode.getClientRect = (config) => { + return internalRect.getClientRect(config); + }; + + imageTemplateNode.on("transformstart", () => { + internalText.visible(false); + }); + + imageTemplateNode.on("transformend", () => { + internalText.visible(true); + }); + + imageTemplateNode.on(WEAVE_NODE_CUSTOM_EVENTS.onTargetLeave, () => { + internalRect.fill("#666666"); + }); + + imageTemplateNode.on(WEAVE_NODE_CUSTOM_EVENTS.onTargetEnter, () => { + const selectedNodes = this.getNodesSelectionPlugin()?.getSelectedNodes(); + if ( + selectedNodes?.length === 1 && + selectedNodes?.[0].getAttrs().nodeType === "image" + ) { + internalRect.fill("#CC0000"); + } + }); + + this.instance.addEventListener( + "onNodeMovedToContainer", + ({ node, container }: { node: Konva.Node; container: Konva.Node }) => { + if (node && container === imageTemplateNode) { + this.link(imageTemplateNode, node as WeaveElementInstance); + } + } + ); + + this.setupDefaultNodeEvents(imageTemplateNode); + + imageTemplateNode.canMoveToContainer = function ( + node: Konva.Node + ): boolean { + return node.getAttr("nodeType") === "image"; + }; + + return imageTemplateNode; + } + + onUpdate( + nodeInstance: WeaveElementInstance, + nextProps: WeaveElementAttributes + ) { + const imageTemplateNode = nodeInstance as Konva.Group; + imageTemplateNode.setAttrs({ + ...nextProps, + }); + + const internalRect = imageTemplateNode.findOne( + `#${nextProps.id}-imageTemplate` + ) as Konva.Rect; + internalRect?.setAttrs({ + width: nextProps.width, + height: nextProps.height, + }); + + const internalText = imageTemplateNode.findOne( + `#${nextProps.id}-imageTemplateId` + ) as Konva.Text; + internalText?.setAttrs({ + x: this.padding, + y: this.padding, + text: nextProps.templateId ?? this.templateIdDefault, + width: nextProps.width - this.padding * 2, + height: nextProps.height - this.padding * 2, + }); + + const internalGroup = imageTemplateNode.findOne( + `#${nextProps.id}-imageTemplate-group` + ) as Konva.Group; + internalGroup?.setAttrs({ + clipWidth: nextProps.width, + clipHeight: nextProps.height, + }); + + if (nextProps.isUsed) { + internalRect.strokeWidth(0); + internalText.visible(false); + } else { + internalRect.strokeWidth(this.borderWidth); + internalText.visible(true); + } + } + + setImage(template: WeaveElementInstance, node: WeaveElementInstance) { + moveNodeToContainer(this.instance, node, template); + } + + link(template: WeaveElementInstance, node: WeaveElementInstance) { + const stage = this.instance.getStage(); + const imageNode = (node as Konva.Group).findOne( + `#${node.getAttr("id")}-image` + ); + + if (!imageNode) { + return; + } + + const imageRect = imageNode?.getClientRect({ + relativeTo: stage, + }); + const iw = imageRect?.width ?? 1; + const ih = imageRect?.height ?? 1; + + const templateRect = template.getClientRect({ + relativeTo: stage, + }); + const gw = templateRect.width; // group width + const gh = templateRect.height; // group height + + const imageRatio = iw / ih; + const groupRatio = gw / gh; + + let scale, x, y; + + if (imageRatio > groupRatio) { + // image is wider -> fit height + scale = gh / ih; + x = (gw - iw * scale) / 2; + y = 0; + } else { + // image is taller -> fit width + scale = gw / iw; + x = 0; + y = (gh - ih * scale) / 2; + } + + const handler = this.instance.getNodeHandler("image"); + + if (handler) { + node.x(x); + node.y(y); + node.scaleX(scale); + node.scaleY(scale); + node.draggable(false); + node.listening(false); + handler.scaleReset(node as Konva.Group); + this.instance.updateNode(handler.serialize(node as WeaveElementInstance)); + } + + template.setAttrs({ isUsed: true }); + this.instance.updateNode(this.serialize(template as WeaveElementInstance)); + } + + unlink(node: WeaveElementInstance) { + const imageTemplateNode = node as Konva.Group; + const attrs = imageTemplateNode.getAttrs(); + + const imageHandler = this.instance.getNodeHandler("image"); + + if (!imageHandler) return; + + if (attrs.isUsed) { + const internalGroup = imageTemplateNode.findOne( + `#${attrs.id}-imageTemplate-group` + ) as Konva.Group; + + if (!internalGroup) return; + + const children = internalGroup.getChildren(); + + if (children.length === 0) return; + + const imageNode = children[0]; + + let layerToMove: Konva.Container | null | undefined = + imageTemplateNode.getParent(); + if ( + layerToMove && + layerToMove.getAttrs().nodeId && + !layerToMove.getAttrs().containerId + ) { + layerToMove = this.instance + .getStage() + .findOne(`#${layerToMove.getAttrs().nodeId}`) as Konva.Container; + } + + if (!layerToMove) return; + + const layerToMoveAttrs = layerToMove?.getAttrs(); + const actualImageLayer = imageNode.getParent(); + const actualImageLayerAttrs = actualImageLayer?.getAttrs(); + + const nodePos = imageNode.getAbsolutePosition(); + const nodeRotation = imageNode.getAbsoluteRotation(); + + imageNode.moveTo(layerToMove); + imageNode.setAbsolutePosition(nodePos); + imageNode.rotation(nodeRotation); + imageNode.draggable(true); + imageNode.listening(true); + imageNode.x( + imageNode.x() - (actualImageLayerAttrs?.containerOffsetX ?? 0) + ); + imageNode.y( + imageNode.y() - (actualImageLayerAttrs?.containerOffsetY ?? 0) + ); + + const actualNode = imageHandler.serialize( + imageNode as WeaveElementInstance + ); + + this.instance.removeNode(actualNode); + this.instance.addNode(actualNode, layerToMoveAttrs?.id); + + imageTemplateNode.setAttrs({ isUsed: false }); + this.instance.updateNode( + this.serialize(imageTemplateNode as WeaveElementInstance) + ); + + this.getNodesSelectionPlugin()?.setSelectedNodes([imageNode]); + } + } + + serialize(instance: WeaveElementInstance): WeaveStateElement { + const stage = this.instance.getStage(); + const attrs = instance.getAttrs(); + + const mainNode = instance as Konva.Group | undefined; + + const frameInternal: Konva.Group | undefined = stage.findOne( + `#${attrs.containerId}` + ); + + const childrenMapped: WeaveStateElement[] = []; + if (frameInternal) { + const children: WeaveElementInstance[] = [ + ...(frameInternal as Konva.Group).getChildren(), + ]; + for (const node of children) { + const handler = this.instance.getNodeHandler( + node.getAttr("nodeType") + ); + if (!handler) { + continue; + } + childrenMapped.push(handler.serialize(node)); + } + } + + const realAttrs = mainNode?.getAttrs(); + + const cleanedAttrs = { ...realAttrs }; + delete cleanedAttrs.draggable; + delete cleanedAttrs.onTargetEnter; + + return { + key: realAttrs?.id ?? "", + type: realAttrs?.nodeType, + props: { + ...cleanedAttrs, + isCloned: undefined, + isCloneOrigin: undefined, + id: realAttrs?.id ?? "", + nodeType: realAttrs?.nodeType, + children: childrenMapped, + }, + }; + } +} diff --git a/code/components/room-components/hooks/use-context-menu.tsx b/code/components/room-components/hooks/use-context-menu.tsx index 905338a..35f60c3 100644 --- a/code/components/room-components/hooks/use-context-menu.tsx +++ b/code/components/room-components/hooks/use-context-menu.tsx @@ -10,7 +10,7 @@ import { WeaveStageContextMenuPluginOnNodeContextMenuEvent, } from "@inditextech/weave-sdk"; import { Vector2d } from "konva/lib/types"; -import { WeaveSelection } from "@inditextech/weave-types"; +import { WeaveElementInstance, WeaveSelection } from "@inditextech/weave-types"; import { SidebarActive, useCollaborationRoom } from "@/store/store"; import React from "react"; import { useWeave } from "@inditextech/weave-react"; @@ -32,6 +32,10 @@ import { ImageDown, Lock, EyeOff, + Link, + HardDriveUpload, + PackagePlus, + PackageOpen, } from "lucide-react"; // import { useMutation } from "@tanstack/react-query"; // import { postRemoveBackground } from "@/api/post-remove-background"; @@ -41,13 +45,18 @@ import { useIACapabilitiesV2 } from "@/store/ia-v2"; // import { SIDEBAR_ELEMENTS } from "@/lib/constants"; import { useExportToImageServerSide } from "./use-export-to-image-server-side"; import { getImageBase64 } from "@/components/utils/images"; +import { ImageTemplateNode } from "@/components/nodes/image-template/image-template"; +import { + getSelectionAsTemplate, + setTemplateOnPosition, +} from "@/components/utils/templates"; function useContextMenu() { const instance = useWeave((state) => state.instance); // const user = useCollaborationRoom((state) => state.user); // const clientId = useCollaborationRoom((state) => state.clientId); - // const room = useCollaborationRoom((state) => state.room); + const room = useCollaborationRoom((state) => state.room); const workloadsEnabled = useCollaborationRoom( (state) => state.features.workloads ); @@ -92,6 +101,9 @@ function useContextMenu() { (state) => state.setImageExporting ); + const linkedNode = useCollaborationRoom((state) => state.linkedNode); + const setLinkedNode = useCollaborationRoom((state) => state.setLinkedNode); + const aiEnabled = useIACapabilities((state) => state.enabled); const setImagesLLMPopupSelectedNodes = useIACapabilities( (state) => state.setImagesLLMPopupSelectedNodes @@ -203,6 +215,16 @@ function useContextMenu() { const singleLocked = nodes.length === 1 && nodes[0].instance.getAttrs().locked; + const isSingleImage = + nodes.length === 1 && nodes[0].node?.type === "image"; + const isSingleImageTemplate = + nodes.length === 1 && nodes[0].node?.type === "image-template"; + + console.log("linkedNode", linkedNode); + + const hasLinkedImageNode = + linkedNode !== null && linkedNode.getAttrs().nodeType === "image"; + if (nodes.length > 0) { // EDIT IMAGE WITH A PROMPT if (!singleLocked) { @@ -289,6 +311,66 @@ function useContextMenu() { }); } } + + // LINK IMAGE TOOLS + if (isSingleImage) { + options.push({ + id: "set-linked-image", + type: "button", + label: ( +
+
Set as template link
+
+ ), + icon: , + onClick: async () => { + setLinkedNode(nodes[0].instance); + setContextMenuShow(false); + toast.success("Image set as template link."); + }, + }); + // SEPARATOR + options.push({ + id: "div-link-image-tools", + type: "divider", + }); + } + + // IMAGE TEMPLATE TOOLS + if (isSingleImageTemplate && hasLinkedImageNode) { + options.push({ + id: "set-image-link", + type: "button", + label: ( +
+
Link image
+
+ ), + icon: , + onClick: async () => { + if (!instance) return; + + const handler = + instance.getNodeHandler("image-template"); + + if (handler) { + handler.setImage( + nodes[0].instance, + linkedNode as WeaveElementInstance + ); + } + + setLinkedNode(null); + setContextMenuShow(false); + }, + }); + // SEPARATOR + options.push({ + id: "div-image-template-tools", + type: "divider", + }); + } + if (!singleLocked) { // EXPORT ON THE SERVER options.push({ @@ -320,6 +402,34 @@ function useContextMenu() { }); } + if (!singleLocked) { + // SAVE AS TEMPLATE + options.push({ + id: "save-as-template", + type: "button", + label: ( +
+
Save as template
+
+ ), + icon: , + disabled: !["selectionTool"].includes(actActionActive ?? ""), + onClick: async () => { + if (!instance) return; + + const template = getSelectionAsTemplate(instance); + + sessionStorage.setItem( + `weave.js_${room}_template`, + JSON.stringify(template) + ); + + toast.success("Selection saved as template."); + setContextMenuShow(false); + }, + }); + } + if (!singleLocked) { // COPY options.push({ @@ -349,6 +459,29 @@ function useContextMenu() { }); } } + options.push({ + id: "create-template-instance", + type: "button", + label: ( +
+
Create template instance here
+
+ ), + icon: , + disabled: !sessionStorage.getItem(`weave.js_${room}_template`), + onClick: () => { + if (!instance) return; + const templateString = sessionStorage.getItem( + `weave.js_${room}_template` + ); + setTemplateOnPosition( + instance, + templateString ? JSON.parse(templateString) : {}, + clickPoint, + stageClickPoint + ); + }, + }); options.push({ id: "paste", type: "button", @@ -655,6 +788,8 @@ function useContextMenu() { // setRemoveBackgroundPopupOriginImage, // setRemoveBackgroundPopupShow, sidebarToggle, + room, + linkedNode, ] ); diff --git a/code/components/room-components/node-properties/fill-properties.tsx b/code/components/room-components/node-properties/fill-properties.tsx index 3ea5a05..57787b0 100644 --- a/code/components/room-components/node-properties/fill-properties.tsx +++ b/code/components/room-components/node-properties/fill-properties.tsx @@ -66,7 +66,9 @@ export function FillProperties() { if ( actualAction && ["selectionTool"].includes(actualAction) && - ["group", "mask", "fuzzy-mask", "text", "frame"].includes(actualNode.type) + ["image-template", "group", "mask", "fuzzy-mask", "text", "frame"].includes( + actualNode.type + ) ) { return null; } diff --git a/code/components/room-components/node-properties/image-template-properties.tsx b/code/components/room-components/node-properties/image-template-properties.tsx new file mode 100644 index 0000000..3bd1eee --- /dev/null +++ b/code/components/room-components/node-properties/image-template-properties.tsx @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import React from "react"; +import { WeaveStateElement } from "@inditextech/weave-types"; +import { useWeave } from "@inditextech/weave-react"; +import { useCollaborationRoom } from "@/store/store"; +import { InputText } from "../inputs/input-text"; + +export function ImageTemplateProperties() { + const instance = useWeave((state) => state.instance); + const node = useWeave((state) => state.selection.node); + const nodes = useWeave((state) => state.selection.nodes); + const actualAction = useWeave((state) => state.actions.actual); + + const nodePropertiesAction = useCollaborationRoom( + (state) => state.nodeProperties.action + ); + + const nodeCreateProps = useCollaborationRoom( + (state) => state.nodeProperties.createProps + ); + + const actualNode = React.useMemo(() => { + if (actualAction && nodePropertiesAction === "create") { + return { + key: "creating", + type: "undefined", + props: { + ...nodeCreateProps, + }, + }; + } + if (node && nodePropertiesAction === "update") { + return node; + } + return undefined; + }, [actualAction, node, nodePropertiesAction, nodeCreateProps]); + + const updateElement = React.useCallback( + (updatedNode: WeaveStateElement) => { + if (!instance) return; + if (actualAction && nodePropertiesAction === "create") { + instance.updatePropsAction(actualAction, updatedNode.props); + } + if (nodePropertiesAction === "update") { + instance.updateNode(updatedNode); + } + }, + [instance, actualAction, nodePropertiesAction] + ); + + if (nodes && nodes.length > 1) return null; + + if (!instance || !actualNode) return null; + + if (!actualAction && !actualNode) return null; + + if ( + actualAction && + ["selectionTool"].includes(actualAction) && + !["image-template"].includes(actualNode.type) + ) { + return null; + } + + if (actualAction && !["selectionTool", "frameTool"].includes(actualAction)) + return null; + + return ( +
+
+
+ + Image Template Properties + +
+
+
+ { + const updatedNode: WeaveStateElement = { + ...actualNode, + props: { + ...actualNode.props, + templateId: value, + }, + }; + updateElement(updatedNode); + }} + /> +
+
+ ); +} diff --git a/code/components/room-components/node-properties/stroke-properties.tsx b/code/components/room-components/node-properties/stroke-properties.tsx index 86a2e9e..76f7f5a 100644 --- a/code/components/room-components/node-properties/stroke-properties.tsx +++ b/code/components/room-components/node-properties/stroke-properties.tsx @@ -78,9 +78,15 @@ export function StrokeProperties() { return null; if ( - ["group", "mask", "fuzzy-mask", "text", "color-token", "frame"].includes( - actualNode.type - ) + [ + "image-template", + "group", + "mask", + "fuzzy-mask", + "text", + "color-token", + "frame", + ].includes(actualNode.type) ) { return null; } diff --git a/code/components/room-components/overlay/hooks/use-node-action-name.tsx b/code/components/room-components/overlay/hooks/use-node-action-name.tsx index 73e659e..b17c1b4 100644 --- a/code/components/room-components/overlay/hooks/use-node-action-name.tsx +++ b/code/components/room-components/overlay/hooks/use-node-action-name.tsx @@ -43,6 +43,8 @@ export const useNodeActionName = () => { return "Text"; case "video": return "Video"; + case "image-template": + return "Image Template"; case "image": return imagesLLMPopupVisible ? "Unknown" : "Image"; case "star": diff --git a/code/components/room-components/overlay/node-properties.tsx b/code/components/room-components/overlay/node-properties.tsx index 56f9c2f..a718a27 100644 --- a/code/components/room-components/overlay/node-properties.tsx +++ b/code/components/room-components/overlay/node-properties.tsx @@ -27,6 +27,7 @@ import { ArrowProperties } from "../node-properties/arrow-properties"; import { RegularPolygonProperties } from "../node-properties/regular-polygon-properties"; import { AlignProperties } from "../node-properties/align-properties"; import { useNodeActionName } from "./hooks/use-node-action-name"; +import { ImageTemplateProperties } from "../node-properties/image-template-properties"; export const NodeProperties = () => { const instance = useWeave((state) => state.instance); @@ -194,6 +195,7 @@ export const NodeProperties = () => { + diff --git a/code/components/room-components/overlay/node-toolbar.tsx b/code/components/room-components/overlay/node-toolbar.tsx index 9c106f0..fb7923c 100644 --- a/code/components/room-components/overlay/node-toolbar.tsx +++ b/code/components/room-components/overlay/node-toolbar.tsx @@ -58,6 +58,9 @@ import { FlipVertical, PaintRoller, RectangleCircle, + Unlink, + Link, + HardDriveUpload, // SquaresSubtract, } from "lucide-react"; import { ShortcutElement } from "../help/shortcut-element"; @@ -78,6 +81,7 @@ import { postNegateImage } from "@/api/post-negate-image"; import { postFlipImage } from "@/api/post-flip-image"; import { postGrayscaleImage } from "@/api/post-grayscale-image"; import { throttle } from "lodash"; +import { ImageTemplateNode } from "@/components/nodes/image-template/image-template"; export const NodeToolbar = () => { const actualNodeRef = React.useRef(undefined); @@ -137,6 +141,9 @@ export const NodeToolbar = () => { (state) => state.images.cropping.enabled ); + const linkedNode = useCollaborationRoom((state) => state.linkedNode); + const setLinkedNode = useCollaborationRoom((state) => state.setLinkedNode); + const updateElement = React.useCallback( (updatedNode: WeaveStateElement) => { if (!instance) return; @@ -525,6 +532,11 @@ export const NodeToolbar = () => { [actualNode] ); + const isImageTemplate = React.useMemo( + () => actualNode && (actualNode.type ?? "") === "image-template", + [actualNode] + ); + const canSetNodeStyling = React.useMemo(() => { return ( isSingleNodeSelected && @@ -747,6 +759,94 @@ export const NodeToolbar = () => { )} + {isImageTemplate && ( + <> + {!actualNode?.props.isUsed && ( + } + disabled={ + !linkedNode || + weaveConnectionStatus !== + WEAVE_STORE_CONNECTION_STATUS.CONNECTED + } + onClick={async () => { + if (!instance) { + return; + } + + const handler = + instance.getNodeHandler( + "image-template" + ); + + const stage = instance.getStage(); + const nodeInstance = stage.findOne( + `#${actualNode?.key ?? ""}` + ); + + if (!handler || !nodeInstance || !linkedNode) { + return; + } + + if (!linkedNode) { + } + + handler.setImage( + nodeInstance as WeaveElementInstance, + linkedNode as WeaveElementInstance + ); + }} + label={ +
+

link image

+
+ } + tooltipSide="bottom" + tooltipAlign="center" + /> + )} + {actualNode?.props.isUsed && ( + } + disabled={ + weaveConnectionStatus !== + WEAVE_STORE_CONNECTION_STATUS.CONNECTED + } + onClick={async () => { + if (!instance) { + return; + } + + const handler = + instance.getNodeHandler( + "image-template" + ); + + const stage = instance.getStage(); + const nodeInstance = stage.findOne( + `#${actualNode?.key ?? ""}` + ); + + if (!handler || !nodeInstance) { + return; + } + + handler.unlink(nodeInstance as WeaveElementInstance); + }} + label={ +
+

unlink image

+
+ } + tooltipSide="bottom" + tooltipAlign="center" + /> + )} + + + )} {!isGroup && canSetNodeStyling && ( { )} {isImage && ( <> + + } + disabled={ + weaveConnectionStatus !== + WEAVE_STORE_CONNECTION_STATUS.CONNECTED + } + onClick={async () => { + if (!instance) { + return; + } + + const stage = instance.getStage(); + const nodeInstance = stage.findOne( + `#${actualNode?.key ?? ""}` + ); + + setLinkedNode(nodeInstance || null); + toast.success("Image set as template link."); + }} + label={ +
+

Set as link

+
+ } + tooltipSide="bottom" + tooltipAlign="center" + /> + {workloadsEnabled && ( <> */} + } + disabled={ + weaveConnectionStatus !== WEAVE_STORE_CONNECTION_STATUS.CONNECTED + } + active={actualAction === "imageTemplateTool"} + onClick={() => triggerTool("imageTemplateTool")} + label={ +
+

Image Template Tool

+
+ } + tooltipSide="top" + tooltipAlign="center" + />
[ }), // new WeaveConnectorNode(), new ColorTokenNode(), + new ImageTemplateNode(), ]; const PLUGINS = (getUser: () => WeaveUser) => [ @@ -566,6 +569,7 @@ const ACTIONS = (getUser: () => WeaveUser) => [ new WeaveArrowToolAction(), new WeaveRegularPolygonToolAction(), new ColorTokenToolAction(), + new ImageTemplateToolAction(), new WeaveTextToolAction(), new WeaveVideoToolAction(), // new WeaveConnectorToolAction(), diff --git a/code/components/utils/templates.ts b/code/components/utils/templates.ts new file mode 100644 index 0000000..35b3fb8 --- /dev/null +++ b/code/components/utils/templates.ts @@ -0,0 +1,171 @@ +import { v4 as uuidv4 } from "uuid"; +import { + containerOverCursor, + getBoundingBox, + Weave, + WeaveNode, + WeaveNodesSelectionPlugin, + WeavePasteModel, + WeaveStageGridPlugin, +} from "@inditextech/weave-sdk"; +import { WeaveStateElement } from "@inditextech/weave-types"; +import Konva from "konva"; + +const getNodesSelectionPlugin = ( + instance: Weave +): WeaveNodesSelectionPlugin | undefined => { + return instance.getPlugin("nodesSelection"); +}; + +const getStageGridPlugin = ( + instance: Weave +): WeaveStageGridPlugin | undefined => { + return instance.getPlugin("stageGrid"); +}; + +export const getSelectionAsTemplate = ( + instance: Weave +): WeavePasteModel | undefined => { + const stage = instance.getStage(); + + stage.container().style.cursor = "default"; + stage.container().focus(); + + const nodesSelectionPlugin = getNodesSelectionPlugin(instance); + const selectedNodes = nodesSelectionPlugin?.getSelectedNodes(); + if (!selectedNodes || selectedNodes.length === 0) { + return; + } + + const box = getBoundingBox(selectedNodes, { + relativeTo: stage, + }); + + const selectionTemplate: WeavePasteModel = { + weaveInstanceId: instance.getId(), + weave: {}, + weaveMinPoint: { x: 0, y: 0 }, + }; + + for (const node of selectedNodes) { + const nodeHandler = instance.getNodeHandler( + node.getAttrs().nodeType + ); + + if (!nodeHandler) { + continue; + } + + const parentNode = node.getParent(); + let parentId = parentNode?.getAttrs().id; + if (parentNode?.getAttrs().nodeId) { + const realParent = instance + .getStage() + .findOne(`#${parentNode.getAttrs().nodeId}`); + if (realParent) { + parentId = realParent.getAttrs().id; + } + } + + if (!parentId) { + continue; + } + + const serializedNode = nodeHandler.serialize(node); + const nodeBox = node.getClientRect({ relativeTo: stage }); + + selectionTemplate.weave[serializedNode.key ?? ""] = { + element: serializedNode, + posRelativeToSelection: { + x: nodeBox.x - (box?.x ?? 0), + y: nodeBox.y - (box?.y ?? 0), + }, + containerId: parentId, + }; + } + + return selectionTemplate; +}; + +const recursivelyUpdateKeys = (nodes: WeaveStateElement[]) => { + for (const child of nodes) { + const newNodeId = uuidv4(); + child.key = newNodeId; + child.props.id = newNodeId; + if (child.props.children) { + recursivelyUpdateKeys(child.props.children); + } + } +}; + +export const setTemplateOnPosition = ( + instance: Weave, + template: WeavePasteModel, + position: Konva.Vector2d, + relativePosition?: Konva.Vector2d +) => { + const stage = instance.getStage(); + const nodesToSelect = []; + + for (const element of Object.keys(template.weave)) { + const node = template.weave[element].element; + const posRelativeToSelection = + template.weave[element].posRelativeToSelection; + let containerId = template.weave[element].containerId; + + if (node.props.children) { + recursivelyUpdateKeys(node.props.children); + } + + const newNodeId = uuidv4(); + delete node.props.containerId; + node.key = newNodeId; + node.props.id = newNodeId; + const container = containerOverCursor(instance, [], relativePosition); + + let localPos = position; + if (!container) { + containerId = instance.getMainLayer()?.getAttrs().id ?? ""; + + const scale = stage.scaleX(); // assume uniform scale + const stagePos = stage.position(); // stage position (pan) + + localPos = { + x: (localPos.x - stagePos.x) / scale, + y: (localPos.y - stagePos.y) / scale, + }; + } + if (container && container.getAttrs().nodeType === "frame") { + containerId = container.getAttrs().id ?? ""; + + localPos = container + .getAbsoluteTransform() + .copy() + .invert() + .point(position); + } + + const nodeHandler = instance.getNodeHandler( + node.props.nodeType ?? "" + ); + + if (nodeHandler) { + const realOffset = nodeHandler.realOffset(node); + + node.props.x = localPos.x + realOffset.x + posRelativeToSelection.x; + node.props.y = localPos.y + realOffset.y + posRelativeToSelection.y; + } + + instance.addNode(node, containerId); + + const realNode = instance.getStage().findOne(`#${newNodeId}`); + if (realNode) { + nodesToSelect.push(realNode); + } + + getStageGridPlugin(instance)?.onRender(); + } + + const nodesSelectionPlugin = getNodesSelectionPlugin(instance); + nodesSelectionPlugin?.setSelectedNodes(nodesToSelect); +}; diff --git a/code/store/store.ts b/code/store/store.ts index 5025f17..ddb343d 100644 --- a/code/store/store.ts +++ b/code/store/store.ts @@ -55,6 +55,7 @@ interface CollaborationRoomState { loading: boolean; error: Error | null; }; + linkedNode: Konva.Node | null; fonts: { loaded: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -192,10 +193,12 @@ interface CollaborationRoomState { setFontsValues: (newValues: { id: string; name: string }[]) => void; setConnectionTestsShow: (newShow: boolean) => void; setBackgroundColor: (newBackgroundColor: BackgroundColor) => void; + setLinkedNode: (newLinkedNode: Konva.Node | null) => void; } export const useCollaborationRoom = create()((set) => ({ backgroundColor: BACKGROUND_COLOR.WHITE, + linkedNode: null, features: { workloads: true, threads: true, @@ -568,4 +571,9 @@ export const useCollaborationRoom = create()((set) => ({ ...state, backgroundColor: newBackgroundColor, })), + setLinkedNode: (newLinkedNode) => + set((state) => ({ + ...state, + linkedNode: newLinkedNode, + })), })); diff --git a/code/weave.d.ts b/code/weave.d.ts index 558bef2..84da34f 100644 --- a/code/weave.d.ts +++ b/code/weave.d.ts @@ -16,6 +16,10 @@ declare module "konva/lib/Node" { updatePosition(position: Vector2d): void; dblClick(): void; movedToContainer(container: Konva.Layer | Konva.Group): void; + canMoveToContainer(node: Konva.Node): boolean; + } + interface Layer { + canMoveToContainer(node: Konva.Node): boolean; } } From f28edbddf5be09b929588c32a0d2df8fc8c63853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Manuel=20Pi=C3=B1eiro=20Cid?= Date: Mon, 17 Nov 2025 16:56:26 +0100 Subject: [PATCH 2/7] chore: support for image templates nodes --- code/api/del-template.ts | 27 + code/api/get-frame-templates.ts | 11 + code/api/get-templates.ts | 15 + code/api/post-template.ts | 39 ++ .../image-template-tool.ts | 3 + .../nodes/image-template/constants.ts | 6 + .../nodes/image-template/image-template.ts | 247 ++++++- code/components/nodes/image-template/types.ts | 5 + .../hooks/use-context-menu.tsx | 83 +-- .../hooks/use-keyboard-handler.tsx | 37 +- .../hooks/use-tasks-events.tsx | 5 + .../hooks/use-tools-events.tsx | 398 ++++++++--- .../room-components/overlay/node-toolbar.tsx | 331 ++++++++- .../room-components/overlay/save-template.tsx | 327 +++++++++ .../overlay/tools-node-overlay-v2.tsx | 243 +++++++ .../overlay/tools-overlay.mouse.tsx | 18 +- .../overlay/tools-overlay.touch.tsx | 15 +- .../room-components/overlay/tools-overlay.tsx | 2 + .../room-components/sidebar-selector.tsx | 14 +- .../templates-library/template.tsx | 80 +++ .../templates-library.actions.tsx | 117 ++++ .../templates-library/templates-library.tsx | 376 ++++++++++ .../templates-library/types.ts | 24 + code/components/room/room.layout.tsx | 4 + code/lib/constants.ts | 1 + code/package-lock.json | 652 +++++++++--------- code/package.json | 6 +- code/store/templates.ts | 25 + 28 files changed, 2563 insertions(+), 548 deletions(-) create mode 100644 code/api/del-template.ts create mode 100644 code/api/get-frame-templates.ts create mode 100644 code/api/get-templates.ts create mode 100644 code/api/post-template.ts create mode 100644 code/components/nodes/image-template/constants.ts create mode 100644 code/components/nodes/image-template/types.ts create mode 100644 code/components/room-components/overlay/save-template.tsx create mode 100644 code/components/room-components/overlay/tools-node-overlay-v2.tsx create mode 100644 code/components/room-components/templates-library/template.tsx create mode 100644 code/components/room-components/templates-library/templates-library.actions.tsx create mode 100644 code/components/room-components/templates-library/templates-library.tsx create mode 100644 code/components/room-components/templates-library/types.ts create mode 100644 code/store/templates.ts diff --git a/code/api/del-template.ts b/code/api/del-template.ts new file mode 100644 index 0000000..4925a83 --- /dev/null +++ b/code/api/del-template.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const delTemplate = async ( + userId: string, + clientId: string, + roomId: string, + templateId: string +) => { + const endpoint = `${process.env.NEXT_PUBLIC_API_ENDPOINT}/${process.env.NEXT_PUBLIC_API_ENDPOINT_HUB_NAME}/rooms/${roomId}/templates/${templateId}`; + const response = await fetch(endpoint, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "x-weave-user-id": userId, + "x-weave-client-id": clientId, + }, + }); + + if (!response.ok) { + throw new Error("Error requesting image deletion."); + } + + const data = await response.json(); + return data; +}; diff --git a/code/api/get-frame-templates.ts b/code/api/get-frame-templates.ts new file mode 100644 index 0000000..90ee55d --- /dev/null +++ b/code/api/get-frame-templates.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const getFrameTemplates = async (roomId: string) => { + const endpoint = `${process.env.NEXT_PUBLIC_API_ENDPOINT}/${process.env.NEXT_PUBLIC_API_ENDPOINT_HUB_NAME}/rooms/${roomId}/templates/frame`; + + const response = await fetch(endpoint); + const data = await response.json(); + return data; +}; diff --git a/code/api/get-templates.ts b/code/api/get-templates.ts new file mode 100644 index 0000000..f7dc6a9 --- /dev/null +++ b/code/api/get-templates.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const getTemplates = async ( + roomId: string, + offset: number = 0, + limit: number = 20 +) => { + const endpoint = `${process.env.NEXT_PUBLIC_API_ENDPOINT}/${process.env.NEXT_PUBLIC_API_ENDPOINT_HUB_NAME}/rooms/${roomId}/templates?offset=${offset}&limit=${limit}`; + + const response = await fetch(endpoint); + const data = await response.json(); + return data; +}; diff --git a/code/api/post-template.ts b/code/api/post-template.ts new file mode 100644 index 0000000..5ed36fb --- /dev/null +++ b/code/api/post-template.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const postTemplate = async ({ + roomId, + name, + linkedNodeType, + templateImage, + templateData, +}: { + roomId: string; + name: string; + linkedNodeType: string | null; + templateImage: string; + templateData: string; +}) => { + const endpoint = `${process.env.NEXT_PUBLIC_API_ENDPOINT}/${process.env.NEXT_PUBLIC_API_ENDPOINT_HUB_NAME}/rooms/${roomId}/templates`; + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + linkedNodeType, + templateImage, + templateData, + }), + }); + + if (!response.ok) { + throw new Error(`Error creating template: ${response.statusText}`); + } + + const data = await response.json(); + + return data; +}; diff --git a/code/components/actions/image-template-tool/image-template-tool.ts b/code/components/actions/image-template-tool/image-template-tool.ts index f74f662..db0e991 100644 --- a/code/components/actions/image-template-tool/image-template-tool.ts +++ b/code/components/actions/image-template-tool/image-template-tool.ts @@ -159,6 +159,9 @@ export class ImageTemplateToolAction extends WeaveAction { y: this.clickPoint.y, width: 100, height: 100, + inUse: false, + lockToContainer: false, + moving: false, }); this.instance.addNode(node, this.container?.getAttrs().id); diff --git a/code/components/nodes/image-template/constants.ts b/code/components/nodes/image-template/constants.ts new file mode 100644 index 0000000..eae478b --- /dev/null +++ b/code/components/nodes/image-template/constants.ts @@ -0,0 +1,6 @@ +export const IMAGE_TEMPLATE_FIT = { + ["COVER"]: "cover", + ["FILL"]: "fill", + ["CONTAIN"]: "contain", + ["FREE"]: "free", +} as const; diff --git a/code/components/nodes/image-template/image-template.ts b/code/components/nodes/image-template/image-template.ts index 7cd97da..fd7d4d8 100644 --- a/code/components/nodes/image-template/image-template.ts +++ b/code/components/nodes/image-template/image-template.ts @@ -14,6 +14,8 @@ import { WeaveStateElement, } from "@inditextech/weave-types"; import Konva from "konva"; +import { ImageTemplateFit } from "./types"; +import { IMAGE_TEMPLATE_FIT } from "./constants"; export const IMAGE_TEMPLATE_NODE_TYPE = "image-template"; @@ -139,6 +141,21 @@ export class ImageTemplateNode extends WeaveNode { return node.getAttr("nodeType") === "image"; }; + if (imageTemplateNode.getAttrs().moving) { + this.instance.emitEvent("onImageTemplateFreed", { + template: imageTemplateNode, + }); + } else { + this.instance.emitEvent("onImageTemplateLocked", { + template: imageTemplateNode, + }); + } + + if (imageTemplateNode.getAttrs().isUsed) { + internalRect.strokeWidth(0); + internalText.visible(false); + } + return imageTemplateNode; } @@ -185,13 +202,133 @@ export class ImageTemplateNode extends WeaveNode { internalRect.strokeWidth(this.borderWidth); internalText.visible(true); } + + if (imageTemplateNode.getAttrs().moving) { + this.instance.emitEvent("onImageTemplateFreed", { + template: imageTemplateNode, + }); + } else { + this.instance.emitEvent("onImageTemplateLocked", { + template: imageTemplateNode, + }); + } } setImage(template: WeaveElementInstance, node: WeaveElementInstance) { moveNodeToContainer(this.instance, node, template); } - link(template: WeaveElementInstance, node: WeaveElementInstance) { + changeFit(template: WeaveElementInstance, fit: ImageTemplateFit) { + const nodeInstance = template as Konva.Group; + + const internalGroup = nodeInstance.findOne( + `#${nodeInstance.getAttr("id")}-imageTemplate-group` + ) as Konva.Group; + + if (!internalGroup) { + return; + } + + this.link( + nodeInstance, + internalGroup.getChildren()[0] as WeaveElementInstance, + fit + ); + } + + freeImage(template: WeaveElementInstance) { + const nodeInstance = template as Konva.Group; + + const internalGroup = nodeInstance.findOne( + `#${nodeInstance.getAttr("id")}-imageTemplate-group` + ) as Konva.Group; + + if (!internalGroup) { + return; + } + + const imageNode = internalGroup.getChildren()[0]; + + const imageHandler = this.instance.getNodeHandler("image"); + + if (!imageHandler) { + return; + } + + imageNode.setAttrs({ + lockToContainer: true, + draggable: true, + listening: true, + }); + + this.instance.updateNode( + imageHandler.serialize(imageNode as WeaveElementInstance) + ); + + template.setAttrs({ + lockToContainer: true, + moving: true, + }); + this.instance.updateNode(this.serialize(template as WeaveElementInstance)); + + this.instance.emitEvent("onImageTemplateFreed", { template }); + + const nodesSelectionPlugin = this.getNodesSelectionPlugin(); + if (nodesSelectionPlugin) { + nodesSelectionPlugin.setSelectedNodes([ + imageNode as WeaveElementInstance, + ]); + } + } + + lockImage(template: WeaveElementInstance) { + const nodeInstance = template as Konva.Group; + + const internalGroup = nodeInstance.findOne( + `#${nodeInstance.getAttr("id")}-imageTemplate-group` + ) as Konva.Group; + + if (!internalGroup) { + return; + } + + const imageNode = internalGroup.getChildren()[0]; + + const imageHandler = this.instance.getNodeHandler("image"); + + if (!imageHandler) { + return; + } + + imageNode.setAttr("lockToContainer", undefined); + imageNode.setAttrs({ + draggable: false, + listening: false, + }); + + this.instance.updateNode( + imageHandler.serialize(imageNode as WeaveElementInstance) + ); + + template.setAttrs({ + lockToContainer: false, + moving: false, + }); + this.instance.updateNode(this.serialize(template as WeaveElementInstance)); + + this.instance.emitEvent("onImageTemplateLocked", { template }); + + const nodesSelectionPlugin = this.getNodesSelectionPlugin(); + if (nodesSelectionPlugin) { + nodesSelectionPlugin.setSelectedNodes([template as WeaveElementInstance]); + } + } + + link( + template: WeaveElementInstance, + node: WeaveElementInstance, + fit: ImageTemplateFit = IMAGE_TEMPLATE_FIT.COVER + ) { const stage = this.instance.getStage(); const imageNode = (node as Konva.Group).findOne( `#${node.getAttr("id")}-image` @@ -204,8 +341,16 @@ export class ImageTemplateNode extends WeaveNode { const imageRect = imageNode?.getClientRect({ relativeTo: stage, }); - const iw = imageRect?.width ?? 1; - const ih = imageRect?.height ?? 1; + let iw = imageRect?.width ?? 1; + let ih = imageRect?.height ?? 1; + + let saveOriginalImage = false; + if (template.getAttr("originalImageWidth") === undefined) { + saveOriginalImage = true; + } else { + iw = template.getAttr("originalImageWidth"); + ih = template.getAttr("originalImageHeight"); + } const templateRect = template.getClientRect({ relativeTo: stage, @@ -216,34 +361,78 @@ export class ImageTemplateNode extends WeaveNode { const imageRatio = iw / ih; const groupRatio = gw / gh; - let scale, x, y; - - if (imageRatio > groupRatio) { - // image is wider -> fit height - scale = gh / ih; - x = (gw - iw * scale) / 2; - y = 0; - } else { - // image is taller -> fit width - scale = gw / iw; - x = 0; - y = (gh - ih * scale) / 2; + let scaleX, scaleY, x, y; + + switch (fit) { + case IMAGE_TEMPLATE_FIT.FILL: + // fit both width and height + scaleX = gw / iw; + scaleY = gh / ih; + x = 0; + y = 0; + break; + case IMAGE_TEMPLATE_FIT.CONTAIN: + if (imageRatio > groupRatio) { + // image is wider -> fit width + scaleX = gw / iw; + scaleY = scaleX; + x = 0; + y = (gh - ih * scaleX) / 2; + } else { + // image is taller -> fit height + scaleY = gh / ih; + scaleX = scaleY; + x = (gw - iw * scaleY) / 2; + y = 0; + } + break; + case IMAGE_TEMPLATE_FIT.COVER: + if (imageRatio > groupRatio) { + // image is wider -> fit height + scaleX = gh / ih; + scaleY = scaleX; + x = (gw - iw * scaleX) / 2; + y = 0; + } else { + // image is taller -> fit width + scaleX = gw / iw; + scaleY = scaleX; + x = 0; + y = (gh - ih * scaleX) / 2; + } + break; + case IMAGE_TEMPLATE_FIT.FREE: + x = 0; + y = 0; + scaleX = 1; + scaleY = 1; + break; + default: + break; } const handler = this.instance.getNodeHandler("image"); if (handler) { - node.x(x); - node.y(y); - node.scaleX(scale); - node.scaleY(scale); - node.draggable(false); - node.listening(false); - handler.scaleReset(node as Konva.Group); + node.setAttrs({ + x, + y, + scaleX, + scaleY, + draggable: false, + listening: false, + }); this.instance.updateNode(handler.serialize(node as WeaveElementInstance)); } - template.setAttrs({ isUsed: true }); + if (saveOriginalImage) { + template.setAttrs({ originalImageWidth: iw, originalImageHeight: ih }); + } + + template.setAttrs({ + isUsed: true, + fit, + }); this.instance.updateNode(this.serialize(template as WeaveElementInstance)); } @@ -292,6 +481,10 @@ export class ImageTemplateNode extends WeaveNode { imageNode.moveTo(layerToMove); imageNode.setAbsolutePosition(nodePos); imageNode.rotation(nodeRotation); + imageNode.setAttr("fit", undefined); + imageNode.setAttr("isUsed", undefined); + imageNode.setAttr("lockToContainer", undefined); + imageNode.setAttr("moving", undefined); imageNode.draggable(true); imageNode.listening(true); imageNode.x( @@ -300,6 +493,7 @@ export class ImageTemplateNode extends WeaveNode { imageNode.y( imageNode.y() - (actualImageLayerAttrs?.containerOffsetY ?? 0) ); + imageHandler.scaleReset(imageNode as Konva.Group); const actualNode = imageHandler.serialize( imageNode as WeaveElementInstance @@ -308,7 +502,12 @@ export class ImageTemplateNode extends WeaveNode { this.instance.removeNode(actualNode); this.instance.addNode(actualNode, layerToMoveAttrs?.id); - imageTemplateNode.setAttrs({ isUsed: false }); + imageTemplateNode.setAttr("originalImageWidth", undefined); + imageTemplateNode.setAttr("originalImageHeight", undefined); + imageTemplateNode.setAttr("fit", undefined); + imageTemplateNode.setAttr("isUsed", false); + imageTemplateNode.setAttr("lockToContainer", false); + imageTemplateNode.setAttr("moving", false); this.instance.updateNode( this.serialize(imageTemplateNode as WeaveElementInstance) ); diff --git a/code/components/nodes/image-template/types.ts b/code/components/nodes/image-template/types.ts new file mode 100644 index 0000000..17030eb --- /dev/null +++ b/code/components/nodes/image-template/types.ts @@ -0,0 +1,5 @@ +import { IMAGE_TEMPLATE_FIT } from "./constants"; + +export type ImageTemplateFitKeys = keyof typeof IMAGE_TEMPLATE_FIT; +export type ImageTemplateFit = + (typeof IMAGE_TEMPLATE_FIT)[ImageTemplateFitKeys]; diff --git a/code/components/room-components/hooks/use-context-menu.tsx b/code/components/room-components/hooks/use-context-menu.tsx index 35f60c3..c855db1 100644 --- a/code/components/room-components/hooks/use-context-menu.tsx +++ b/code/components/room-components/hooks/use-context-menu.tsx @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: Apache-2.0 -// import { v4 as uuidv4 } from "uuid"; import { toast } from "sonner"; import { WeaveContextMenuPlugin, @@ -17,7 +16,6 @@ import { useWeave } from "@inditextech/weave-react"; import { ContextMenuOption } from "../context-menu"; import { ShortcutElement } from "../help/shortcut-element"; import { SYSTEM_OS } from "@/lib/utils"; -// import Konva from "konva"; import { ClipboardCopy, ClipboardPaste, @@ -37,25 +35,17 @@ import { PackagePlus, PackageOpen, } from "lucide-react"; -// import { useMutation } from "@tanstack/react-query"; -// import { postRemoveBackground } from "@/api/post-remove-background"; import { useIACapabilities } from "@/store/ia"; import { useIACapabilitiesV2 } from "@/store/ia-v2"; -// import { postRemoveBackground as postRemoveBackgroundV2 } from "@/api/v2/post-remove-background"; -// import { SIDEBAR_ELEMENTS } from "@/lib/constants"; import { useExportToImageServerSide } from "./use-export-to-image-server-side"; import { getImageBase64 } from "@/components/utils/images"; import { ImageTemplateNode } from "@/components/nodes/image-template/image-template"; -import { - getSelectionAsTemplate, - setTemplateOnPosition, -} from "@/components/utils/templates"; +import { setTemplateOnPosition } from "@/components/utils/templates"; +import { useTemplates } from "@/store/templates"; function useContextMenu() { const instance = useWeave((state) => state.instance); - // const user = useCollaborationRoom((state) => state.user); - // const clientId = useCollaborationRoom((state) => state.clientId); const room = useCollaborationRoom((state) => state.room); const workloadsEnabled = useCollaborationRoom( (state) => state.features.workloads @@ -134,6 +124,10 @@ function useContextMenu() { (state) => state.setImagesLLMPopupImage ); + const setSaveDialogVisible = useTemplates( + (state) => state.setSaveDialogVisible + ); + const sidebarToggle = React.useCallback( (element: SidebarActive) => { setSidebarActive(element); @@ -220,8 +214,6 @@ function useContextMenu() { const isSingleImageTemplate = nodes.length === 1 && nodes[0].node?.type === "image-template"; - console.log("linkedNode", linkedNode); - const hasLinkedImageNode = linkedNode !== null && linkedNode.getAttrs().nodeType === "image"; @@ -414,21 +406,39 @@ function useContextMenu() { ), icon: , disabled: !["selectionTool"].includes(actActionActive ?? ""), - onClick: async () => { - if (!instance) return; - - const template = getSelectionAsTemplate(instance); - - sessionStorage.setItem( - `weave.js_${room}_template`, - JSON.stringify(template) - ); - - toast.success("Selection saved as template."); + onClick: () => { + setSaveDialogVisible(true); setContextMenuShow(false); }, }); } + options.push({ + id: "create-template-instance", + type: "button", + label: ( +
+
Add template instance here
+
+ ), + icon: , + disabled: !sessionStorage.getItem(`weave.js_${room}_template`), + onClick: () => { + if (!instance) return; + const templateString = sessionStorage.getItem( + `weave.js_${room}_template` + ); + setTemplateOnPosition( + instance, + templateString ? JSON.parse(templateString) : {}, + clickPoint, + stageClickPoint + ); + }, + }); + options.push({ + id: "div-templates", + type: "divider", + }); if (!singleLocked) { // COPY @@ -459,29 +469,6 @@ function useContextMenu() { }); } } - options.push({ - id: "create-template-instance", - type: "button", - label: ( -
-
Create template instance here
-
- ), - icon: , - disabled: !sessionStorage.getItem(`weave.js_${room}_template`), - onClick: () => { - if (!instance) return; - const templateString = sessionStorage.getItem( - `weave.js_${room}_template` - ); - setTemplateOnPosition( - instance, - templateString ? JSON.parse(templateString) : {}, - clickPoint, - stageClickPoint - ); - }, - }); options.push({ id: "paste", type: "button", diff --git a/code/components/room-components/hooks/use-keyboard-handler.tsx b/code/components/room-components/hooks/use-keyboard-handler.tsx index 7deb01f..3bdb0cf 100644 --- a/code/components/room-components/hooks/use-keyboard-handler.tsx +++ b/code/components/room-components/hooks/use-keyboard-handler.tsx @@ -177,7 +177,14 @@ export function useKeyboardHandler() { triggerTool("connectorTool"); } - if (event.code === "KeyT") { + if ( + event.code === "KeyT" && + !event.shiftKey && + !event.altKey && + !([SYSTEM_OS.MAC as string].includes(os) + ? event.metaKey + : event.ctrlKey) + ) { event.preventDefault(); triggerTool("textTool"); } @@ -408,41 +415,67 @@ export function useKeyboardHandler() { if ( event.code === "KeyI" && + event.shiftKey && event.altKey && ([SYSTEM_OS.MAC as string].includes(os) ? event.metaKey : event.ctrlKey) ) { + event.preventDefault(); + event.stopPropagation(); sidebarToggle(SIDEBAR_ELEMENTS.images); } if ( event.code === "KeyV" && + event.shiftKey && event.altKey && ([SYSTEM_OS.MAC as string].includes(os) ? event.metaKey : event.ctrlKey) ) { + event.preventDefault(); + event.stopPropagation(); sidebarToggle(SIDEBAR_ELEMENTS.videos); } if ( - event.code === "KeyO" && + event.code === "KeyC" && + event.shiftKey && event.altKey && ([SYSTEM_OS.MAC as string].includes(os) ? event.metaKey : event.ctrlKey) ) { + event.preventDefault(); + event.stopPropagation(); sidebarToggle(SIDEBAR_ELEMENTS.colorTokens); } if ( event.code === "KeyF" && + event.shiftKey && event.altKey && ([SYSTEM_OS.MAC as string].includes(os) ? event.metaKey : event.ctrlKey) ) { + event.preventDefault(); + event.stopPropagation(); sidebarToggle(SIDEBAR_ELEMENTS.frames); } + if ( + event.code === "KeyT" && + event.shiftKey && + event.altKey && + ([SYSTEM_OS.MAC as string].includes(os) ? event.metaKey : event.ctrlKey) + ) { + event.preventDefault(); + event.stopPropagation(); + sidebarToggle(SIDEBAR_ELEMENTS.templates); + } + if ( event.code === "KeyE" && + event.shiftKey && event.altKey && ([SYSTEM_OS.MAC as string].includes(os) ? event.metaKey : event.ctrlKey) ) { + event.preventDefault(); + event.stopPropagation(); sidebarToggle(SIDEBAR_ELEMENTS.nodesTree); } diff --git a/code/components/room-components/hooks/use-tasks-events.tsx b/code/components/room-components/hooks/use-tasks-events.tsx index 9b5d44c..bcc8fa3 100644 --- a/code/components/room-components/hooks/use-tasks-events.tsx +++ b/code/components/room-components/hooks/use-tasks-events.tsx @@ -84,6 +84,11 @@ export const useTasksEvents = () => { queryClient.invalidateQueries({ queryKey }); } + if (["saveTemplate", "deleteTemplate"].includes(type)) { + const queryKey = ["getTemplates", room]; + queryClient.invalidateQueries({ queryKey }); + } + if (["deleteVideo"].includes(type)) { const queryKey = ["getVideos", room]; queryClient.invalidateQueries({ queryKey }); diff --git a/code/components/room-components/hooks/use-tools-events.tsx b/code/components/room-components/hooks/use-tools-events.tsx index 6536572..568c6ea 100644 --- a/code/components/room-components/hooks/use-tools-events.tsx +++ b/code/components/room-components/hooks/use-tools-events.tsx @@ -6,10 +6,21 @@ import React from "react"; import { toast } from "sonner"; import { SidebarActive, useCollaborationRoom } from "@/store/store"; import { useWeave } from "@inditextech/weave-react"; -import { WeaveActionPropsChangeEvent } from "@inditextech/weave-sdk"; +import { + WeaveActionPropsChangeEvent, + WeaveFrameToolAction, +} from "@inditextech/weave-sdk"; import { useIsTouchDevice } from "./use-is-touch-device"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { RectangleHorizontal, RectangleVertical } from "lucide-react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, @@ -20,11 +31,22 @@ import { cn } from "@/lib/utils"; import { ToolbarButton } from "../toolbar/toolbar-button"; import { ColorPickerInput } from "../inputs/color-picker"; import { Button } from "@/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; +import { getFrameTemplates } from "@/api/get-frame-templates"; +import { TemplateEntity } from "../templates-library/types"; +import Konva from "konva"; +import { setTemplateOnPosition } from "@/components/utils/templates"; const AddFrameToast = () => { const instance = useWeave((state) => state.instance); const weaveConnectionStatus = useWeave((state) => state.connection.status); + const room = useCollaborationRoom((state) => state.room); + + const [frameTemplate, setFrameTemplate] = React.useState<"none" | string>( + "none" + ); + const [templates, setTemplates] = React.useState([]); const [frameKind, setFrameKind] = React.useState<"horizontal" | "vertical">( "horizontal" ); @@ -34,128 +56,282 @@ const AddFrameToast = () => { const isTouchDevice = useIsTouchDevice(); + const query = useQuery({ + queryKey: ["getFrameTemplates", room], + queryFn: async () => { + if (!room) { + return []; + } + + return await getFrameTemplates(room ?? ""); + }, + select: (newData) => newData, // keep shape stable + structuralSharing: true, + }); + + React.useEffect(() => { + if (!instance) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function handleClickStage(e: any) { + if (!instance) return; + + if (frameTemplate === "none") { + return; + } + + const template = templates.find( + (t) => t.templateId === frameTemplate + ) as TemplateEntity; + + if (!template) { + return; + } + + e.cancelBubble = true; + + const position: Konva.Vector2d | null | undefined = instance + .getStage() + .getPointerPosition(); + + if (!position) { + return; + } + + const { mousePoint } = instance.getMousePointer(position); + + setTemplateOnPosition( + instance, + JSON.parse(template.templateData), + mousePoint + ); + + const actionHandler: WeaveFrameToolAction | undefined = + instance.getActionHandler("frameTool"); + + if (actionHandler) { + actionHandler.cleanup(); + } + } + + instance.getStage().on("pointerclick", handleClickStage); + + return () => { + instance.getStage().off("pointerclick", handleClickStage); + }; + }, [instance, frameTemplate, templates]); + + React.useEffect(() => { + if (!query.data) return; + setTemplates(query.data.items); + }, [query.data]); + return ( -
+
{`Select the frame background color and orientation and finally ${isTouchDevice ? "tap" : "click"} on the room to add the frame.`}
-
-
Color
- - +
Template
+ +
+
+
+ Template properties +
+
+
+ + + + } + disabled={ + weaveConnectionStatus !== + WEAVE_STORE_CONNECTION_STATUS.CONNECTED || + frameTemplate !== "none" + } + active={selectBackgroundColor} + onClick={(e) => { + e.preventDefault(); + setSelectBackgroundColor((prev) => !prev); }} + label={ +
+

Background color

+
+ } + tooltipSide="right" + tooltipAlign="center" /> - } - disabled={ - weaveConnectionStatus !== - WEAVE_STORE_CONNECTION_STATUS.CONNECTED - } - active={selectBackgroundColor} - onClick={(e) => { - e.preventDefault(); - setSelectBackgroundColor((prev) => !prev); - }} - label={ -
-

Background color

+ + +
e.preventDefault()} + > + { + setBackgroundColor(color); + + if (!instance) return; + + instance.updatePropsAction("frameTool", { + frameBackground: color, + }); + }} + /> +
- } - tooltipSide="right" - tooltipAlign="center" - /> - - + +
e.preventDefault()} + className={cn("text-[10px] font-inter uppercase", { + ["text-[var(--accent-foreground)] opacity-50"]: + weaveConnectionStatus !== + WEAVE_STORE_CONNECTION_STATUS.CONNECTED || + frameTemplate !== "none", + })} > - { - setBackgroundColor(color); - - if (!instance) return; - - instance.updatePropsAction("frameTool", { - frameBackground: color, - }); - }} - /> - + Background color
-
- +
+ { + if (instance && value === "horizontal") { + setFrameKind("horizontal"); + instance.updatePropsAction("frameTool", { + frameWidth: 1920, + frameHeight: 1080, + }); + } + if (instance && value === "vertical") { + setFrameKind(value); + instance.updatePropsAction("frameTool", { + frameWidth: 1080, + frameHeight: 1920, + }); + } + }} + > + + Horizontal + + + Vertical + + +
- { - if (instance && value === "horizontal") { - setFrameKind("horizontal"); - instance.updatePropsAction("frameTool", { - frameWidth: 1920, - frameHeight: 1080, - }); - } - if (instance && value === "vertical") { - setFrameKind(value); - instance.updatePropsAction("frameTool", { - frameWidth: 1080, - frameHeight: 1920, - }); - } - }} - > - - Horizontal - - - Vertical - -
); diff --git a/code/components/room-components/overlay/node-toolbar.tsx b/code/components/room-components/overlay/node-toolbar.tsx index fb7923c..7622a18 100644 --- a/code/components/room-components/overlay/node-toolbar.tsx +++ b/code/components/room-components/overlay/node-toolbar.tsx @@ -61,7 +61,7 @@ import { Unlink, Link, HardDriveUpload, - // SquaresSubtract, + ImageUpscale, } from "lucide-react"; import { ShortcutElement } from "../help/shortcut-element"; import { cn, SYSTEM_OS } from "@/lib/utils"; @@ -82,12 +82,15 @@ import { postFlipImage } from "@/api/post-flip-image"; import { postGrayscaleImage } from "@/api/post-grayscale-image"; import { throttle } from "lodash"; import { ImageTemplateNode } from "@/components/nodes/image-template/image-template"; +import { IMAGE_TEMPLATE_FIT } from "@/components/nodes/image-template/constants"; export const NodeToolbar = () => { const actualNodeRef = React.useRef(undefined); const observerRef = React.useRef(null); const toolbarRef = React.useRef(null); + const [movingImageTemplate, setMovingImageTemplate] = + React.useState(null); const [nodeFillMenuOpen, setNodeFillMenuOpen] = React.useState(false); const [nodeStrokeWidthMenuOpen, setNodeStrokeWidthMenuOpen] = React.useState(false); @@ -108,6 +111,7 @@ export const NodeToolbar = () => { ] = React.useState(false); const [nodesAlignmentVerticalMenuOpen, setNodesAlignmentVerticalMenuOpen] = React.useState(false); + const [templateFitMenuOpen, setTemplateFitMenuOpen] = React.useState(false); const [isSelecting, setIsSelecting] = React.useState(false); const [isVideoPlaying, setIsVideoPlaying] = React.useState(false); @@ -157,6 +161,43 @@ export const NodeToolbar = () => { [instance, actualAction, nodePropertiesAction] ); + React.useEffect(() => { + if (!instance) return; + + function handleImageTemplateFreed({ template }: { template: Konva.Group }) { + setMovingImageTemplate(template as Konva.Group); + } + + function handleImageTemplateLocked({ + template, + }: { + template: Konva.Group; + }) { + if ( + movingImageTemplate?.getAttrs()?.id === (template.getAttrs().id ?? "") + ) { + setMovingImageTemplate(null); + } + } + + instance.addEventListener("onImageTemplateFreed", handleImageTemplateFreed); + instance.addEventListener( + "onImageTemplateLocked", + handleImageTemplateLocked + ); + + return () => { + instance.removeEventListener( + "onImageTemplateFreed", + handleImageTemplateFreed + ); + instance.removeEventListener( + "onImageTemplateLocked", + handleImageTemplateLocked + ); + }; + }, [instance, movingImageTemplate]); + React.useEffect(() => { if (!instance) return; @@ -537,6 +578,14 @@ export const NodeToolbar = () => { [actualNode] ); + const imageTemplateFit = React.useMemo(() => { + if (!isImageTemplate || !actualNode) { + return undefined; + } + + return actualNode.props.fit; + }, [isImageTemplate, actualNode]); + const canSetNodeStyling = React.useMemo(() => { return ( isSingleNodeSelected && @@ -807,42 +856,262 @@ export const NodeToolbar = () => { /> )} {actualNode?.props.isUsed && ( - } - disabled={ - weaveConnectionStatus !== - WEAVE_STORE_CONNECTION_STATUS.CONNECTED - } - onClick={async () => { - if (!instance) { - return; + <> + + + + } + disabled={ + weaveConnectionStatus !== + WEAVE_STORE_CONNECTION_STATUS.CONNECTED + } + active={nodeStyleMenuOpen} + onClick={(e) => { + e.preventDefault(); + setNodeLayeringMenuOpen(false); + setTemplateFitMenuOpen((prev) => !prev); + }} + label={ +
+

Template fit

+
+ } + tooltipSide="bottom" + tooltipAlign="center" + /> +
+ { + e.preventDefault(); + }} + align="center" + side="bottom" + alignOffset={0} + sideOffset={8} + className="min-w-auto !p-0 font-inter rounded-2xl !border-zinc-200 shadow-none flex flex-row" + > +
+ + + + +
+
+
+ } + disabled={ + weaveConnectionStatus !== + WEAVE_STORE_CONNECTION_STATUS.CONNECTED } + onClick={async () => { + if (!instance) { + return; + } - const handler = - instance.getNodeHandler( - "image-template" + const handler = + instance.getNodeHandler( + "image-template" + ); + + const stage = instance.getStage(); + const nodeInstance = stage.findOne( + `#${actualNode?.key ?? ""}` ); - const stage = instance.getStage(); - const nodeInstance = stage.findOne( - `#${actualNode?.key ?? ""}` - ); + if (!handler || !nodeInstance) { + return; + } - if (!handler || !nodeInstance) { - return; + handler.unlink(nodeInstance as WeaveElementInstance); + }} + label={ +
+

unlink image

+
} - - handler.unlink(nodeInstance as WeaveElementInstance); - }} - label={ -
-

unlink image

-
- } - tooltipSide="bottom" - tooltipAlign="center" - /> + tooltipSide="bottom" + tooltipAlign="center" + /> + )} diff --git a/code/components/room-components/overlay/save-template.tsx b/code/components/room-components/overlay/save-template.tsx new file mode 100644 index 0000000..74094e4 --- /dev/null +++ b/code/components/room-components/overlay/save-template.tsx @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogClose, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import React from "react"; +import { useMutation } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { useTemplates } from "@/store/templates"; +import { useWeave } from "@inditextech/weave-react"; +import { getImageBase64 } from "@/components/utils/images"; +import { WeaveNodesSelectionPlugin } from "@inditextech/weave-sdk"; +import { postTemplate } from "@/api/post-template"; +import { SidebarActive, useCollaborationRoom } from "@/store/store"; +import { getSelectionAsTemplate } from "@/components/utils/templates"; +import { SIDEBAR_ELEMENTS } from "@/lib/constants"; + +export function SaveTemplateDialog() { + const inputRef = React.useRef(null); + + const room = useCollaborationRoom((state) => state.room); + const setSidebarActive = useCollaborationRoom( + (state) => state.setSidebarActive + ); + + const [templateData, setTemplateData] = React.useState(""); + const [templateImage, setTemplateImage] = React.useState( + undefined + ); + const [generatingImagePreview, setGeneratingImagePreview] = + React.useState(false); + const [linkedNodeType, setLinkedNodeType] = React.useState("none"); + const [name, setName] = React.useState(""); + const [saving, setSaving] = React.useState(false); + + const saveDialogVisible = useTemplates((state) => state.saveDialog.visible); + const setSaveDialogVisible = useTemplates( + (state) => state.setSaveDialogVisible + ); + + const instance = useWeave((state) => state.instance); + + const sidebarToggle = React.useCallback( + (element: SidebarActive) => { + setSidebarActive(element); + }, + [setSidebarActive] + ); + + React.useEffect(() => { + if (!instance) return; + + async function getSelectionPreviewImage(): Promise { + if (!instance) { + setTemplateImage(undefined); + return; + } + + const nodesSelectionPlugin = + instance.getPlugin("nodesSelection"); + const selectedNodes = nodesSelectionPlugin?.getSelectedNodes(); + + if (!selectedNodes || selectedNodes.length === 0) { + setTemplateImage(undefined); + return; + } + + setGeneratingImagePreview(true); + const selectionPreview = await getImageBase64({ + instance, + nodes: selectedNodes.map((node) => node.getAttrs().id ?? ""), + options: { + format: "image/png", + padding: 40, + backgroundColor: "#D6D6D6", + pixelRatio: 1, + }, + }); + setGeneratingImagePreview(false); + setTemplateImage(selectionPreview.url); + + const template = getSelectionAsTemplate(instance); + setTemplateData(JSON.stringify(template)); + } + + setTemplateImage(undefined); + getSelectionPreviewImage(); + if (saveDialogVisible) { + setLinkedNodeType("none"); + } + }, [saveDialogVisible, instance]); + + React.useEffect(() => { + if (saveDialogVisible) { + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + if (!generatingImagePreview) { + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + }, [saveDialogVisible, generatingImagePreview]); + + const mutationGenerate = useMutation({ + mutationFn: async ({ + name, + templateImage, + templateData, + }: { + name: string; + templateImage: string; + templateData: string; + }) => { + if (!room) { + throw new Error("Room is not defined"); + } + setSaving(true); + return await postTemplate({ + roomId: room ?? "", + name, + linkedNodeType: linkedNodeType, + templateImage, + templateData, + }); + }, + onSettled: () => { + setSaving(false); + }, + onSuccess: () => { + toast.success("Templates", { + description: "You have successfully save the template.", + }); + + setName(""); + setLinkedNodeType("none"); + setTemplateImage(undefined); + setTemplateData(""); + + setSaveDialogVisible(false); + if (linkedNodeType === "none") { + sidebarToggle(SIDEBAR_ELEMENTS.templates); + } + }, + onError() { + toast.error("Templates", { + description: "Failed to save the template. Please check and try again.", + }); + }, + }); + + const onKeyDown = React.useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (event: any) => { + if (!saveDialogVisible) return; + + if (!templateImage) return; + + if (event.key === "Enter") { + mutationGenerate.mutate({ + name, + templateImage, + templateData, + }); + } + }, + [name, templateImage, templateData, saveDialogVisible, mutationGenerate] + ); + + React.useEffect(() => { + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [onKeyDown]); + + return ( + setSaveDialogVisible(open)} + > +
+ + +
+ + Save as Template + + + + +
+ + Save the selection as a template for future use. + +
+
+
+
+
+ {generatingImagePreview && ( +
+ generating selection thumbnail... +
+ )} + {!generatingImagePreview && ( + + )} +
+ + { + window.weaveOnFieldFocus = true; + }} + onBlurCapture={() => { + window.weaveOnFieldFocus = false; + }} + onChange={(e) => setName(e.target.value)} + className="w-full py-0 h-[40px] rounded-none !text-[14px] !border-black font-normal text-black text-left focus:outline-none bg-transparent shadow-none" + /> + + +
+
+
+ + + + +
+
+
+ ); +} diff --git a/code/components/room-components/overlay/tools-node-overlay-v2.tsx b/code/components/room-components/overlay/tools-node-overlay-v2.tsx new file mode 100644 index 0000000..2ceb690 --- /dev/null +++ b/code/components/room-components/overlay/tools-node-overlay-v2.tsx @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import React from "react"; +import Konva from "konva"; +import { ToolbarButton } from "../toolbar/toolbar-button"; +import { Lock, LockOpen } from "lucide-react"; +import { useWeave } from "@inditextech/weave-react"; +import { Toolbar } from "../toolbar/toolbar"; +import { motion } from "framer-motion"; +import { rightElementVariants } from "./variants"; +import { useCollaborationRoom } from "@/store/store"; +import { + WEAVE_STORE_CONNECTION_STATUS, + WeaveElementInstance, +} from "@inditextech/weave-types"; +import { ImageTemplateNode } from "@/components/nodes/image-template/image-template"; +import { IMAGE_TEMPLATE_FIT } from "@/components/nodes/image-template/constants"; + +export function ToolsNodeOverlayV2() { + const [movingImageTemplate, setMovingImageTemplate] = + React.useState(null); + + const instance = useWeave((state) => state.instance); + const weaveConnectionStatus = useWeave((state) => state.connection.status); + const node = useWeave((state) => state.selection.node); + const nodes = useWeave((state) => state.selection.nodes); + const actualAction = useWeave((state) => state.actions.actual); + + const nodePropertiesAction: "create" | "update" | undefined = + useCollaborationRoom((state) => state.nodeProperties.action); + const showUI = useCollaborationRoom((state) => state.ui.show); + + React.useEffect(() => { + if (!instance) return; + + function handleImageTemplateFreed({ template }: { template: Konva.Group }) { + setMovingImageTemplate(template as Konva.Group); + } + + function handleImageTemplateLocked({ + template, + }: { + template: Konva.Group; + }) { + if ( + movingImageTemplate?.getAttrs()?.id === (template.getAttrs().id ?? "") + ) { + setMovingImageTemplate(null); + } + } + + instance.addEventListener("onImageTemplateFreed", handleImageTemplateFreed); + instance.addEventListener( + "onImageTemplateLocked", + handleImageTemplateLocked + ); + + return () => { + instance.removeEventListener( + "onImageTemplateFreed", + handleImageTemplateFreed + ); + instance.removeEventListener( + "onImageTemplateLocked", + handleImageTemplateLocked + ); + }; + }, [instance, movingImageTemplate]); + + const singleLocked = React.useMemo(() => { + return nodes.length === 1 && nodes[0].instance.getAttrs().locked; + }, [nodes]); + + const actualNode = React.useMemo(() => { + if (node && nodePropertiesAction === "update") { + return node; + } + return undefined; + }, [actualAction, node, nodePropertiesAction]); + + const isImageTemplate = React.useMemo( + () => actualNode && (actualNode.type ?? "") === "image-template", + [actualNode] + ); + + const imageTemplateFit = React.useMemo(() => { + if (!isImageTemplate || !actualNode) { + return undefined; + } + + return actualNode.props.fit; + }, [isImageTemplate, actualNode]); + + const imageTemplateTools = React.useMemo(() => { + const actualNodeTools = []; + + if (!actualNode) { + return []; + } + + if ( + nodes.length === 1 && + ["image-template"].includes(nodes[0].node?.type ?? "") && + !singleLocked && + imageTemplateFit === IMAGE_TEMPLATE_FIT.FREE + ) { + actualNodeTools.push( + + } + disabled={ + weaveConnectionStatus !== WEAVE_STORE_CONNECTION_STATUS.CONNECTED + } + onClick={async () => { + if (!instance) { + return; + } + + const handler = + instance.getNodeHandler("image-template"); + + const stage = instance.getStage(); + const nodeInstance = stage.findOne(`#${actualNode?.key ?? ""}`); + + if (!handler || !nodeInstance) { + return; + } + + handler.freeImage(nodeInstance as WeaveElementInstance); + }} + label={ +
+

Move image

+
+ } + tooltipSide="left" + tooltipAlign="center" + /> +
+ ); + } + + return actualNodeTools; + }, [instance, nodes, actualNode, singleLocked, weaveConnectionStatus]); + + const imageTools = React.useMemo(() => { + const actualNodeTools = []; + + if (!actualNode) { + return []; + } + + if ( + nodes.length === 1 && + ["image"].includes(nodes[0].node?.type ?? "") && + !singleLocked && + nodes[0].node?.props?.lockToContainer + ) { + actualNodeTools.push( + + } + disabled={ + weaveConnectionStatus !== WEAVE_STORE_CONNECTION_STATUS.CONNECTED + } + onClick={async () => { + if (!instance) { + return; + } + + const handler = + instance.getNodeHandler("image-template"); + + const stage = instance.getStage(); + const nodeInstance = stage.findOne(`#${actualNode?.key ?? ""}`); + + if (!handler || !nodeInstance) { + return; + } + + const imageTemplateInstance = nodeInstance + .getParent() + ?.getParent(); + + if (!imageTemplateInstance) { + return; + } + + handler.lockImage(imageTemplateInstance as WeaveElementInstance); + }} + label={ +
+

Lock image

+
+ } + tooltipSide="left" + tooltipAlign="center" + /> +
+ ); + } + + return actualNodeTools; + }, [instance, nodes, actualNode, singleLocked, weaveConnectionStatus]); + + if (!showUI) { + return null; + } + + if (imageTemplateTools.length === 0 && imageTools.length === 0) { + return null; + } + + if (nodes.length >= 2) { + return null; + } + + return ( + + {(imageTemplateTools.length > 0 || imageTools.length > 0) && ( + + {imageTemplateTools} + {imageTools} + + )} + + ); +} diff --git a/code/components/room-components/overlay/tools-overlay.mouse.tsx b/code/components/room-components/overlay/tools-overlay.mouse.tsx index 7994005..5f30d78 100644 --- a/code/components/room-components/overlay/tools-overlay.mouse.tsx +++ b/code/components/room-components/overlay/tools-overlay.mouse.tsx @@ -35,6 +35,7 @@ import { Video, Film, SquareDashed, + LayoutPanelTop, // ChevronsLeftRightEllipsis, } from "lucide-react"; import { @@ -896,6 +897,21 @@ export function ToolsOverlayMouse() { {SYSTEM_OS.MAC ? "⌥ ⌘ F" : "Alt Ctrl F"} + { + setShapesMenuOpen(false); + setStrokesMenuOpen(false); + setImagesMenuOpen(false); + setSidebarsMenuOpen(false); + sidebarToggle(SIDEBAR_ELEMENTS.templates); + }} + > + Templates + + {SYSTEM_OS.MAC ? "⌥ ⌘ T" : "Alt Ctrl T"} + + { @@ -908,7 +924,7 @@ export function ToolsOverlayMouse() { > Color tokens - {SYSTEM_OS.MAC ? "⌥ ⌘ O" : "Alt Ctrl O"} + {SYSTEM_OS.MAC ? "⌥ ⌘ C" : "Alt Ctrl C"} {threadsEnabled && ( diff --git a/code/components/room-components/overlay/tools-overlay.touch.tsx b/code/components/room-components/overlay/tools-overlay.touch.tsx index 3e8abaf..0eb7e29 100644 --- a/code/components/room-components/overlay/tools-overlay.touch.tsx +++ b/code/components/room-components/overlay/tools-overlay.touch.tsx @@ -34,6 +34,7 @@ import { Frame, Video, Film, + LayoutPanelTop, } from "lucide-react"; import { DropdownMenu, @@ -298,7 +299,19 @@ export function ToolsOverlayTouch() { sidebarToggle(SIDEBAR_ELEMENTS.frames); }} > - Frames + Frames + + { + setShapesMenuOpen(false); + setStrokesMenuOpen(false); + setImagesMenuOpen(false); + setSidebarsMenuOpen(false); + sidebarToggle(SIDEBAR_ELEMENTS.templates); + }} + > + Templates (null); @@ -260,6 +261,7 @@ export function ToolsOverlay() { )} {imagesLLMPopupVisibleV2 && } + ); } diff --git a/code/components/room-components/sidebar-selector.tsx b/code/components/room-components/sidebar-selector.tsx index 01b925a..ba7d79f 100644 --- a/code/components/room-components/sidebar-selector.tsx +++ b/code/components/room-components/sidebar-selector.tsx @@ -22,6 +22,7 @@ import { ChevronDown, ChevronUp, MessageCircle, + LayoutPanelTop, } from "lucide-react"; import { SidebarActive, useCollaborationRoom } from "@/store/store"; import { SIDEBAR_ELEMENTS } from "@/lib/constants"; @@ -106,6 +107,17 @@ export const SidebarSelector = ({ title }: Readonly) => { {SYSTEM_OS.MAC ? "⌥ ⌘ F" : "Alt Ctrl F"} + { + sidebarToggle(SIDEBAR_ELEMENTS.templates); + }} + > + Templates + + {SYSTEM_OS.MAC ? "⌥ ⌘ T" : "Alt Ctrl T"} + + { @@ -114,7 +126,7 @@ export const SidebarSelector = ({ title }: Readonly) => { > Color tokens - {SYSTEM_OS.MAC ? "⌥ ⌘ O" : "Alt Ctrl O"} + {SYSTEM_OS.MAC ? "⌥ ⌘ C" : "Alt Ctrl C"} {threadsEnabled && ( diff --git a/code/components/room-components/templates-library/template.tsx b/code/components/room-components/templates-library/template.tsx new file mode 100644 index 0000000..158db68 --- /dev/null +++ b/code/components/room-components/templates-library/template.tsx @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import React from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useWeave } from "@inditextech/weave-react"; +import { useCollaborationRoom } from "@/store/store"; +import { SIDEBAR_ELEMENTS } from "@/lib/constants"; +import { TemplateEntity } from "./types"; +import { cn } from "@/lib/utils"; + +type TemplateProps = { + template: TemplateEntity; + selected: boolean; + showSelection: boolean; + onChange: (checked: string | boolean) => void; +}; + +export const Template = ({ + template, + selected, + showSelection, + onChange, +}: Readonly) => { + const instance = useWeave((state) => state.instance); + + const sidebarLeftActive = useCollaborationRoom( + (state) => state.sidebar.left.active + ); + + if (!instance) { + return null; + } + + if (sidebarLeftActive !== SIDEBAR_ELEMENTS.templates) { + return null; + } + + return ( +
+ +
+
{template.name}
+ {showSelection && ( + { + onChange(checked); + }} + /> + )} +
+ {template.removalJobId !== null && + template.removalStatus !== null && + ["pending", "working"].includes(template.removalStatus) && ( +
+ )} +
+ ); +}; diff --git a/code/components/room-components/templates-library/templates-library.actions.tsx b/code/components/room-components/templates-library/templates-library.actions.tsx new file mode 100644 index 0000000..051f2b4 --- /dev/null +++ b/code/components/room-components/templates-library/templates-library.actions.tsx @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import React from "react"; +import { toast } from "sonner"; +import { useMutation } from "@tanstack/react-query"; +import { Trash } from "lucide-react"; +import { useWeave } from "@inditextech/weave-react"; +import { useCollaborationRoom } from "@/store/store"; +import { SIDEBAR_ELEMENTS } from "@/lib/constants"; +import { TemplateEntity } from "./types"; +import { cn } from "@/lib/utils"; +import { delTemplate } from "@/api/del-template"; + +type TemplatesLibraryActions = { + selectedTemplates: TemplateEntity[]; +}; + +export const TemplatesLibraryActions = ({ + selectedTemplates, +}: Readonly) => { + const instance = useWeave((state) => state.instance); + + const user = useCollaborationRoom((state) => state.user); + const clientId = useCollaborationRoom((state) => state.clientId); + const room = useCollaborationRoom((state) => state.room); + const sidebarLeftActive = useCollaborationRoom( + (state) => state.sidebar.left.active + ); + + const mutationDelete = useMutation({ + mutationFn: async (templateId: string) => { + return await delTemplate( + user?.name ?? "", + clientId ?? "", + room ?? "", + templateId + ); + }, + onMutate: () => { + const toastId = toast.loading("Requesting templates deletion..."); + return { toastId }; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onSettled: (_, __, ___, context: any) => { + if (context?.toastId) { + toast.dismiss(context.toastId); + } + }, + onError() { + toast.error("Error requesting templates deletion."); + }, + }); + + const handleDeleteTemplate = React.useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (template: any) => { + if (!instance) { + return; + } + + mutationDelete.mutate(template.templateId); + }, + [instance, mutationDelete] + ); + + const actions = React.useMemo(() => { + const selectionActions = []; + + if (selectedTemplates.length > 0) { + selectionActions.push( + + ); + } + + return selectionActions; + }, [handleDeleteTemplate]); + + if (!instance) { + return null; + } + + if (sidebarLeftActive !== SIDEBAR_ELEMENTS.templates) { + return null; + } + + return ( +
+
0, + ["w-full justify-center"]: actions.length === 0, + })} + > + {actions.length > 0 ? ( + "SELECTION ACTIONS" + ) : ( + select a template + )} +
+
{actions}
+
+ ); +}; diff --git a/code/components/room-components/templates-library/templates-library.tsx b/code/components/room-components/templates-library/templates-library.tsx new file mode 100644 index 0000000..782e11f --- /dev/null +++ b/code/components/room-components/templates-library/templates-library.tsx @@ -0,0 +1,376 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import React from "react"; +import { toast } from "sonner"; +import { useInView } from "react-intersection-observer"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useMutation, useInfiniteQuery } from "@tanstack/react-query"; +import { SquareCheck, SquareX, Trash2, X } from "lucide-react"; +import { useWeave } from "@inditextech/weave-react"; +import { useCollaborationRoom } from "@/store/store"; +import { SIDEBAR_ELEMENTS } from "@/lib/constants"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SidebarSelector } from "../sidebar-selector"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { cn } from "@/lib/utils"; +import { TemplatesLibraryActions } from "./templates-library.actions"; +import { getTemplates } from "@/api/get-templates"; +import { Template } from "./template"; +import { delTemplate } from "@/api/del-template"; +import { TemplateEntity } from "./types"; +import Konva from "konva"; +import { setTemplateOnPosition } from "@/components/utils/templates"; +// import { eventBus } from "@/components/utils/events-bus"; + +const TEMPLATES_LIMIT = 20; + +export const TemplatesLibrary = () => { + const instance = useWeave((state) => state.instance); + + const [selectedTemplates, setSelectedTemplates] = React.useState< + TemplateEntity[] + >([]); + const [templates, setTemplates] = React.useState([]); + const [showSelection, setShowSelection] = React.useState(false); + + const user = useCollaborationRoom((state) => state.user); + const clientId = useCollaborationRoom((state) => state.clientId); + const room = useCollaborationRoom((state) => state.room); + const sidebarLeftActive = useCollaborationRoom( + (state) => state.sidebar.left.active + ); + const setSidebarActive = useCollaborationRoom( + (state) => state.setSidebarActive + ); + + const mutationDelete = useMutation({ + mutationFn: async (templateId: string) => { + return await delTemplate( + user?.name ?? "", + clientId ?? "", + room ?? "", + templateId + ); + }, + onMutate: () => { + const toastId = toast.loading("Requesting template deletion..."); + return { toastId }; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onSettled: (_, __, ___, context: any) => { + if (context?.toastId) { + toast.dismiss(context.toastId); + } + }, + onError() { + toast.error("Error requesting images deletion."); + }, + }); + + const handleDeleteTemplate = React.useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (template: any) => { + if (!instance) { + return; + } + + mutationDelete.mutate(template.templateId); + }, + [instance, mutationDelete] + ); + + React.useEffect(() => { + if (!instance) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function handleTemplateStageDrop(e: any) { + if (!instance) { + return; + } + + if (window.weaveDragTemplateData) { + instance.getStage().setPointersPositions(e); + const position: Konva.Vector2d | null | undefined = instance + .getStage() + .getPointerPosition(); + // getPositionRelativeToContainerOnPosition(instance); + + if (!position) { + return; + } + + const { mousePoint } = instance.getMousePointer(position); + + setTemplateOnPosition( + instance, + window.weaveDragTemplateData + ? JSON.parse(window.weaveDragTemplateData.templateData) + : {}, + mousePoint + ); + + window.weaveDragTemplateData = undefined; + } + } + + instance.addEventListener("onStageDrop", handleTemplateStageDrop); + + return () => { + instance.removeEventListener("onStageDrop", handleTemplateStageDrop); + }; + }, [instance]); + + const query = useInfiniteQuery({ + queryKey: ["getTemplates", room], + queryFn: async ({ pageParam }) => { + if (!room) { + return []; + } + + return await getTemplates( + room ?? "", + pageParam as number, + TEMPLATES_LIMIT + ); + }, + select: (newData) => newData, // keep shape stable + structuralSharing: true, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const loadedSoFar = allPages.reduce( + (sum, page) => sum + page.items.length, + 0 + ); + if (loadedSoFar < lastPage.total) { + return loadedSoFar; // next offset + } + return undefined; // no more pages + }, + enabled: sidebarLeftActive === "templates", + }); + + React.useEffect(() => { + if (!query.data) return; + setTemplates((prev: TemplateEntity[]) => + (query.data?.pages.flatMap((page) => page.items) ?? []).map( + (newItem: TemplateEntity) => + prev.find( + (oldItem) => + oldItem.templateId === newItem.templateId && + oldItem.updatedAt === newItem.updatedAt + ) || newItem + ) + ); + }, [query.data]); + + const { ref, inView } = useInView({ threshold: 1 }); + + React.useEffect(() => { + if (inView && query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }, [inView, query]); + + const realSelectedImages = React.useMemo(() => { + return selectedTemplates.filter((template) => templates.includes(template)); + }, [selectedTemplates, templates]); + + const handleCheckNone = React.useCallback(() => { + setSelectedTemplates([]); + }, []); + + const handleCheckAll = React.useCallback(() => { + const newSelectedTemplates = []; + + for (const template of templates) { + newSelectedTemplates.push(template); + } + + setSelectedTemplates(newSelectedTemplates); + }, [templates]); + + const handleCheckboxChange = React.useCallback( + (checked: boolean, template: TemplateEntity) => { + let newSelectedTemplates = [...selectedTemplates]; + if (checked) { + newSelectedTemplates.push(template); + } else { + newSelectedTemplates = newSelectedTemplates.filter( + (actTemplate) => actTemplate !== template + ); + } + const unique = [...new Set(newSelectedTemplates)]; + setSelectedTemplates(unique); + }, + [selectedTemplates] + ); + + if (!instance) { + return null; + } + + if (sidebarLeftActive !== SIDEBAR_ELEMENTS.templates) { + return null; + } + + return ( +
+
+
+ +
+
+
+ { + setShowSelection(checked); + }} + className="w-[32px] cursor-pointer" + /> + +
+ +
+
+ + {showSelection && ( +
+
+ SELECTED{" "} + + {realSelectedImages.length} + +
+
+ + +
+
+ )} + +
{ + if (e.target instanceof HTMLImageElement) { + window.weaveDragTemplateData = { + templateData: e.target.dataset.templateData, + }; + } + }} + > + {templates.length === 0 && ( +
+ No templates + + Save a node or a selection of nodes +
+ as a template. +
+
+ )} + {templates.length > 0 && + templates.map((template) => { + const isChecked = selectedTemplates.includes(template); + + const templateComponent = ( +