senangwebs-story
Version:
Lightweight, dependency-free JavaScript library for creating interactive, visual novel-style story experiences.
491 lines (430 loc) • 19.2 kB
JavaScript
/**
* SenangWebs Story (SWS)
* A lightweight, dependency-free JavaScript library for creating interactive, visual novel-style story experiences.
*/
class SWS {
constructor(element, options) {
this.element = element;
this.config = options;
this.storyId = null;
this.scenes = [];
this.currentSceneIndex = 0;
this.currentDialogIndex = 0;
this.typewriterTimeout = null;
this.isTypewriterRunning = false;
this.dialogSpeed = 50; // Default speed in milliseconds
this._currentTypewriterText = ''; // Store full text for typewriter
this._currentTypewriterElement = null; // Store element for typewriter
// Bind event handlers for proper cleanup
this._boundNext = () => this.next();
this._boundBack = () => this.back();
this._boundKeydown = (e) => this._handleKeydown(e);
if (this.config) {
this._initFromJSON();
} else {
this._initFromHTML();
}
this._setupUI();
this._updateUI();
this._attachEventListeners();
}
//------------------------------------------------------------------------------------------------------------------
// INITIALIZATION
//------------------------------------------------------------------------------------------------------------------
/**
* Parses the story structure from data-* attributes in the HTML.
* @private
*/
_initFromHTML() {
this.storyId = this.element.dataset.swsId;
// Parse dialog speed if provided
if (this.element.dataset.swsDialogSpeed) {
const speed = parseInt(this.element.dataset.swsDialogSpeed, 10);
if (!isNaN(speed) && speed > 0) {
this.dialogSpeed = speed;
}
}
// Find all elements with scene attributes dynamically (no limit)
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) => {
const getSceneNum = el => parseInt(Array.from(el.attributes).find(attr => attr.name.startsWith('data-sws-scene-'))?.name.split('-').pop() || '0');
return getSceneNum(a) - getSceneNum(b);
});
sceneElements.forEach((sceneEl, i) => {
const scene = {
element: sceneEl,
sceneStart: sceneEl.dataset.swsSceneStart || null,
background: sceneEl.querySelector('[data-sws-background] img')?.src,
subjects: [],
dialogs: []
};
const subjectElements = Array.from(sceneEl.querySelectorAll('[data-sws-subject-id]'));
scene.subjects = subjectElements.map(subjectEl => ({
id: subjectEl.dataset.swsSubjectId,
name: subjectEl.dataset.swsSubjectName,
element: subjectEl
}));
// Find all elements with dialog attributes dynamically (no limit)
const dialogElements = Array.from(sceneEl.querySelectorAll('*'))
.filter(el => Array.from(el.attributes).some(attr => /^data-sws-dialog-\d+$/.test(attr.name)))
.sort((a, b) => {
const getDialogNum = el => parseInt(Array.from(el.attributes).find(attr => attr.name.startsWith('data-sws-dialog-'))?.name.split('-').pop() || '0');
return getDialogNum(a) - getDialogNum(b);
});
dialogElements.forEach((dialogEl, j) => {
const pElement = dialogEl.querySelector('p');
scene.dialogs.push({
element: dialogEl,
text: pElement ? pElement.innerHTML.trim() : '',
subjectId: dialogEl.dataset.swsSubject || null,
dialogStart: dialogEl.dataset.swsDialogStart || null
});
});
this.scenes.push(scene);
});
}
/**
* Generates the required HTML structure from a JSON object.
* @private
*/
_initFromJSON() {
this.storyId = this.config.id;
// Parse dialog speed if provided in config
if (this.config.dialogSpeed) {
const speed = parseInt(this.config.dialogSpeed, 10);
if (!isNaN(speed) && speed > 0) {
this.dialogSpeed = speed;
}
}
this.element.dataset.sws = true;
this.element.dataset.swsId = this.storyId;
this.element.innerHTML = ''; // Clear existing content
this.config.scenes.forEach((sceneData, i) => {
const sceneEl = document.createElement('div');
sceneEl.setAttribute(`data-sws-scene-${i + 1}`, ''); // Use setAttribute for kebab-case
if (sceneData.sceneStart) {
sceneEl.dataset.swsSceneStart = sceneData.sceneStart;
}
if (i > 0) {
sceneEl.style.display = 'none';
}
// Background
const backgroundEl = document.createElement('div');
backgroundEl.dataset.swsBackground = '';
const backgroundImg = document.createElement('img');
backgroundImg.src = sceneData.background;
backgroundImg.alt = `Scene Background ${i + 1}`;
backgroundEl.appendChild(backgroundImg);
sceneEl.appendChild(backgroundEl);
// Subjects
const subjectsEl = document.createElement('div');
subjectsEl.dataset.swsSubjects = '';
const subjects = [];
sceneData.subjects.forEach(subjectData => {
const subjectImg = document.createElement('img');
subjectImg.src = subjectData.src;
subjectImg.dataset.swsSubjectId = subjectData.id;
subjectImg.dataset.swsSubjectName = subjectData.name;
subjectImg.alt = subjectData.name;
subjectsEl.appendChild(subjectImg);
// Store subject data for scene object
subjects.push({
id: subjectData.id,
name: subjectData.name,
element: subjectImg
});
});
sceneEl.appendChild(subjectsEl);
// Dialog Box
const dialogBoxEl = document.createElement('div');
dialogBoxEl.dataset.swsDialogBox = '';
const activeSubjectNameEl = document.createElement('h4');
activeSubjectNameEl.dataset.swsActiveSubjectName = '';
dialogBoxEl.appendChild(activeSubjectNameEl);
const dialogs = [];
sceneData.dialogs.forEach((dialogData, j) => {
const dialogEl = document.createElement('div');
dialogEl.dataset.swsDialog = j + 1; // This will create data-sws-dialog attribute
dialogEl.setAttribute(`data-sws-dialog-${j + 1}`, ''); // Explicitly set the kebab-case version
if (dialogData.subjectId) {
dialogEl.dataset.swsSubject = dialogData.subjectId;
}
if (dialogData.dialogStart) {
dialogEl.dataset.swsDialogStart = dialogData.dialogStart;
}
const p = document.createElement('p');
p.innerHTML = dialogData.text;
dialogEl.appendChild(p);
dialogBoxEl.appendChild(dialogEl);
// Store dialog data for scene object
dialogs.push({
element: dialogEl,
text: dialogData.text,
subjectId: dialogData.subjectId || null,
dialogStart: dialogData.dialogStart || null
});
});
sceneEl.appendChild(dialogBoxEl);
this.element.appendChild(sceneEl);
// Create scene object and add to scenes array
const scene = {
element: sceneEl,
sceneStart: sceneData.sceneStart || null,
background: sceneData.background,
subjects: subjects,
dialogs: dialogs
};
this.scenes.push(scene);
});
// Actions
const actionsEl = document.createElement('div');
actionsEl.dataset.swsActions = '';
const backButton = document.createElement('button');
backButton.dataset.swsButton = 'back';
backButton.textContent = 'Back';
const nextButton = document.createElement('button');
nextButton.dataset.swsButton = 'next';
nextButton.textContent = 'Next';
actionsEl.appendChild(backButton);
actionsEl.appendChild(nextButton);
this.element.appendChild(actionsEl);
}
/**
* Hides all scenes and dialogs to prepare the initial state.
* @private
*/
_setupUI() {
this.scenes.forEach((scene, i) => {
scene.element.style.display = i === 0 ? '' : 'none';
scene.dialogs.forEach((dialog, j) => {
if (dialog.element) {
dialog.element.style.display = 'none';
}
});
});
}
/**
* Attaches click event listeners to the navigation buttons.
* @private
*/
_attachEventListeners() {
const nextButton = this.element.querySelector('[data-sws-button="next"]');
const backButton = this.element.querySelector('[data-sws-button="back"]');
if (nextButton) nextButton.addEventListener('click', this._boundNext);
if (backButton) backButton.addEventListener('click', this._boundBack);
// Add keyboard navigation
document.addEventListener('keydown', this._boundKeydown);
}
/**
* Handles keyboard navigation.
* @param {KeyboardEvent} e - The keyboard event.
* @private
*/
_handleKeydown(e) {
// Only handle if this story container or its children are focused, or no specific element is focused
if (!this.element) return;
if (e.key === 'ArrowRight' || e.key === ' ') {
e.preventDefault();
this.next();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
this.back();
}
}
//------------------------------------------------------------------------------------------------------------------
// NAVIGATION
//------------------------------------------------------------------------------------------------------------------
/**
* Moves to the next dialog or scene.
*/
next() {
// If typewriter is running, complete it instead of moving to next dialog
if (this.isTypewriterRunning) {
this._completeTypewriter();
return;
}
const currentScene = this.scenes[this.currentSceneIndex];
if (this.currentDialogIndex < currentScene.dialogs.length - 1) {
this.currentDialogIndex++;
this._updateUI();
} else if (this.currentSceneIndex < this.scenes.length - 1) {
this.currentSceneIndex++;
this.currentDialogIndex = 0;
this._updateUI(true);
}
}
/**
* Moves to the previous dialog or scene.
*/
back() {
this._completeTypewriter();
if (this.currentDialogIndex > 0) {
this.currentDialogIndex--;
this._updateUI();
} else if (this.currentSceneIndex > 0) {
this.currentSceneIndex--;
this.currentDialogIndex = this.scenes[this.currentSceneIndex].dialogs.length - 1;
this._updateUI(true);
}
}
//------------------------------------------------------------------------------------------------------------------
// UI & EFFECTS
//------------------------------------------------------------------------------------------------------------------
/**
* Updates the entire story display based on the current state.
* @param {boolean} [sceneChanged=false] - Indicates if the scene has changed.
* @private
*/
_updateUI(sceneChanged = false) {
if (sceneChanged) {
this.scenes.forEach((scene, i) => {
scene.element.style.display = i === this.currentSceneIndex ? '' : 'none';
});
this._executeCallback(this.scenes[this.currentSceneIndex].sceneStart);
}
const scene = this.scenes[this.currentSceneIndex];
// Add defensive check to prevent undefined errors
if (!scene || !scene.dialogs || this.currentDialogIndex >= scene.dialogs.length) {
console.error('Scene or dialog not found', {
sceneIndex: this.currentSceneIndex,
dialogIndex: this.currentDialogIndex,
totalScenes: this.scenes.length,
scene: scene
});
return;
}
const dialog = scene.dialogs[this.currentDialogIndex];
// Update dialog visibility
scene.dialogs.forEach((d, i) => {
if (d.element) d.element.style.display = 'none';
});
const activeDialogElement = scene.element.querySelector(`[data-sws-dialog-${this.currentDialogIndex + 1}]`);
// Update active subject and name
const activeSubjectNameEl = scene.element.querySelector('[data-sws-active-subject-name]');
const subject = scene.subjects.find(s => s.id === dialog.subjectId);
// Remove active class from all subjects first
scene.subjects.forEach(s => s.element?.classList.remove('active'));
if (subject) {
activeSubjectNameEl.textContent = subject.name;
subject.element?.classList.add('active');
} else {
activeSubjectNameEl.textContent = '';
}
// Add defensive check for activeDialogElement
if (!activeDialogElement) {
console.error('Active dialog element not found', {
sceneIndex: this.currentSceneIndex,
dialogIndex: this.currentDialogIndex,
selector: `[data-sws-dialog-${this.currentDialogIndex + 1}]`,
sceneElement: scene.element
});
return;
}
// Start typewriter effect
this._typewriter(dialog.text, activeDialogElement.querySelector('p'));
// Execute dialog callback
this._executeCallback(dialog.dialogStart);
}
/**
* Displays text with a typewriter effect.
* @param {string} text - The text to display.
* @param {HTMLElement} element - The target element for the text.
* @private
*/
_typewriter(text, element) {
if (!element) return;
element.parentElement.style.display = '';
element.innerHTML = '';
this.isTypewriterRunning = true;
this._currentTypewriterText = text;
this._currentTypewriterElement = element;
let i = 0;
// Parse HTML to extract text content while preserving structure
const tempDiv = document.createElement('div');
tempDiv.innerHTML = text;
const plainText = tempDiv.textContent || tempDiv.innerText || '';
const type = () => {
if (i < plainText.length) {
element.textContent += plainText.charAt(i);
i++;
this.typewriterTimeout = setTimeout(type, this.dialogSpeed);
} else {
// Typewriter animation completed - restore full HTML
element.innerHTML = text;
this.isTypewriterRunning = false;
this._currentTypewriterText = '';
this._currentTypewriterElement = null;
}
};
type();
}
/**
* Instantly completes the typewriter animation.
* @private
*/
_completeTypewriter() {
if (this.typewriterTimeout) {
clearTimeout(this.typewriterTimeout);
this.typewriterTimeout = null;
this.isTypewriterRunning = false;
const scene = this.scenes[this.currentSceneIndex];
const dialog = scene.dialogs[this.currentDialogIndex];
const dialogElement = scene.element.querySelector(`[data-sws-dialog-${this.currentDialogIndex + 1}]`);
if (dialogElement) {
const pElement = dialogElement.querySelector('p');
if (pElement) {
pElement.innerHTML = dialog.text; // Use innerHTML to preserve HTML formatting
}
}
this._currentTypewriterText = '';
this._currentTypewriterElement = null;
}
}
/**
* Destroys the SWS instance and cleans up all resources.
* Call this before removing the story from the DOM or loading a new story.
*/
destroy() {
// Clear any pending typewriter animation
if (this.typewriterTimeout) {
clearTimeout(this.typewriterTimeout);
this.typewriterTimeout = null;
}
// Remove event listeners
const nextButton = this.element?.querySelector('[data-sws-button="next"]');
const backButton = this.element?.querySelector('[data-sws-button="back"]');
if (nextButton) nextButton.removeEventListener('click', this._boundNext);
if (backButton) backButton.removeEventListener('click', this._boundBack);
document.removeEventListener('keydown', this._boundKeydown);
// Clear internal state
this.scenes = [];
this.isTypewriterRunning = false;
this._currentTypewriterText = '';
this._currentTypewriterElement = null;
this.element = null;
this.config = null;
}
/**
* Executes a callback string.
* @param {string} callbackString - The JS code to execute.
* @private
*/
_executeCallback(callbackString) {
if (callbackString) {
try {
// Using new Function() is safer than eval()
new Function(callbackString)();
} catch (e) {
console.error("Error executing callback:", e);
}
}
}
}
// Auto-initialize on DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
const storyElements = document.querySelectorAll('[data-sws]');
storyElements.forEach(el => new SWS(el));
});
// Export the SWS class for module systems
export default SWS;