import { html, css, PropertyValues } from "lit"; import { consume } from "@lit/context"; import { LitElementWw } from "@webwriter/lit"; import { customElement, property, query, queryAll } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; //Shoelace Imports import "@shoelace-style/shoelace/dist/themes/light.css"; import { SlButton, SlDialog, SlIconButton, SlDivider, SlTextarea, SlMenu, SlMenuItem, SlMenuLabel, SlDropdown, SlIcon, } from "@shoelace-style/shoelace"; //@tabler icons import file from "@tabler/icons/filled/file.svg"; import circleArrowRight from "@tabler/icons/filled/circle-arrow-right.svg"; import dotsVertical from "@tabler/icons/outline/dots-vertical.svg"; import zoomIn from "@tabler/icons/outline/zoom-in.svg"; import zoomOut from "@tabler/icons/outline/zoom-out.svg"; import squares from "@tabler/icons/filled/squares.svg"; import arrowsSplit2 from "@tabler/icons/outline/arrows-split-2.svg"; import mapPin from "@tabler/icons/outline/map-pin.svg"; //Drawflow Imports import Drawflow, { DrawflowNode } from "drawflow"; import { style } from "drawflow/dist/drawflow.style.js"; import customDrawflowStyles from "./drawflow.styles"; import styles from "./node-editor.styles"; import { NodeEditorToolbar } from "./toolbar/node-editor-toolbar"; import { NodeEditorHelpMenu } from "./help-menu/node-editor-help-menu"; const NO_CONNECTION_SELECTED = "output_id-input_id-output_class-input_class"; const GRID_SIZE = 40; import { editorState, GamebookEditorState, } from "../../utils/gamebook-editor-state-context"; @customElement("node-editor") export class NodeEditor extends LitElementWw { //registering custom elements used in the Web component static get scopedElements() { return { "sl-button": SlButton, "sl-textarea": SlTextarea, "sl-divider": SlDivider, "sl-dialog": SlDialog, "sl-icon": SlIcon, "sl-icon-button": SlIconButton, "sl-menu": SlMenu, "sl-menu-item": SlMenuItem, "sl-dropdown": SlDropdown, "sl-menu-label": SlMenuLabel, "node-editor-toolbar": NodeEditorToolbar, "node-editor-help-menu": NodeEditorHelpMenu, }; } // Import CSS static styles = [ style, styles, customDrawflowStyles, css` #drawflowEditorDiv { background-image: radial-gradient( circle, #dedede, 1px, transparent 1px ); background-size: ${GRID_SIZE}px ${GRID_SIZE}px; background-color: #fbfbfb; overflow: hidden; cursor: grab; } #drawflowEditorDiv:active { cursor: grabbing; } `, ]; //internal reactive state, not part of the component's API @property({ type: Object, attribute: true, reflect: false }) public accessor editor: Drawflow; @property({ type: String }) accessor editorZoomString = ""; @property({ type: String }) accessor selectedConnection = NO_CONNECTION_SELECTED; @property({ type: Boolean, attribute: true }) accessor connectionStarted = false; @property({ type: Boolean }) accessor backgroundIsDragging = false; @property({ type: Number }) accessor backgroundLastX = 0; @property({ type: Number }) accessor backgroundLastY = 0; @property({ type: Number }) accessor backgroundTranslateX = 0; @property({ type: Number }) accessor backgroundTranslateY = 0; @property({ type: Number }) accessor backgroundScale = 0.45; @property({ type: Number }) accessor backgroundMinScale = 0.5; @property({ type: Number }) accessor backgroundMaxScale = 2; @property({ type: Number }) accessor backgroundScaleFactor = 1.05; @property({ type: Boolean }) accessor nodePasted = false; @query("#drawflowEditorDiv") accessor drawflowEditorDiv; @queryAll('div[id*="node-"]') accessor nodeDivs; @consume({ context: editorState, subscribe: true }) @property({ type: Object, attribute: true, reflect: false }) public accessor editorStore = new GamebookEditorState("Default"); /* */ protected firstUpdated(_changedProperties: any): void { this.editor = new Drawflow(this.drawflowEditorDiv); this.editor.reroute = false; this.editor.reroute_fix_curvature = false; //max scale this.editor.zoom_max = 0.8; //min scale this.editor.zoom_min = 0.25; //scale factor this.editor.zoom_value = 0.05; if (this.editorStore.editorZoom == -1) { this.editor.zoom = this.backgroundScale; } else { this.editor.zoom = this.editorStore.editorZoom; this.onZoom(this.editorStore.editorZoom, 0.2, this.editor.zoom_max); } this.editor.start(); this.editor.zoom_refresh(); if ( this.editorStore.editorPosition.x != undefined && this.editorStore.editorPosition.y != undefined ) { this.editor.canvas_x = this.editorStore.editorPosition.x; this.editor.canvas_y = this.editorStore.editorPosition.y; const drawflowContainer = this.drawflowEditorDiv.querySelector(".drawflow"); if (drawflowContainer) { drawflowContainer.style.transform = `translate(${this.editor.canvas_x}px, ${this.editor.canvas_y}px) scale(${this.editor.zoom})`; } } this._registerEditorEventHandlers(); if (this.editorStore.editorContent == null) { this.addPageNode("First Page", true); } else { let editorContent = this.addHTMLToNodes(this.editorStore.editorContent); this.editor.import(editorContent); if (this.editorStore.selectedNode.id !== -1) { this.programaticallySelectNode( this.editorStore.selectedNode.id.toString() ); } this.dispatchEvent( new CustomEvent("editorInitialized", { bubbles: true, composed: true, }) ); } const nodes = this.editorStore.editorContent.drawflow.Home.data; const originNode = Object.values(nodes).find( (node: any) => node.class === "origin" ); this.moveToNode(originNode as DrawflowNode, false); } protected updated(_changedProperties: PropertyValues): void { if (_changedProperties.has("editorStore")) { //TODO: support undo/redo // let editorContent = this.addHTMLToNodes(this.editorStore.editorContent); // this.editor.import(editorContent); // if (this.editorStore.selectedNode.id !== -1) { // this.programaticallySelectNode( // this.editorStore.selectedNode.id.toString() // ); // } } } /* */ connectedCallback() { super.connectedCallback(); this.shadowRoot?.addEventListener("mousemove", this.onMouseMove.bind(this)); this.shadowRoot?.addEventListener("mousedown", this.onMouseDown.bind(this)); this.shadowRoot?.addEventListener("mouseup", this.onMouseUp.bind(this)); this.shadowRoot?.addEventListener( "mouseleave", this.onMouseLeave.bind(this) ); } /* */ disconnectedCallback() { this.shadowRoot?.removeEventListener( "mousemove", this.onMouseMove.bind(this) ); this.shadowRoot?.removeEventListener( "mousedown", this.onMouseDown.bind(this) ); this.shadowRoot?.removeEventListener("mouseup", this.onMouseUp.bind(this)); this.shadowRoot?.removeEventListener( "mouseleave", this.onMouseLeave.bind(this) ); super.disconnectedCallback(); } /* */ render() { const gridStyles = { backgroundPosition: `${this.backgroundTranslateX}px ${this.backgroundTranslateY}px`, backgroundSize: `${GRID_SIZE * this.backgroundScale}px ${ GRID_SIZE * this.backgroundScale }px`, }; return html`
{ this.addPageNode(e.detail.title, e.detail.isOrigin); }} @addPopUpNode=${(e: CustomEvent) => { this.addPopUpNode(e.detail.title); }} @addBranchNode=${(e: CustomEvent) => { this.addBranchNode(e.detail.title); }} @addTemplate=${(e: CustomEvent) => this.addTemplate(e.detail.template)} @clearDialog=${() => (this.shadowRoot.getElementById("dialog") as SlDialog).show()} >
{ const nodes = this.editorStore.editorContent.drawflow.Home.data; const originNode = Object.values(nodes).find( (node: any) => node.class === "origin" ); this.moveToNode(originNode as DrawflowNode, true); }} > this.editor.zoom_in()} > this.editor.zoom_out()} >

${this.editorZoomString}

Do you want to clear the graph? All your progress will be lost. (this.shadowRoot.getElementById("dialog") as SlDialog).hide()} >Cancel this._clearEditor()} >Clear You are about to delete the node "${this.editorStore.selectedNode.data.title}". Do you want to proceed? ( this.shadowRoot.getElementById("delete_node_dialog") as SlDialog ).hide()} >Abort this.deleteSelectedNode()} >Delete `; } /* */ private onMouseDown(event: MouseEvent) { if ( (event.target as HTMLElement).classList.contains("drawflow") || (event.target as HTMLElement).id === "drawflowEditorDiv" ) { this.backgroundIsDragging = true; this.backgroundLastX = event.clientX; this.backgroundLastY = event.clientY; } } /* */ public onMouseMove(event: MouseEvent) { if ( this.backgroundIsDragging && this.editor.node_selected === null && !this.connectionStarted ) { // Check if node is not selected const dx = event.clientX - this.backgroundLastX; const dy = event.clientY - this.backgroundLastY; this.backgroundTranslateX += dx; this.backgroundTranslateY += dy; this.backgroundLastX = event.clientX; this.backgroundLastY = event.clientY; this.requestUpdate(); } } /* */ private onMouseUp() { if (this.backgroundIsDragging) { this.backgroundIsDragging = false; // this.editorStore.setEditorPosition( // this.editor.canvas_x, // this.editor.canvas_y // ); } } /* */ private onMouseLeave() { if (this.backgroundIsDragging) { this.backgroundIsDragging = false; // If dragging is in progress, stop the dragging action this.editor.drag_point = false; this.editor.drag = false; // Find the Drawflow container (replace with the correct selector if needed) const editorElement = this.drawflowEditorDiv.querySelector(".drawflow"); // Adjust the selector to target the correct element if (editorElement) { // Create and dispatch a fake mouseup event const fakeMouseUpEvent = new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window, }); editorElement.dispatchEvent(fakeMouseUpEvent); // Get the computed style of the element to extract the transform property const computedStyle = window.getComputedStyle(editorElement); const transform = computedStyle.transform; if (transform && transform !== "none") { // Parse the transform matrix values const matrixValues = transform.match(/matrix\((.+)\)/); if (matrixValues && matrixValues[1]) { const values = matrixValues[1].split(", ").map(parseFloat); // The translateX and translateY values are in the 4th and 5th positions in the matrix (index 4 and 5) const translateX = values[4]; const translateY = values[5]; this.editor.canvas_x = translateX; this.editor.canvas_y = translateY; editorElement.style.transform = `translate(${this.editor.canvas_x}px, ${this.editor.canvas_y}px) scale(${this.editor.zoom})`; } } } } } /* */ public onZoom(zoom_value: number, min_zoom: number, max_zoom: number) { const rect = this.shadowRoot!.querySelector( "#drawflowEditorDiv" )!.getBoundingClientRect(); const centerX = rect.width / 2; const centerY = rect.height / 2; // Calculate the new scale from zoom_value const prevScale = this.backgroundScale; this.backgroundScale = zoom_value; // Assuming zoom_value directly represents the new scale // Limit the scale within minScale and maxScale this.backgroundScale = Math.min( Math.max(min_zoom, this.backgroundScale), max_zoom ); // Calculate the scale ratio const scaleRatio = this.backgroundScale / prevScale; // Update translateX and translateY to center the zoom this.backgroundTranslateX = centerX - (centerX - this.backgroundTranslateX) * scaleRatio; this.backgroundTranslateY = centerY - (centerY - this.backgroundTranslateY) * scaleRatio; // Request an update to apply the changes this.requestUpdate(); } /* */ private addHTMLToNodes(editorContent) { let copy = editorContent; if (copy) { Object.values(copy.drawflow.Home.data).forEach((node) => { if ((node as DrawflowNode).class === "page") { (node as DrawflowNode).html = this.createPageNodeHTML(false); } else if ((node as DrawflowNode).class === "origin") { (node as DrawflowNode).html = this.createPageNodeHTML(true); } else if ((node as DrawflowNode).class === "popup") { (node as DrawflowNode).html = this.createPopupNodeHTML(); } else if ((node as DrawflowNode).class === "branch") { (node as DrawflowNode).html = this.createBranchNodeHTML(); } }); } return copy; } /* */ private _clearEditor() { const dialog = this.shadowRoot.getElementById("dialog") as SlDialog; dialog.hide(); this.editor.clear(); this.editorStore.setEditorContent(this.editor.drawflow); this.dispatchEvent( new CustomEvent("editorCleared", { bubbles: true, composed: true, }) ); this.addPageNode("First Page", true); } /* */ public moveToNode(node: DrawflowNode, withAnimation: Boolean) { const { zoom, canvas_x, canvas_y } = this.editor; const { id, pos_x, pos_y } = node; const nodeDiv = Array.from(this.nodeDivs as NodeListOf).find( (div) => parseInt(div.id.split("-")[1], 10).toString() === id.toString() ); const nodeWidth = nodeDiv.offsetWidth; const nodeHeight = nodeDiv.offsetHeight; const drawflowContainer = this.drawflowEditorDiv.querySelector(".drawflow"); const rect = this.drawflowEditorDiv.getBoundingClientRect(); if (drawflowContainer) { // Add the transition class for smooth animation if (withAnimation) { drawflowContainer.classList.add("smooth-transition"); this.drawflowEditorDiv.classList.add("smooth-background-transition"); } // Calculate the center of the origin node const nodeCenterX = pos_x + nodeWidth / 2; const nodeCenterY = pos_y + nodeHeight / 2; // Calculate the position of the editor and the node const nodePosX = nodeCenterX * zoom + canvas_x + (rect.width - rect.width * zoom) / 2; const nodePosY = nodeCenterY * zoom + canvas_y + (rect.height - rect.height * zoom) / 2; // Calculate the translation required to center the node this.editor.canvas_x -= nodePosX - rect.width / 2; this.editor.canvas_y -= nodePosY - rect.height / 2; drawflowContainer.style.transform = `translate(${this.editor.canvas_x}px, ${this.editor.canvas_y}px) scale(${zoom})`; this.backgroundLastX = this.backgroundTranslateX; this.backgroundLastY = this.backgroundTranslateY; this.backgroundTranslateX -= nodePosX - rect.width / 2; this.backgroundTranslateY -= nodePosY - rect.height / 2; this.requestUpdate(); if (withAnimation) { // // // Optionally, remove the transition class after the animation is done setTimeout(() => { drawflowContainer.classList.remove("smooth-transition"); this.drawflowEditorDiv.classList.remove( "smooth-background-transition" ); }, 350); // Adjust the timeout duration to match your animation duration } // this.editorStore.setEditorPosition( // this.editor.canvas_x, // this.editor.canvas_y // ); } } /* */ public pasteNode() { const copiedNode = this.editorStore.copiedNode; if (copiedNode.id !== -1) { this.nodePasted = true; const titleCopy = `${copiedNode.data.title} copy`; switch (copiedNode.class) { case "page": case "origin": this.addPageNode(titleCopy, false); break; case "popup": this.addPopUpNode(titleCopy); break; case "branch": this.addBranchNode(titleCopy); break; } } } /* */ private _registerEditorEventHandlers() { // Event listener for node click this.editor.on("nodeSelected", (id) => { this.dispatchEvent( new CustomEvent("nodeSelected", { detail: { nodeId: id }, bubbles: true, composed: true, }) ); }); // Event listener for node unselected this.editor.on("nodeUnselected", (boolean) => { this.dispatchEvent( new CustomEvent("nodeUnselected", { bubbles: true, composed: true, }) ); }); //Event listerner for creation of a node this.editor.on("nodeCreated", (id) => { this.editorStore.setEditorContent(this.editor.drawflow); let createdNode = this.editor.getNodeFromId(id); if (this.nodePasted == false) { this.dispatchEvent( new CustomEvent("nodeCreated", { detail: { node: createdNode }, bubbles: true, composed: true, }) ); } else { this.nodePasted = false; this.dispatchEvent( new CustomEvent("nodePasted", { detail: { node: createdNode }, bubbles: true, composed: true, }) ); } }); //Event listener for deletion of a node this.editor.on("nodeRemoved", (id) => { this.dispatchEvent( new CustomEvent("nodeRemoved", { detail: { id: id }, bubbles: true, composed: true, }) ); this.editorStore.setEditorContent(this.editor.drawflow); }); //Event listener for when a node got moved this.editor.on("nodeMoved", (id) => { this.editorStore.setEditorContent(this.editor.drawflow); }); // // //Event listener for when a connection creation started via drag and drop this.editor.on("connectionStart", ({ output_id, output_class }) => { this.programaticallySelectNode(output_id); this.dispatchEvent( new CustomEvent("nodeSelected", { detail: { nodeId: output_id }, bubbles: true, composed: true, }) ); this.connectionStarted = true; this.shadowRoot .querySelector('svg[class="connection"]') ?.querySelector("path") ?.setAttribute("highlighted", "true"); }); // // this.editor.on("connectionCancel", () => { this.connectionStarted = false; }); //Event listener for when a connection is selected this.editor.on( "connectionSelected", ({ output_id, input_id, output_class, input_class }) => { const outputNode = this.editor.getNodeFromId(output_id); const inputNode = this.editor.getNodeFromId(input_id); this.selectedConnection = `${output_id}-${input_id}-${output_class}-${input_class}`; this.dispatchEvent( new CustomEvent("nodeSelected", { detail: { nodeId: output_id }, bubbles: true, composed: true, }) ); this._highlightConnection( output_id, input_id, output_class, input_class ); this.dispatchEvent( new CustomEvent("connectionSelected", { detail: { outputNode: outputNode, inputNode: inputNode, outputClass: output_class, inputClass: input_class, }, bubbles: true, composed: true, }) ); } ); //event listener for when a connection is unselected this.editor.on("connectionUnselected", (boolean) => { const parsedConnection = this.parseConnectionIdentifier( this.selectedConnection ); this._unhighlightConnection( parsedConnection.outputNodeId, parsedConnection.inputNodeId, parsedConnection.outputClass, parsedConnection.inputClass ); const outputNode = this.editor.getNodeFromId( parsedConnection.outputNodeId ); const inputNode = this.editor.getNodeFromId(parsedConnection.inputNodeId); this.selectedConnection = NO_CONNECTION_SELECTED; this.dispatchEvent( new CustomEvent("connectionUnselected", { detail: { outputNode: outputNode, inputNode: inputNode, outputClass: parsedConnection.outputClass, inputClass: parsedConnection.inputClass, }, bubbles: true, composed: true, }) ); this.dispatchEvent( new CustomEvent("nodeUnselected", { bubbles: true, composed: true, }) ); }); //Event for created connections done e.g. via drag and drop this.editor.on( "connectionCreated", ({ output_id, input_id, output_class, input_class }) => { this.connectionStarted = false; const outputNode = this.editor.getNodeFromId(output_id); const inputNode = this.editor.getNodeFromId(input_id); this.editorStore.setEditorContent(this.editor.drawflow); this.editorStore.setSelectedNode(this.editor.getNodeFromId(output_id)); this.shadowRoot .querySelector( `svg.connection.node_in_node-${input_id}.node_out_node-${output_id}.${output_class}.${input_class}` ) ?.querySelector("path") ?.removeAttribute("highlighted"); const removeConnection = () => this.editor.removeSingleConnection( outputNode.id, inputNode.id, output_class, input_class ); const triggerEvent = (eventName) => this.dispatchEvent( new CustomEvent(eventName, { detail: { outputNode, inputNode, outputClass: output_class, inputClass: input_class, }, bubbles: true, composed: true, }) ); // Branch node checks if (inputNode.class === "branch") { if (inputNode.inputs["input_1"]?.connections?.length > 1) { console.error("The branch node is already connected"); removeConnection(); } else if (outputNode.class === "branch") { console.error("Connecting branch nodes is not allowed."); removeConnection(); } else { triggerEvent("nodeConnectedToBranchNode"); } } // Output node is a branch else if (outputNode.class === "branch") { if ( inputNode.id === Number(outputNode.inputs["input_1"]?.connections[0]?.node) ) { console.error("Self loops are not allowed."); removeConnection(); } else { triggerEvent("branchNodeConnected"); } } // General case else { triggerEvent("nodesConnected"); } } ); this.editor.on( "connectionRemoved", ({ output_id, input_id, output_class, input_class }) => { const outputNode = this.editor.getNodeFromId(output_id); const inputNode = this.editor.getNodeFromId(input_id); const isBranchInput = inputNode.class === "branch"; const isBranchOutput = outputNode.class === "branch"; this.editorStore.setEditorContent(this.editor.drawflow); if (this.selectedConnection !== NO_CONNECTION_SELECTED) { const { outputNodeId, inputNodeId, outputClass, inputClass } = this.parseConnectionIdentifier(this.selectedConnection); this._unhighlightConnection( outputNodeId, inputNodeId, outputClass, inputClass ); this.selectedConnection = NO_CONNECTION_SELECTED; } if (isBranchOutput) { this.dispatchEvent( new CustomEvent("branchNodeConnectionRemoved", { detail: { outputNode: outputNode, outputClass: output_class }, bubbles: true, composed: true, }) ); } // else { const eventDetail = { outputNode: outputNode, outputClass: output_class, inputNode: inputNode, inputClass: input_class, }; this.dispatchEvent( new CustomEvent("connectionRemoved", { detail: eventDetail, bubbles: true, composed: true, }) ); } this.editorStore.setSelectedNode(this.editor.getNodeFromId(output_id)); } ); //event listener for when the user zoomed into the editor this.editor.on("zoom", (zoom_level) => { //NOTE: Usually this.editor.zoom_min should have been supplied here, however drawflow has an error in which the minimum gets undercut. //This results in faulty calculation for zooming into the background, so we hardcode it here. //Issue report drawflow: https://github.com/jerosoler/Drawflow/issues/883#issuecomment-2238986045 this.onZoom(zoom_level, 0.2, this.editor.zoom_max); //Attention: Due to floating errors in the drawflow framework, we hardcoded the actual zoom_min of 0.1. //Although it is set to 0.2 in the firstUpdated() method, values of 0.1 are produced const range = this.editor.zoom_max - 0.2; const percentage = (((zoom_level - 0.2) / range) * 100).toFixed(0); this.editorZoomString = percentage + "%"; const zoomValue = this.shadowRoot.querySelector( ".zoomValue" ) as HTMLElement; if (zoomValue) { zoomValue.classList.remove("fade-in-out"); // Trigger reflow to restart the animation void zoomValue.offsetWidth; zoomValue.classList.add("fade-in-out"); } //this.editorStore.setEditorZoom(zoom_level); // this.editorStore.setEditorPosition( // this.editor.canvas_x, // this.editor.canvas_y // ); }); } /* */ public addPageNode(title: string, isOrigin: boolean) { const nodeData = { title: title, }; const nodeHTML = this.createPageNodeHTML(isOrigin); const editorDivCenterPos = this.getCenterOfEditorDiv(); this.editor.addNode( title, 1, 1, editorDivCenterPos.centerX - 320 / 2, editorDivCenterPos.centerY - 109 / 2, isOrigin ? "origin" : "page", nodeData, nodeHTML, false ); } /* */ public addPopUpNode(title: string) { const nodeData = { title: title, }; const nodeHTML = this.createPopupNodeHTML(); const editorDivCenterPos = this.getCenterOfEditorDiv(); this.editor.addNode( title, 1, 1, editorDivCenterPos.centerX - 320 / 2, editorDivCenterPos.centerY - 109 / 2, "popup", nodeData, nodeHTML, false ); } /* */ public addBranchNode(title: string) { const nodeData = { title: title, }; const nodeHTML = this.createBranchNodeHTML(); const editorDivCenterPos = this.getCenterOfEditorDiv(); this.editor.addNode( title, 1, 0, editorDivCenterPos.centerX - 320 / 2, editorDivCenterPos.centerY - 109 / 2, "branch", nodeData, nodeHTML, false ); } /* */ private getCenterOfEditorDiv() { //get current center of drawflow div const rect = this.drawflowEditorDiv.getBoundingClientRect(); const zoom = this.editor.zoom; //center of canvas - translation of canvas / zoom - node dimension center const centerX = rect.width / 2 - this.editor.canvas_x / zoom; const centerY = rect.height / 2 - this.editor.canvas_y / zoom; return { centerX, centerY }; } /* */ public createPageNodeHTML(isOrigin: boolean) { // Create the container div and its child elements const containerDiv = document.createElement("div"); containerDiv.classList.add("container"); // Create the page icon const iconDiv = document.createElement("div"); iconDiv.classList.add("iconDiv"); const icon = document.createElement("sl-icon") as SlIcon; icon.setAttribute("src", file); icon.classList.add("pageIcon"); iconDiv.appendChild(icon); containerDiv.appendChild(iconDiv); // Create the content div with input const contentDiv = document.createElement("div"); contentDiv.classList.add("content"); const input = document.createElement("input"); input.id = "title"; input.setAttribute("df-title", ""); // Adding df-title attribute contentDiv.appendChild(input); // Add origin badge or input label const nameLabel = document.createElement("p"); if (isOrigin) { const badge = document.createElement("div"); badge.classList.add("badge"); const arrowIcon = document.createElement("sl-icon") as SlIcon; arrowIcon.setAttribute("src", circleArrowRight); badge.appendChild(arrowIcon); nameLabel.textContent = "Start Page"; badge.appendChild(nameLabel); contentDiv.appendChild(badge); } else { nameLabel.classList.add("input-label"); nameLabel.textContent = "Page"; // Set the text content of the label contentDiv.appendChild(nameLabel); } containerDiv.appendChild(contentDiv); // Add three dots icon const threeDotsIcon = document.createElement("sl-icon") as SlIcon; threeDotsIcon.setAttribute("src", dotsVertical); threeDotsIcon.classList.add("threeDots"); containerDiv.appendChild(threeDotsIcon); const containerHtml = containerDiv.outerHTML; return containerHtml; } /* */ public createPopupNodeHTML() { // Create the container div const containerDiv = document.createElement("div"); containerDiv.classList.add("container"); // Create page sl-icon const iconDiv = document.createElement("div"); iconDiv.classList.add("iconDiv"); const icon = document.createElement("sl-icon") as SlIcon; icon.setAttribute("src", squares); icon.classList.add("pageIcon"); iconDiv.appendChild(icon); containerDiv.appendChild(iconDiv); // const contentDiv = document.createElement("div"); contentDiv.classList.add("content"); const input = document.createElement("input"); input.id = "title"; input.setAttribute("df-title", ""); // Adding df-title attribute contentDiv.appendChild(input); //Add label to the input for the nodes name const nameLabel = document.createElement("p"); nameLabel.classList.add("input-label"); nameLabel.textContent = "Popup"; // Set the text content of the label contentDiv.appendChild(nameLabel); containerDiv.appendChild(contentDiv); // Add three dots iccon const threeDotsIcon = document.createElement("sl-icon") as SlIcon; threeDotsIcon.setAttribute("src", dotsVertical); threeDotsIcon.classList.add("threeDots"); containerDiv.appendChild(threeDotsIcon); const containerHtml = containerDiv.outerHTML; return containerHtml; } /* */ public createBranchNodeHTML() { // Create the container div const containerDiv = document.createElement("div"); containerDiv.classList.add("container"); // Create page sl-icon const iconDiv = document.createElement("div"); iconDiv.classList.add("iconDiv"); const icon = document.createElement("sl-icon") as SlIcon; icon.setAttribute("src", arrowsSplit2); icon.classList.add("pageIcon"); iconDiv.appendChild(icon); containerDiv.appendChild(iconDiv); const contentDiv = document.createElement("div"); contentDiv.classList.add("content"); const input = document.createElement("input"); input.id = "title"; input.setAttribute("df-title", ""); // Adding df-title attribute contentDiv.appendChild(input); //Add label to the input for the nodes name const nameLabel = document.createElement("p"); nameLabel.classList.add("input-label"); nameLabel.textContent = "Branch"; // Set the text content of the label contentDiv.appendChild(nameLabel); containerDiv.appendChild(contentDiv); // Add three dots iccon const threeDotsIcon = document.createElement("sl-icon") as SlIcon; threeDotsIcon.setAttribute("src", dotsVertical); threeDotsIcon.classList.add("threeDots"); containerDiv.appendChild(threeDotsIcon); const containerHtml = containerDiv.outerHTML; return containerHtml; } /* */ public _highlightConnection( outputNodeId, inputNodeId, outputClass, inputClass ) { const inputNodeClass = this.editor.getNodeFromId(inputNodeId).class; [ ["output", outputNodeId, outputClass], ["input", inputNodeId, inputClass], ].forEach(([type, nodeId, cls]) => { const nodeDiv = this.shadowRoot?.getElementById(`node-${nodeId}`); if (nodeDiv) { nodeDiv .querySelector(`.${type}.${cls}`) .setAttribute(`highlighted`, "true"); } }); this.shadowRoot .querySelector( `svg[class="connection node_in_node-${inputNodeId} node_out_node-${outputNodeId} ${outputClass} ${inputClass}"]` ) ?.querySelector("path") ?.setAttribute(`highlighted`, "true"); } /* */ public _unhighlightConnection( outputNodeId, inputNodeId, outputClass, inputClass ) { const inputNodeClass = this.editor.getNodeFromId(inputNodeId).class; [ ["output", outputNodeId, outputClass], ["input", inputNodeId, inputClass], ].forEach(([type, nodeId, cls]) => { const nodeDiv = this.shadowRoot?.getElementById(`node-${nodeId}`); if (nodeDiv) { nodeDiv .querySelector(`.${type}.${cls}`) ?.removeAttribute("highlighted"); } }); this.shadowRoot .querySelector( `svg[class="connection node_in_node-${inputNodeId} node_out_node-${outputNodeId} ${outputClass} ${inputClass}"]` ) ?.querySelector("path") ?.removeAttribute("highlighted"); } /* */ public _highlightNode(nodeId) { const selector = `div#node-${nodeId}.drawflow-node`; this.shadowRoot .querySelector(selector) ?.setAttribute("highlighted", "true"); } /* */ public _unhighlightNode(nodeId) { const selector = `div#node-${nodeId}.drawflow-node`; this.shadowRoot.querySelector(selector)?.removeAttribute("highlighted"); } /* */ public _highlightOutput(outputNodeId, outputClass) { [["output", outputNodeId, outputClass]].forEach(([type, nodeId, cls]) => { this.shadowRoot ?.getElementById(`node-${nodeId}`) ?.querySelector(`.${type}.${cls}`) ?.setAttribute("highlighted", "true"); }); } /* */ public _unhighlightOutput(outputNodeId, outputClass) { [["output", outputNodeId, outputClass]].forEach(([type, nodeId, cls]) => { this.shadowRoot ?.getElementById(`node-${nodeId}`) ?.querySelector(`.${type}.${cls}`) ?.removeAttribute("highlighted"); }); } public unhighlightAllOutputs() { Array.from(this.nodeDivs as NodeListOf).forEach((nodeDiv) => { const outputs = nodeDiv.querySelectorAll(".output"); outputs.forEach((output) => { output.removeAttribute("highlighted"); }); }); } /* */ private addTemplate(template) { var currentNodes = this.editor.export(); let currentNodesWithHTML = this.addHTMLToNodes(currentNodes); // Create a deep copy of the nodeTemplates let nodeTemplatesCopy = JSON.parse(JSON.stringify(template)); // Assuming you have the following from the drawflow editor: const rect = this.drawflowEditorDiv.getBoundingClientRect(); const zoom = this.editor.zoom; const centerX = rect.width / 2 - this.editor.canvas_x / zoom - 317 / 2; const centerY = rect.height / 2 - this.editor.canvas_y / zoom - 105 / 2; // Move nodes to the center of the canvas this.moveNodesToCenter(nodeTemplatesCopy, centerX, centerY); nodeTemplatesCopy = this.addHTMLToNodes(nodeTemplatesCopy); const mergedData = this.mergeTemplate( currentNodesWithHTML, nodeTemplatesCopy ); // this.editor.import(mergedData.currentNodes); this.editorStore.setEditorContent(this.editor.drawflow); this.dispatchEvent( new CustomEvent("nodeGroupImported", { detail: { templateContainers: mergedData.templateContainers }, bubbles: true, composed: true, }) ); } /* */ private moveNodesToCenter(nodeTemplate, targetCenterX, targetCenterY) { const data = nodeTemplate.drawflow.Home.data; // Step 1: Get the current center of the bounding box const { centerX: currentCenterX, centerY: currentCenterY } = this.getCenterOfBoundingBox(nodeTemplate.drawflow.Home.data); // Step 2: Calculate the translation required to move the nodes to the target center const deltaX = targetCenterX - currentCenterX; const deltaY = targetCenterY - currentCenterY; // Step 3: Update each node's position Object.values(data).forEach((node) => { (node as DrawflowNode).pos_x += deltaX; (node as DrawflowNode).pos_y += deltaY; }); } /* */ private getCenterOfBoundingBox(data) { const nodes = Object.values(data); let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; nodes.forEach((node) => { const { pos_x, pos_y } = node as DrawflowNode; if (pos_x < minX) minX = pos_x; if (pos_x > maxX) maxX = pos_x; if (pos_y < minY) minY = pos_y; if (pos_y > maxY) maxY = pos_y; }); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; return { centerX, centerY }; } /* */ private mergeTemplate(currentNodes, nodeTemplates) { const currentData = currentNodes.drawflow.Home.data; const templateData = nodeTemplates.drawflow.Home.data; const currentMaxIndex = Math.max(...Object.keys(currentData).map(Number)); let newIndex = currentMaxIndex + 1; const indexMap = Object.fromEntries( Object.keys(templateData).map((key) => [Number(key), newIndex++]) ); for (const [key, node] of Object.entries(templateData)) { const newId = indexMap[Number(key)]; (node as DrawflowNode).id = newId; for (const connections of Object.values( (node as DrawflowNode).inputs ).concat(Object.values((node as DrawflowNode).outputs))) { for (const connection of connections.connections) { connection.node = indexMap[connection.node]?.toString() || connection.node; } } currentData[newId] = { ...(node as DrawflowNode) }; } // Update the containers with the new indexMap values const templateContainers = nodeTemplates.containers; for (const container of templateContainers) { // Update the drawflownodeid attribute const drawflowNodeIdAttr = container.attributes.find( (attr) => attr.name === "drawflownodeid" ); if (drawflowNodeIdAttr) { const oldId = Number(drawflowNodeIdAttr.value); drawflowNodeIdAttr.value = indexMap[oldId].toString(); } const incomingContainerIdAttr = container.attributes.find( (attr) => attr.name === "incomingcontainerid" ); if (incomingContainerIdAttr) { const oldId = Number(incomingContainerIdAttr.value); incomingContainerIdAttr.value = indexMap[oldId].toString(); } const rulesAttr = container.attributes.find( (attr) => attr.name === "rules" ); if (rulesAttr) { const rules = JSON.parse(rulesAttr.value); rules.forEach((rule) => { const oldId = Number(rule.target); rule.target = indexMap[oldId].toString(); }); rulesAttr.value = JSON.stringify(rules); } const elseRuleAttr = container.attributes.find( (attr) => attr.name === "elserule" ); if (elseRuleAttr) { const elseRule = JSON.parse(elseRuleAttr.value); const oldId = Number(elseRule.target); elseRule.target = indexMap[oldId].toString(); elseRuleAttr.value = JSON.stringify(elseRule); } // Update the datatargetid and identifier in the innerHTML const parser = new DOMParser(); const doc = parser.parseFromString(container.innerHTML, "text/html"); const buttons = doc.querySelectorAll( "webwriter-gamebook-button, webwriter-gamebook-branch-button" ); buttons.forEach((button) => { // Update datatargetid const dataTargetId = button.getAttribute("datatargetid"); if (dataTargetId) { const oldTargetId = Number(dataTargetId); button.setAttribute("datatargetid", indexMap[oldTargetId].toString()); } // Update identifier const identifier = button.getAttribute("identifier"); if (identifier) { const identifierParts = identifier.split("-"); if (identifierParts.length === 4) { const x = Number(identifierParts[0]); const y = Number(identifierParts[2]); const newX = indexMap[x]; const newY = indexMap[y]; const newIdentifier = `${newX}-${identifierParts[1]}-${newY}-input_1`; button.setAttribute("identifier", newIdentifier); } } }); // Update the container's innerHTML with the modified content container.innerHTML = doc.body.innerHTML; } return { currentNodes, templateContainers }; } /* */ private parseConnectionIdentifier(identifier) { const parts = identifier.split("-"); const parsed = { outputNodeId: parseInt(parts[0]), inputNodeId: parseInt(parts[1]), outputClass: parts[2], inputClass: parts[3], }; return parsed; } /* */ public searchNodes(value: String) { let matchNodeIds = []; // Loop through all nodes in drawflow const nodes = this.editor.drawflow.drawflow.Home.data; Object.values(nodes).forEach((node) => { if ( node.data.title.toLowerCase().includes(value.toLowerCase()) || node.class.toLowerCase().includes(value.toLowerCase()) ) { matchNodeIds = [...matchNodeIds, node.id]; } }); return matchNodeIds; } /* */ public highlightSearchedNodes(nodeIds: Array) { // Loop through all nodes in drawflow const nodes = this.editor.drawflow.drawflow.Home.data; Object.values(nodes).forEach((node) => { if (nodeIds.includes(node.id)) { this.shadowRoot ?.getElementById(`node-${(node as DrawflowNode).id}`) .setAttribute("searched", "true"); } else { this.shadowRoot ?.getElementById(`node-${(node as DrawflowNode).id}`) .removeAttribute("searched"); } }); } /* */ public removeSearchHighlightFromAllNodes() { // Loop through all nodes in drawflow const nodes = this.editor.drawflow.drawflow.Home.data; Object.values(nodes).forEach((node) => { this.shadowRoot ?.getElementById(`node-${(node as DrawflowNode).id}`) .removeAttribute("searched"); }); } /* */ public makeNodeOrigin(nodeId: number) { let originNodeId = -1; Object.values(this.editor.drawflow.drawflow.Home.data).forEach((node) => { if (node.class == "origin") { const originNodeDiv = this.shadowRoot?.getElementById( `node-${node.id}` ); originNodeId = node.id; const badgeElement = originNodeDiv.querySelector(".badge"); badgeElement.remove(); const originNodeContentDiv = originNodeDiv.querySelector(".content"); const nameLabel = document.createElement("p"); nameLabel.classList.add("input-label"); nameLabel.textContent = "Page"; // Set the text content of the label originNodeContentDiv.appendChild(nameLabel); originNodeDiv.classList.remove("origin"); originNodeDiv.classList.add("page"); node.html = originNodeDiv.querySelector(".container").outerHTML; node.class = "page"; } if (node.id == nodeId) { const nodeDiv = this.shadowRoot?.getElementById(`node-${nodeId}`); if (nodeDiv) { // Find the element with the class "input-label" const inputLabelElement = nodeDiv.querySelector(".input-label"); // If the element exists, remove it from the DOM if (inputLabelElement) { inputLabelElement.remove(); const contentDiv = nodeDiv.querySelector(".content"); const badge = document.createElement("div"); badge.classList.add("badge"); const arrowIcon = document.createElement("sl-icon") as SlIcon; arrowIcon.setAttribute("src", circleArrowRight); badge.appendChild(arrowIcon); const nameLabel = document.createElement("p"); nameLabel.textContent = "Start Page"; badge.appendChild(nameLabel); contentDiv.appendChild(badge); nodeDiv.classList.remove("page"); nodeDiv.classList.add("origin"); } } node.html = nodeDiv.querySelector(".container").outerHTML; node.class = "origin"; } }); this.requestUpdate(); } /* */ private deleteSelectedNode() { this.editor.removeNodeId(`node-${this.editorStore.selectedNode.id}`); (this.shadowRoot.getElementById("delete_node_dialog") as SlDialog).hide(); } /* */ public programaticallySelectNode(id) { this.nodeDivs.forEach((nodeDiv) => { (nodeDiv as HTMLElement).classList.remove("selected"); }); let nodeDiv = Array.from(this.nodeDivs as NodeListOf).find( (nodeDiv) => { return ( parseInt(nodeDiv.id.split("-")[1], 10).toString() === id.toString() ); } ); nodeDiv.classList.add("selected"); this.editor.node_selected = nodeDiv; } /* */ public programaticallyUnselectConnection() { if (this.selectedConnection !== NO_CONNECTION_SELECTED) { const parsedConnection = this.parseConnectionIdentifier( this.selectedConnection ); const deleteButton = this.shadowRoot.querySelector(".drawflow-delete"); if (deleteButton) { deleteButton.remove(); } this.editor.connection_selected = null; this.editor.ele_selected = null; } } }