UNPKG

@webwriter/branching-scenario

Version:

Create adaptable learning experiences by authoring a gamebook, where you present the student with choices on how to continue.

706 lines (596 loc) 24.6 kB
import { html, css, LitElement, unsafeCSS, PropertyValues } from "lit"; import { repeat } from "lit/directives/repeat.js"; import { LitElementWw } from "@webwriter/lit"; import { customElement, property } from "lit/decorators.js"; // Shoelace Imports import "@shoelace-style/shoelace/dist/themes/light.css"; import { SlButton, SlDivider, SlIcon, SlIconButton, SlInput, SlOption, SlSelect, } from "@shoelace-style/shoelace"; import { msg, localized } from "@lit/localize"; //CSS import styles from "./branch-node-detail-view.styles"; //Tabler import plus from "@tabler/icons/outline/plus.svg"; import minus from "@tabler/icons/outline/minus.svg"; import circleDashedX from "@tabler/icons/outline/circle-dashed-x.svg"; import circleDashedCheck from "@tabler/icons/outline/circle-dashed-check.svg"; import arrowsSplit2 from "@tabler/icons/outline/arrows-split-2.svg"; import gripHorizontal from "@tabler/icons/outline/grip-horizontal.svg"; import percentage from "@tabler/icons/outline/percentage.svg"; import { NodeOutputSelect } from "../node-output-select/node-output-select"; import { WebWriterQuizSelect } from "../quiz-select/webwriter-quiz-select"; import { WebWriterQuizTasksSelect } from "../quiz-tasks-select/webwriter-quiz-tasks-select"; import { ToggleTextInput } from "../toggle-text-input/toggle-text-input"; import { NodeConnectionList } from "../node-connection-list/node-connection-list"; import { provide, consume, createContext } from "@lit/context"; import { editorState, GamebookEditorState, } from "../../utils/gamebook-editor-state-context"; @localized() export class BranchNodeDetailView extends LitElementWw { //registering custom elements used in the widget static get scopedElements() { return { "sl-button": SlButton, "sl-icon-button": SlIconButton, "sl-icon": SlIcon, "sl-select": SlSelect, "sl-input": SlInput, "sl-option": SlOption, "sl-divider": SlDivider, "node-output-select": NodeOutputSelect, "webwriter-quiz-select": WebWriterQuizSelect, "toggle-text-input": ToggleTextInput, "node-connection-list": NodeConnectionList, "webwriter-quiz-tasks-select": WebWriterQuizTasksSelect, }; } //import CSS static styles = [styles]; //public properties are part of the component's public API @property({ type: Boolean }) accessor ruleDrag = false; private draggedIndex = -1; @property({ type: Number }) accessor hoveredDividerIndex = -1; @consume({ context: editorState, subscribe: true }) @property({ type: Object, attribute: true, reflect: false }) public accessor editorStore = new GamebookEditorState("Default"); // // protected firstUpdated(_changedProperties: PropertyValues): void {} render() { let isConnected = this.editorStore.selectedContainer?.incomingContainerId !== -1; return html` <div class="title-bar"> <div class="div-icon-branch"> <sl-icon src=${arrowsSplit2}></sl-icon> </div> <div class="div-title"> <toggle-text-input .text=${this.editorStore.selectedNode.data.title} .saveChanges=${(string) => this.renameNode(string)} ></toggle-text-input> <p class="subtitle">${msg("Branch")}</p> </div> <div class="inputOutputControls"> <node-connection-list branch .selectedNode=${this.editorStore.selectedNode} ></node-connection-list> </div> </div> ${this.editorStore.selectedContainer ? html`<div class="container"> <div class="titlebar"> <p class="title" style=${isConnected ? "color: #505055" : "color: darkgrey"} > ${msg("Rules")} (${this.editorStore.selectedContainer.rules?.length.toString()}) </p> <sl-icon-button src=${plus} class="add" @click=${() => this.addEmptyRule()} ?disabled=${!isConnected} > </sl-icon-button> </div> <div class="ruleList"> ${this.editorStore.selectedContainer?.rules?.length > 0 ? html` ${repeat( this.editorStore.selectedContainer?.rules as Rule[], (rule, index) => html` <!-- top divider --> <sl-divider class="rule-divider" style=" visibility: ${ this.draggedIndex == -1 || index > this.draggedIndex || index == this.draggedIndex ? "hidden" : "visible" }; opacity: ${ this.hoveredDividerIndex == index ? "100%" : "0%" }; " ></sl-divider> <!-- --> <div class="ruleItem" id="horizontal-stack-${index}" draggable="true" @dragstart=${(e: DragEvent) => this._onDragStart(e, index)} @dragend=${this._onDragEnd} @dragover=${(e: DragEvent) => this._onDragOver(e, index)} @dragleave=${(e: DragEvent) => this._onDragLeave(e, index)} @drop=${(e: DragEvent) => this._onDrop(e)} > <div id="index" style="min-width: 25px; display: flex; flex-direction: row; align-items: center; justify-content: center;" > <p style="color: darkgrey; font-size: 15px;"> ${parseInt( (rule as any).output_id.split("_")[1], 10 )} </p> </div> <sl-icon class="draggable" src=${gripHorizontal} style="font-size: 15px; flex-shrink: 0" ></sl-icon> <!-- Element --> <webwriter-quiz-select .value=${(rule as any).elementId} @sl-change=${(e: Event) => this.updateRuleElement( index, ( e.target as WebWriterQuizSelect ).selectElement.value.toString() )} .container=${ this.editorStore.branchIncomingContainer } > </webwriter-quiz-select> <!-- Subelements --> ${ (rule as any).elementId !== "" && rule.elementId !== "text" && this.editorStore.branchIncomingContainer .querySelector(`#${rule.elementId}`) ?.tagName.toLowerCase() == "webwriter-quiz" ? html` <webwriter-quiz-tasks-select .value=${rule.quizTasks.split(" ")} @sl-change=${(e: Event) => this.updateRuleTasks( index, ( e.target as WebWriterQuizSelect ).selectElement.value.toString() )} .quiz=${this.editorStore.branchIncomingContainer.querySelector( `#${rule.elementId}` )} > </webwriter-quiz-tasks-select>` : null } <!-- Condition --> <sl-select clearable placeholder=${msg("Condition")} value=${rule.condition} @sl-change=${(e: Event) => this.updateRuleCondition( index, (e.target as HTMLSelectElement).value )} ?disabled=${!rule.isConditionEnabled} > <!-- Quiz Conditions --> <sl-option value="correct"> <sl-icon slot="prefix" src=${circleDashedCheck} ></sl-icon> Correct</sl-option > <sl-option value="incorrect"> <sl-icon slot="prefix" src=${circleDashedX} ></sl-icon> Incorrect</sl-option > </sl-select> <!-- Match --> ${ rule.elementId !== "" ? html`${this.editorStore.branchIncomingContainer .querySelector(`#${rule.elementId}`) ?.tagName?.toLowerCase() === "webwriter-quiz" && rule.condition == "" ? html` <sl-input placeholder="Match" ?disabled=${!rule.isMatchEnabled} ></sl-input> ` : this.editorStore.branchIncomingContainer .querySelector(`#${rule.elementId}`) ?.tagName.toLowerCase() == "webwriter-quiz" && (rule.condition == "correct" || rule.condition == "incorrect") ? html`<sl-input id="percent" placeholder="..." value=${rule.match} type="number" min="0" max="100" inputmode="numeric" @sl-input=${(e: Event) => this._validateAndUpdateRuleMatch( e, index )} > <sl-icon src=${percentage} slot="prefix" ></sl-icon> </sl-input>` : null}` : null } <!-- Output --> <node-output-select .selectedNode=${this.editorStore.selectedNode} .incomingNodeId=${ this.editorStore.selectedNode.inputs["input_1"] .connections?.[0]?.node } .outputClass=${rule.output_id} ?disabled=${!rule.isTargetEnabled} required="true" ></node-output-select> <sl-icon-button class="minus" src=${minus} style="font-size: 15px;" @click=${() => this.editorStore.selectedContainer.deleteRule( rule.output_id )} ?disabled=${!this.isConnected} ></sl-icon-button> </div> <!-- bottom divider --> <sl-divider class="rule-divider" style=" visibility: ${ this.draggedIndex == -1 || index < this.draggedIndex || index == this.draggedIndex ? "hidden" : "visible" }; opacity: ${ this.hoveredDividerIndex == index ? "100%" : "0%" }; " ></sl-divider> <!-- --> </div> ` )} <sl-divider></sl-divider> <!-- the else rule --> <div class="ruleItem" id="horizontal-stack-else"> <div id="index" style="min-width: 25px; display: flex; flex-direction: row; align-items: center; justify-content: center;" > <p style="color: darkgrey; font-size: 15px;"> ${msg("If no rule is satisfied, go to")} </p> </div> <!-- Output --> <node-output-select .selectedNode=${this.editorStore.selectedNode} .incomingNodeId=${this.editorStore.selectedNode.inputs[ "input_1" ].connections?.[0]?.node} .outputClass=${this.editorStore.selectedContainer .elseRule?.output_id} required="true" ></node-output-select> </div> ` : html`<p class="no-node">${msg("No branching rules")}</p>`} </div> <slot></slot> </div>` : null} `; } /* */ private _onDragStart(event: DragEvent, index: number) { this.draggedIndex = index; const stackElement = this.shadowRoot?.getElementById( `horizontal-stack-${index}` ); if (stackElement) { stackElement.classList.add("dragging"); } this.requestUpdate(); } /* */ private _onDragEnd() { if (this.draggedIndex !== -1) { const stackElement = this.shadowRoot?.getElementById( `horizontal-stack-${this.draggedIndex}` ); if (stackElement) { stackElement.classList.remove("dragging"); } } this.draggedIndex = -1; // Reset dragged index this.hoveredDividerIndex = -1; // Reset hovered divider index this.requestUpdate(); } /* */ private _onDragOver(event: DragEvent, index: number) { event.preventDefault(); this.hoveredDividerIndex = index; this.requestUpdate(); } /* */ private _onDragLeave(event: DragEvent, index: number) { event.preventDefault(); this.hoveredDividerIndex = -1; this.requestUpdate(); } /* */ private _onDrop(event: DragEvent) { event.preventDefault(); if ( this.draggedIndex !== -1 && this.hoveredDividerIndex !== -1 && this.draggedIndex !== this.hoveredDividerIndex ) { const { selectedContainer, selectedNode } = this.editorStore; let staticCopyRules = this.editorStore.selectedContainer.rules; const hoveredRuleOutput = selectedContainer.rules[this.hoveredDividerIndex].output_id; const draggedRuleOutput = selectedContainer.rules[this.draggedIndex].output_id; const outputs = selectedNode.outputs; // Rule targets get lost if not restored const savedTargets = staticCopyRules.map((rule) => rule.target); // Create copy of connections to avoid modifying during iteration const connectionSnapshot: Record<string, { node: string; output: string }[]> = {}; Object.keys(outputs).forEach((outputClass) => { connectionSnapshot[outputClass] = outputs[outputClass].connections.map( (c) => ({ ...c }) ); }); // Extract output numbers const hoveredOutputNumber = parseInt(hoveredRuleOutput.split("_")[1], 10); const draggedOutputNumber = parseInt(draggedRuleOutput.split("_")[1], 10); // Calculate range and movement direction const [minOutputNumber, maxOutputNumber] = [ Math.min(hoveredOutputNumber, draggedOutputNumber), Math.max(hoveredOutputNumber, draggedOutputNumber), ]; const adjustment = hoveredOutputNumber < draggedOutputNumber ? 1 : -1; const outputMapping: Record<string, string> = {}; outputMapping[draggedRuleOutput] = hoveredRuleOutput; // Iterate through outputs Object.keys(outputs).forEach((outputClass) => { const outputIdNumber = parseInt(outputClass.split("_")[1], 10); // Check if the output is between the hovered and dragged, excluding the dragged one if ( outputIdNumber >= minOutputNumber && outputIdNumber <= maxOutputNumber && outputIdNumber !== draggedOutputNumber ) { outputMapping[outputClass] = `output_${outputIdNumber + adjustment}`; } }); // Delete old connections Object.keys(outputMapping).forEach((oldOutputClass) => { connectionSnapshot[oldOutputClass]?.forEach((connection) => { this.dispatchEvent( new CustomEvent("deleteConnection", { detail: { outputNodeId: selectedNode.id, inputNodeId: connection.node, outputClass: oldOutputClass, inputClass: "input_1", }, bubbles: true, composed: true, }) ); }); }); // Create connections at new positions Object.entries(outputMapping).forEach(([oldOutputClass, newOutputClass]) => { connectionSnapshot[oldOutputClass]?.forEach((connection) => { this.dispatchEvent( new CustomEvent("createConnection", { detail: { outputNodeId: selectedNode.id, inputNodeId: connection.node, outputClass: newOutputClass, inputClass: "input_1", }, bubbles: true, composed: true, }) ); }); }); // Update output_ids Object.keys(outputs).forEach((outputClass, index) => { if (outputMapping[outputClass] && outputClass !== draggedRuleOutput) { staticCopyRules[index].output_id = outputMapping[outputClass]; } }); staticCopyRules[this.draggedIndex].output_id = hoveredRuleOutput; // Restore saved targets staticCopyRules.forEach((rule, index) => { rule.target = savedTargets[index]; }); //Update the rules index in the rules array according to drag by removing the rule and adding it at the drop position let [draggedRule] = staticCopyRules.splice(this.draggedIndex, 1); staticCopyRules.splice(this.hoveredDividerIndex, 0, draggedRule); this.editorStore.selectedContainer.updateRules(staticCopyRules); } this._onDragEnd(); this.dispatchEvent( new CustomEvent("markOutputs", { bubbles: true, composed: true, }) ); this.editorStore.setSelectedContainer(this.editorStore.selectedContainer); this.requestUpdate(); } /* */ private _validateAndUpdateRuleMatch(e: Event, index: number) { const inputElement = e.target as SlInput; let value = inputElement.value; if (value != "") { // Remove any non-numeric characters (this makes sure input is strictly numeric) value = value.replace(/[^0-9]/g, ""); // Convert the value to a number and clamp it to the range 0-100 let numericValue = Number(value); if (numericValue < 0) numericValue = 0; if (numericValue > 100) numericValue = 100; // Update the input value with the clamped number inputElement.value = numericValue.toString(); } // Update the rule match this.editorStore.selectedContainer._updateRuleMatch( index, inputElement.value ); this.editorStore.setSelectedContainer(this.editorStore.selectedContainer); this.requestUpdate(); } /* */ private addEmptyRule() { // Step 1 this.dispatchEvent( new CustomEvent("addOutput", { detail: { nodeId: this.editorStore.selectedNode.id, }, bubbles: true, composed: true, }) ); this.editorStore.selectedContainer.addEmptyRule( this.editorStore.selectedNode ); // Step 5: Ensure the else rule is handled properly if (this.editorStore.selectedContainer.elseRule) { this.editorStore.selectedContainer._moveElseRuleToLastOutput( this.editorStore.selectedNode ); } else { this.dispatchEvent( new CustomEvent("addOutput", { detail: { nodeId: this.editorStore.selectedNode.id, }, bubbles: true, composed: true, }) ); this.editorStore.selectedContainer.addEmptyElseRule( this.editorStore.selectedNode ); } this.editorStore.setSelectedContainer(this.editorStore.selectedContainer); this.requestUpdate(); } /* */ private updateRuleElement(index, value) { this.editorStore.selectedContainer._updateRuleElement( index, value, this.editorStore.branchIncomingContainer ); this.editorStore.setSelectedContainer(this.editorStore.selectedContainer); this.requestUpdate(); } /* */ private updateRuleTasks(index, value) { this.editorStore.selectedContainer._updateRuleTasks( index, value, this.editorStore.branchIncomingContainer ); this.editorStore.setSelectedContainer(this.editorStore.selectedContainer); this.requestUpdate(); } /* */ private updateRuleCondition(index, value) { this.editorStore.selectedContainer._updateRuleCondition( index, value, this.editorStore.branchIncomingContainer ); this.editorStore.setSelectedContainer(this.editorStore.selectedContainer); this.requestUpdate(); } /* */ /* */ private renameNode(text: String) { const event = new CustomEvent("renameSelectedNode", { detail: { newTitle: text }, bubbles: true, composed: true, }); this.dispatchEvent(event); } }