@webwriter/branching-scenario
Version:
Create adaptable learning experiences by authoring a gamebook, where you present the student with choices on how to continue.
1,684 lines (1,419 loc) • 55.5 kB
text/typescript
import { html, css, PropertyValues } from "lit";
import { consume } from "@lit/context";
import { LitElementWw } from "@webwriter/lit";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators.js";
import { styleMap } from "lit/directives/style-map.js";
import { msg, str, localized } from "@lit/localize";
//Shoelace Imports
import "@shoelace-style/shoelace/dist/themes/light.css";
import {
SlButton,
SlDialog,
SlIconButton,
SlDivider,
SlTextarea,
SlMenu,
SlMenuItem,
SlMenuLabel,
SlDropdown,
SlIcon,
SlInput,
SlTooltip,
} from "@shoelace-style/shoelace";
//@tabler icons
import fileFilled from "@tabler/icons/filled/file.svg";
import fileOutline from "@tabler/icons/outline/file.svg";
import circleArrowRight from "@tabler/icons/filled/circle-arrow-right.svg";
import dotsVertical from "@tabler/icons/outline/grip-vertical.svg";
import zoomIn from "@tabler/icons/outline/zoom-in.svg";
import zoomOut from "@tabler/icons/outline/zoom-out.svg";
import squaresFilled from "@tabler/icons/filled/squares.svg";
import squaresOutline from "@tabler/icons/outline/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 "../node-editor-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";
@localized()
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-input": SlInput,
"sl-icon-button": SlIconButton,
"sl-menu": SlMenu,
"sl-menu-item": SlMenuItem,
"sl-dropdown": SlDropdown,
"sl-menu-label": SlMenuLabel,
"sl-tooltip": SlTooltip,
"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;
@state() accessor typeAdded = "";
@state() accessor pagesAdded = 1;
@state() accessor popupsAdded = 1;
@state() accessor branchesAdded = 1;
private hoverPreviousCanvasX: number | null = null;
private hoverPreviousCanvasY: number | null = null;
private hoverPreviousBackgroundTranslateX: number | null = null;
private hoverPreviousBackgroundTranslateY: number | null = null;
@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;
this.editor.zoom = 1;
//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(msg("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`
<div id="nodeEditor">
<node-editor-toolbar
@addPageNode=${(e: CustomEvent) => {
this.preAddPageNode();
}}
@addPopUpNode=${(e: CustomEvent) => {
this.preAddPopupNode();
}}
@addBranchNode=${(e: CustomEvent) => {
this.preAddBranchNode();
}}
@addTemplate=${(e: CustomEvent) =>
this.addTemplate(e.detail.template)}
@clearDialog=${() =>
(this.shadowRoot.getElementById("dialog") as SlDialog).show()}
>
</node-editor-toolbar>
<div id="drawflowEditorDiv" style=${styleMap(gridStyles)}></div>
<div class="zoomControls">
<sl-tooltip content=${msg("Jump to origin node")}>
<sl-icon-button
id="jumpToOriginBtn"
src=${mapPin}
style="font-size: 18px;"
@click=${() => {
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);
}}
>
</sl-icon-button>
</sl-tooltip>
<sl-divider vertical style="height: 20px; margin: 2px;"></sl-divider>
<sl-icon-button
id="zoomInBtn"
src=${zoomIn}
style="font-size: 18px;"
@click=${() => this.editor.zoom_in()}
>
</sl-icon-button>
<sl-icon-button
id="zoomOutBtn"
src=${zoomOut}
style="font-size: 18px;"
@click=${() => this.editor.zoom_out()}
>
</sl-icon-button>
</div>
<div class="zoomValue">
<p>${this.editorZoomString}</p>
</div>
<node-editor-help-menu></node-editor-help-menu>
</div>
<!-- Dialog for clearing editor-->
<sl-dialog label="${msg("Clear Graph")}" class="dialog" id="dialog">
${msg(
"Do you want to clear the graph? All your progress will be lost."
)}
<sl-button
slot="footer"
variant="primary"
outline
@click=${() =>
(this.shadowRoot.getElementById("dialog") as SlDialog).hide()}
>${msg("Cancel")}</sl-button
>
<sl-button
slot="footer"
variant="danger"
outline
@click=${() => this._clearEditor()}
>${msg("Clear")}</sl-button
>
</sl-dialog>
<sl-dialog
label=${msg("Delete node")}
class="dialog"
id="delete_node_dialog"
>
${msg(
str`You are about to delete the node ${this.editorStore.selectedNode.data.title}. Do you want to proceed?`
)}
<sl-button
slot="footer"
variant="primary"
outline
@click=${() =>
(
this.shadowRoot.getElementById("delete_node_dialog") as SlDialog
).hide()}
>${msg("Abort")}</sl-button
>
<sl-button
slot="footer"
variant="danger"
outline
@click=${() => this.deleteSelectedNode()}
>Delete</sl-button
>
</sl-dialog>
<sl-dialog
label=${msg("Adding Node")}
class="dialog"
id="add_node_dialog"
style="width: 100%"
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
(this.shadowRoot?.querySelector("#addButton") as SlButton).click();
}
}}
>
${msg("Give your new")}
<div
style="display: inline-flex; align-items: baseline; padding-left: 4px; padding-right: 4px; padding-top: 0px; padding-bottom: 0px; color: #262629;"
>
<sl-icon
src=${this.typeAdded === "Page"
? fileOutline
: this.typeAdded === "Popup"
? squaresOutline
: this.typeAdded === "Branch"
? arrowsSplit2
: ""}
style="display: inline-block; vertical-align: baseline; height: 0.8em; width: auto; line-height: 1;"
></sl-icon>
<span style="margin-left: 6px;">
${this.typeAdded === "Page"
? msg("Page node")
: this.typeAdded === "Popup"
? msg("Popup node")
: this.typeAdded === "Branch"
? msg("Branch node")
: ""}
</span>
</div>
${msg("a title!")}
<br />
<br />
<sl-input
autofocus
id="title_input"
value=${this.typeAdded === "Page"
? msg(str`Untitled Page ${this.pagesAdded}`)
: this.typeAdded === "Popup"
? msg(str`Untitled Popup ${this.popupsAdded}`)
: this.typeAdded === "Branch"
? msg(str`Untitled Branch ${this.branchesAdded}`)
: ""}
></sl-input>
<sl-button
slot="footer"
variant="text"
outline
@click=${() =>
(
this.shadowRoot.getElementById("add_node_dialog") as SlDialog
).hide()}
>${msg("Cancel")}</sl-button
>
<sl-button
id="addButton"
slot="footer"
variant="primary"
@click=${this.typeAdded === "Page"
? () =>
this.addPageNode(
(this.shadowRoot.getElementById("title_input") as SlInput)
.value,
false
)
: this.typeAdded === "Popup"
? () =>
this.addPopUpNode(
(this.shadowRoot.getElementById("title_input") as SlInput)
.value
)
: this.typeAdded === "Branch"
? () =>
this.addBranchNode(
(this.shadowRoot.getElementById("title_input") as SlInput)
.value
)
: () => console.log()}
>${msg("Add")}</sl-button
>
</sl-dialog>
`;
}
/*
*/
private onMouseDown(event: MouseEvent) {
//console.log(event);
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(msg("First Page"), true);
this.pagesAdded = 1;
this.popupsAdded = 1;
this.branchesAdded = 1;
}
/*
*/
private moveToCoords(
canvasX: number,
canvasY: number,
bgTranslateX: number,
bgTranslateY: number,
withAnimation: boolean
) {
const drawflowContainer = this.drawflowEditorDiv.querySelector(".drawflow");
const zoom = this.editor.zoom;
if (drawflowContainer) {
// Add the transition class for smooth animation
if (withAnimation) {
drawflowContainer.classList.add("smooth-transition");
this.drawflowEditorDiv.classList.add("smooth-background-transition");
}
this.editor.canvas_x = canvasX;
this.editor.canvas_y = canvasY;
this.backgroundTranslateX = bgTranslateX;
this.backgroundTranslateY = bgTranslateY;
drawflowContainer.style.transform = `translate(${this.editor.canvas_x}px, ${this.editor.canvas_y}px) scale(${zoom})`;
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);
}
}
}
/*
*/
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<HTMLElement>).find(
(div) => parseInt(div.id.split("-")[1], 10).toString() === id.toString()
);
const nodeWidth = nodeDiv.offsetWidth;
const nodeHeight = nodeDiv.offsetHeight;
const rect = this.drawflowEditorDiv.getBoundingClientRect();
// 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
const newCanvasX = canvas_x - (nodePosX - rect.width / 2);
const newCanvasY = canvas_y - (nodePosY - rect.height / 2);
this.backgroundLastX = this.backgroundTranslateX;
this.backgroundLastY = this.backgroundTranslateY;
const newBgTranslateX = this.backgroundTranslateX - (nodePosX - rect.width / 2);
const newBgTranslateY = this.backgroundTranslateY - (nodePosY - rect.height / 2);
this.moveToCoords(newCanvasX, newCanvasY, newBgTranslateX, newBgTranslateY, !!withAnimation);
}
/*
*/
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) => {
//console.log("zoom");
//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 preAddPageNode() {
this.typeAdded = "Page";
(this.shadowRoot.getElementById("add_node_dialog") as SlDialog).show();
}
/*
*/
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
);
(this.shadowRoot.getElementById("add_node_dialog") as SlDialog).hide();
this.pagesAdded++;
}
/*
*/
public preAddPopupNode() {
this.typeAdded = "Popup";
(this.shadowRoot.getElementById("add_node_dialog") as SlDialog).show();
}
/*
*/
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
);
(this.shadowRoot.getElementById("add_node_dialog") as SlDialog).hide();
this.popupsAdded++;
}
/*
*/
public preAddBranchNode() {
this.typeAdded = "Branch";
(this.shadowRoot.getElementById("add_node_dialog") as SlDialog).show();
}
/*
*/
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
);
(this.shadowRoot.getElementById("add_node_dialog") as SlDialog).hide();
this.branchesAdded++;
}
/*
*/
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", fileFilled);
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 = msg("Start Page");
badge.appendChild(nameLabel);
contentDiv.appendChild(badge);
} else {
nameLabel.classList.add("input-label");
nameLabel.textContent = msg("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", squaresFilled);
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 = msg("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 = msg("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");
// Save current position
this.hoverPreviousCanvasX = this.editor.canvas_x;
this.hoverPreviousCanvasY = this.editor.canvas_y;
this.hoverPreviousBackgroundTranslateX = this.backgroundTranslateX;
this.hoverPreviousBackgroundTranslateY = this.backgroundTranslateY;
// Center on the hovered node
const node = this.editor.getNodeFromId(nodeId);
if (node) {
this.moveToNode(node, true);
}
}
/*
*/
public _unhighlightNode(nodeId) {
const selector = `div#node-${nodeId}.drawflow-node`;
this.shadowRoot.querySelector(selector)?.removeAttribute("highlighted");
// Restore previous position
if (this.hoverPreviousCanvasX !== null && this.hoverPreviousCanvasY !== null) {
this.moveToCoords(
this.hoverPreviousCanvasX,
this.hoverPreviousCanvasY,
this.hoverPreviousBackgroundTranslateX!,
this.hoverPreviousBackgroundTranslateY!,
true
);
// Clear saved position
this.hoverPreviousCanvasX = null;
this.hoverPreviousCanvasY = null;
this.hoverPreviousBackgroundTranslateX = null;
this.hoverPreviousBackgroundTranslateY = null;
}
}
/*
*/
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<HTMLElement>).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(identi