UNPKG

senangwebs-story

Version:

Lightweight, dependency-free JavaScript library for creating interactive, visual novel-style story experiences.

79 lines (75 loc) 20.4 kB
/* * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development"). * This devtool is neither made for production nor for readable output files. * It uses "eval()" calls to create a separate source file in the browser devtools. * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/) * or disable the default devtool with "devtool: false". * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/). */ (function webpackUniversalModuleDefinition(root, factory) { if(typeof exports === 'object' && typeof module === 'object') module.exports = factory(); else if(typeof define === 'function' && define.amd) define([], factory); else if(typeof exports === 'object') exports["SWS"] = factory(); else root["SWS"] = factory(); })(this, () => { return /******/ (() => { // webpackBootstrap /******/ "use strict"; /******/ var __webpack_modules__ = ({ /***/ "./src/js/sws.js": /*!***********************!*\ !*** ./src/js/sws.js ***! \***********************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { eval("{__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/**\r\n * SenangWebs Story (SWS)\r\n * A lightweight, dependency-free JavaScript library for creating interactive, visual novel-style story experiences.\r\n */\nclass SWS {\n constructor(element, options) {\n this.element = element;\n this.config = options;\n this.storyId = null;\n this.scenes = [];\n this.currentSceneIndex = 0;\n this.currentDialogIndex = 0;\n this.typewriterTimeout = null;\n this.isTypewriterRunning = false;\n this.dialogSpeed = 50; // Default speed in milliseconds\n this._currentTypewriterText = ''; // Store full text for typewriter\n this._currentTypewriterElement = null; // Store element for typewriter\n\n // Bind event handlers for proper cleanup\n this._boundNext = () => this.next();\n this._boundBack = () => this.back();\n this._boundKeydown = e => this._handleKeydown(e);\n if (this.config) {\n this._initFromJSON();\n } else {\n this._initFromHTML();\n }\n this._setupUI();\n this._updateUI();\n this._attachEventListeners();\n }\n\n //------------------------------------------------------------------------------------------------------------------\n // INITIALIZATION\n //------------------------------------------------------------------------------------------------------------------\n\n /**\r\n * Parses the story structure from data-* attributes in the HTML.\r\n * @private\r\n */\n _initFromHTML() {\n this.storyId = this.element.dataset.swsId;\n\n // Parse dialog speed if provided\n if (this.element.dataset.swsDialogSpeed) {\n const speed = parseInt(this.element.dataset.swsDialogSpeed, 10);\n if (!isNaN(speed) && speed > 0) {\n this.dialogSpeed = speed;\n }\n }\n\n // Find all elements with scene attributes dynamically (no limit)\n const sceneElements = Array.from(this.element.querySelectorAll('*')).filter(el => Array.from(el.attributes).some(attr => /^data-sws-scene-\\d+$/.test(attr.name))).sort((a, b) => {\n const getSceneNum = el => parseInt(Array.from(el.attributes).find(attr => attr.name.startsWith('data-sws-scene-'))?.name.split('-').pop() || '0');\n return getSceneNum(a) - getSceneNum(b);\n });\n sceneElements.forEach((sceneEl, i) => {\n const scene = {\n element: sceneEl,\n sceneStart: sceneEl.dataset.swsSceneStart || null,\n background: sceneEl.querySelector('[data-sws-background] img')?.src,\n subjects: [],\n dialogs: []\n };\n const subjectElements = Array.from(sceneEl.querySelectorAll('[data-sws-subject-id]'));\n scene.subjects = subjectElements.map(subjectEl => ({\n id: subjectEl.dataset.swsSubjectId,\n name: subjectEl.dataset.swsSubjectName,\n element: subjectEl\n }));\n\n // Find all elements with dialog attributes dynamically (no limit)\n const dialogElements = Array.from(sceneEl.querySelectorAll('*')).filter(el => Array.from(el.attributes).some(attr => /^data-sws-dialog-\\d+$/.test(attr.name))).sort((a, b) => {\n const getDialogNum = el => parseInt(Array.from(el.attributes).find(attr => attr.name.startsWith('data-sws-dialog-'))?.name.split('-').pop() || '0');\n return getDialogNum(a) - getDialogNum(b);\n });\n dialogElements.forEach((dialogEl, j) => {\n const pElement = dialogEl.querySelector('p');\n scene.dialogs.push({\n element: dialogEl,\n text: pElement ? pElement.innerHTML.trim() : '',\n subjectId: dialogEl.dataset.swsSubject || null,\n dialogStart: dialogEl.dataset.swsDialogStart || null\n });\n });\n this.scenes.push(scene);\n });\n }\n\n /**\r\n * Generates the required HTML structure from a JSON object.\r\n * @private\r\n */\n _initFromJSON() {\n this.storyId = this.config.id;\n\n // Parse dialog speed if provided in config\n if (this.config.dialogSpeed) {\n const speed = parseInt(this.config.dialogSpeed, 10);\n if (!isNaN(speed) && speed > 0) {\n this.dialogSpeed = speed;\n }\n }\n this.element.dataset.sws = true;\n this.element.dataset.swsId = this.storyId;\n this.element.innerHTML = ''; // Clear existing content\n\n this.config.scenes.forEach((sceneData, i) => {\n const sceneEl = document.createElement('div');\n sceneEl.setAttribute(`data-sws-scene-${i + 1}`, ''); // Use setAttribute for kebab-case\n if (sceneData.sceneStart) {\n sceneEl.dataset.swsSceneStart = sceneData.sceneStart;\n }\n if (i > 0) {\n sceneEl.style.display = 'none';\n }\n\n // Background\n const backgroundEl = document.createElement('div');\n backgroundEl.dataset.swsBackground = '';\n const backgroundImg = document.createElement('img');\n backgroundImg.src = sceneData.background;\n backgroundImg.alt = `Scene Background ${i + 1}`;\n backgroundEl.appendChild(backgroundImg);\n sceneEl.appendChild(backgroundEl);\n\n // Subjects\n const subjectsEl = document.createElement('div');\n subjectsEl.dataset.swsSubjects = '';\n const subjects = [];\n sceneData.subjects.forEach(subjectData => {\n const subjectImg = document.createElement('img');\n subjectImg.src = subjectData.src;\n subjectImg.dataset.swsSubjectId = subjectData.id;\n subjectImg.dataset.swsSubjectName = subjectData.name;\n subjectImg.alt = subjectData.name;\n subjectsEl.appendChild(subjectImg);\n\n // Store subject data for scene object\n subjects.push({\n id: subjectData.id,\n name: subjectData.name,\n element: subjectImg\n });\n });\n sceneEl.appendChild(subjectsEl);\n\n // Dialog Box\n const dialogBoxEl = document.createElement('div');\n dialogBoxEl.dataset.swsDialogBox = '';\n const activeSubjectNameEl = document.createElement('h4');\n activeSubjectNameEl.dataset.swsActiveSubjectName = '';\n dialogBoxEl.appendChild(activeSubjectNameEl);\n const dialogs = [];\n sceneData.dialogs.forEach((dialogData, j) => {\n const dialogEl = document.createElement('div');\n dialogEl.dataset.swsDialog = j + 1; // This will create data-sws-dialog attribute\n dialogEl.setAttribute(`data-sws-dialog-${j + 1}`, ''); // Explicitly set the kebab-case version\n if (dialogData.subjectId) {\n dialogEl.dataset.swsSubject = dialogData.subjectId;\n }\n if (dialogData.dialogStart) {\n dialogEl.dataset.swsDialogStart = dialogData.dialogStart;\n }\n const p = document.createElement('p');\n p.innerHTML = dialogData.text;\n dialogEl.appendChild(p);\n dialogBoxEl.appendChild(dialogEl);\n\n // Store dialog data for scene object\n dialogs.push({\n element: dialogEl,\n text: dialogData.text,\n subjectId: dialogData.subjectId || null,\n dialogStart: dialogData.dialogStart || null\n });\n });\n sceneEl.appendChild(dialogBoxEl);\n this.element.appendChild(sceneEl);\n\n // Create scene object and add to scenes array\n const scene = {\n element: sceneEl,\n sceneStart: sceneData.sceneStart || null,\n background: sceneData.background,\n subjects: subjects,\n dialogs: dialogs\n };\n this.scenes.push(scene);\n });\n\n // Actions\n const actionsEl = document.createElement('div');\n actionsEl.dataset.swsActions = '';\n const backButton = document.createElement('button');\n backButton.dataset.swsButton = 'back';\n backButton.textContent = 'Back';\n const nextButton = document.createElement('button');\n nextButton.dataset.swsButton = 'next';\n nextButton.textContent = 'Next';\n actionsEl.appendChild(backButton);\n actionsEl.appendChild(nextButton);\n this.element.appendChild(actionsEl);\n }\n\n /**\r\n * Hides all scenes and dialogs to prepare the initial state.\r\n * @private\r\n */\n _setupUI() {\n this.scenes.forEach((scene, i) => {\n scene.element.style.display = i === 0 ? '' : 'none';\n scene.dialogs.forEach((dialog, j) => {\n if (dialog.element) {\n dialog.element.style.display = 'none';\n }\n });\n });\n }\n\n /**\r\n * Attaches click event listeners to the navigation buttons.\r\n * @private\r\n */\n _attachEventListeners() {\n const nextButton = this.element.querySelector('[data-sws-button=\"next\"]');\n const backButton = this.element.querySelector('[data-sws-button=\"back\"]');\n if (nextButton) nextButton.addEventListener('click', this._boundNext);\n if (backButton) backButton.addEventListener('click', this._boundBack);\n\n // Add keyboard navigation\n document.addEventListener('keydown', this._boundKeydown);\n }\n\n /**\r\n * Handles keyboard navigation.\r\n * @param {KeyboardEvent} e - The keyboard event.\r\n * @private\r\n */\n _handleKeydown(e) {\n // Only handle if this story container or its children are focused, or no specific element is focused\n if (!this.element) return;\n if (e.key === 'ArrowRight' || e.key === ' ') {\n e.preventDefault();\n this.next();\n } else if (e.key === 'ArrowLeft') {\n e.preventDefault();\n this.back();\n }\n }\n\n //------------------------------------------------------------------------------------------------------------------\n // NAVIGATION\n //------------------------------------------------------------------------------------------------------------------\n\n /**\r\n * Moves to the next dialog or scene.\r\n */\n next() {\n // If typewriter is running, complete it instead of moving to next dialog\n if (this.isTypewriterRunning) {\n this._completeTypewriter();\n return;\n }\n const currentScene = this.scenes[this.currentSceneIndex];\n if (this.currentDialogIndex < currentScene.dialogs.length - 1) {\n this.currentDialogIndex++;\n this._updateUI();\n } else if (this.currentSceneIndex < this.scenes.length - 1) {\n this.currentSceneIndex++;\n this.currentDialogIndex = 0;\n this._updateUI(true);\n }\n }\n\n /**\r\n * Moves to the previous dialog or scene.\r\n */\n back() {\n this._completeTypewriter();\n if (this.currentDialogIndex > 0) {\n this.currentDialogIndex--;\n this._updateUI();\n } else if (this.currentSceneIndex > 0) {\n this.currentSceneIndex--;\n this.currentDialogIndex = this.scenes[this.currentSceneIndex].dialogs.length - 1;\n this._updateUI(true);\n }\n }\n\n //------------------------------------------------------------------------------------------------------------------\n // UI & EFFECTS\n //------------------------------------------------------------------------------------------------------------------\n\n /**\r\n * Updates the entire story display based on the current state.\r\n * @param {boolean} [sceneChanged=false] - Indicates if the scene has changed.\r\n * @private\r\n */\n _updateUI(sceneChanged = false) {\n if (sceneChanged) {\n this.scenes.forEach((scene, i) => {\n scene.element.style.display = i === this.currentSceneIndex ? '' : 'none';\n });\n this._executeCallback(this.scenes[this.currentSceneIndex].sceneStart);\n }\n const scene = this.scenes[this.currentSceneIndex];\n\n // Add defensive check to prevent undefined errors\n if (!scene || !scene.dialogs || this.currentDialogIndex >= scene.dialogs.length) {\n console.error('Scene or dialog not found', {\n sceneIndex: this.currentSceneIndex,\n dialogIndex: this.currentDialogIndex,\n totalScenes: this.scenes.length,\n scene: scene\n });\n return;\n }\n const dialog = scene.dialogs[this.currentDialogIndex];\n\n // Update dialog visibility\n scene.dialogs.forEach((d, i) => {\n if (d.element) d.element.style.display = 'none';\n });\n const activeDialogElement = scene.element.querySelector(`[data-sws-dialog-${this.currentDialogIndex + 1}]`);\n\n // Update active subject and name\n const activeSubjectNameEl = scene.element.querySelector('[data-sws-active-subject-name]');\n const subject = scene.subjects.find(s => s.id === dialog.subjectId);\n\n // Remove active class from all subjects first\n scene.subjects.forEach(s => s.element?.classList.remove('active'));\n if (subject) {\n activeSubjectNameEl.textContent = subject.name;\n subject.element?.classList.add('active');\n } else {\n activeSubjectNameEl.textContent = '';\n }\n\n // Add defensive check for activeDialogElement\n if (!activeDialogElement) {\n console.error('Active dialog element not found', {\n sceneIndex: this.currentSceneIndex,\n dialogIndex: this.currentDialogIndex,\n selector: `[data-sws-dialog-${this.currentDialogIndex + 1}]`,\n sceneElement: scene.element\n });\n return;\n }\n\n // Start typewriter effect\n this._typewriter(dialog.text, activeDialogElement.querySelector('p'));\n\n // Execute dialog callback\n this._executeCallback(dialog.dialogStart);\n }\n\n /**\r\n * Displays text with a typewriter effect.\r\n * @param {string} text - The text to display.\r\n * @param {HTMLElement} element - The target element for the text.\r\n * @private\r\n */\n _typewriter(text, element) {\n if (!element) return;\n element.parentElement.style.display = '';\n element.innerHTML = '';\n this.isTypewriterRunning = true;\n this._currentTypewriterText = text;\n this._currentTypewriterElement = element;\n let i = 0;\n\n // Parse HTML to extract text content while preserving structure\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = text;\n const plainText = tempDiv.textContent || tempDiv.innerText || '';\n const type = () => {\n if (i < plainText.length) {\n element.textContent += plainText.charAt(i);\n i++;\n this.typewriterTimeout = setTimeout(type, this.dialogSpeed);\n } else {\n // Typewriter animation completed - restore full HTML\n element.innerHTML = text;\n this.isTypewriterRunning = false;\n this._currentTypewriterText = '';\n this._currentTypewriterElement = null;\n }\n };\n type();\n }\n\n /**\r\n * Instantly completes the typewriter animation.\r\n * @private\r\n */\n _completeTypewriter() {\n if (this.typewriterTimeout) {\n clearTimeout(this.typewriterTimeout);\n this.typewriterTimeout = null;\n this.isTypewriterRunning = false;\n const scene = this.scenes[this.currentSceneIndex];\n const dialog = scene.dialogs[this.currentDialogIndex];\n const dialogElement = scene.element.querySelector(`[data-sws-dialog-${this.currentDialogIndex + 1}]`);\n if (dialogElement) {\n const pElement = dialogElement.querySelector('p');\n if (pElement) {\n pElement.innerHTML = dialog.text; // Use innerHTML to preserve HTML formatting\n }\n }\n this._currentTypewriterText = '';\n this._currentTypewriterElement = null;\n }\n }\n\n /**\r\n * Destroys the SWS instance and cleans up all resources.\r\n * Call this before removing the story from the DOM or loading a new story.\r\n */\n destroy() {\n // Clear any pending typewriter animation\n if (this.typewriterTimeout) {\n clearTimeout(this.typewriterTimeout);\n this.typewriterTimeout = null;\n }\n\n // Remove event listeners\n const nextButton = this.element?.querySelector('[data-sws-button=\"next\"]');\n const backButton = this.element?.querySelector('[data-sws-button=\"back\"]');\n if (nextButton) nextButton.removeEventListener('click', this._boundNext);\n if (backButton) backButton.removeEventListener('click', this._boundBack);\n document.removeEventListener('keydown', this._boundKeydown);\n\n // Clear internal state\n this.scenes = [];\n this.isTypewriterRunning = false;\n this._currentTypewriterText = '';\n this._currentTypewriterElement = null;\n this.element = null;\n this.config = null;\n }\n\n /**\r\n * Executes a callback string.\r\n * @param {string} callbackString - The JS code to execute.\r\n * @private\r\n */\n _executeCallback(callbackString) {\n if (callbackString) {\n try {\n // Using new Function() is safer than eval()\n new Function(callbackString)();\n } catch (e) {\n console.error(\"Error executing callback:\", e);\n }\n }\n }\n}\n\n// Auto-initialize on DOMContentLoaded\ndocument.addEventListener('DOMContentLoaded', () => {\n const storyElements = document.querySelectorAll('[data-sws]');\n storyElements.forEach(el => new SWS(el));\n});\n\n// Export the SWS class for module systems\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (SWS);\n\n//# sourceURL=webpack://SWS/./src/js/sws.js?\n}"); /***/ }) /******/ }); /************************************************************************/ /******/ // The require scope /******/ var __webpack_require__ = {}; /******/ /************************************************************************/ /******/ /* webpack/runtime/define property getters */ /******/ (() => { /******/ // define getter functions for harmony exports /******/ __webpack_require__.d = (exports, definition) => { /******/ for(var key in definition) { /******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { /******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); /******/ } /******/ } /******/ }; /******/ })(); /******/ /******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ (() => { /******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) /******/ })(); /******/ /******/ /* webpack/runtime/make namespace object */ /******/ (() => { /******/ // define __esModule on exports /******/ __webpack_require__.r = (exports) => { /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); /******/ } /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ })(); /******/ /************************************************************************/ /******/ /******/ // startup /******/ // Load entry module and return exports /******/ // This entry module can't be inlined because the eval devtool is used. /******/ var __webpack_exports__ = {}; /******/ __webpack_modules__["./src/js/sws.js"](0, __webpack_exports__, __webpack_require__); /******/ __webpack_exports__ = __webpack_exports__["default"]; /******/ /******/ return __webpack_exports__; /******/ })() ; });