feat: Integrate Kokoro TTS with WebGPU and fallback
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:18
|
||||||
|
|
||||||
|
# Install basic development tools
|
||||||
|
RUN apt update && apt install -y less git procps
|
||||||
|
|
||||||
|
# Install Kokoro JS dependencies if needed
|
||||||
|
RUN apt install -y build-essential python3
|
||||||
|
|
||||||
|
# Ensure default `node` user has access to `sudo`
|
||||||
|
ARG USERNAME=node
|
||||||
|
RUN apt-get install -y sudo \
|
||||||
|
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
|
||||||
|
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
||||||
|
|
||||||
|
# Set the default user
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /workspace
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "Node.js Development",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile"
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"terminal.integrated.defaultProfile.linux": "bash",
|
||||||
|
"terminal.integrated.profiles.linux": {
|
||||||
|
"bash": {
|
||||||
|
"path": "/bin/bash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forwardPorts": [3001],
|
||||||
|
"postCreateCommand": "npm install",
|
||||||
|
"remoteUser": "node"
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ OPENROUTER_API_KEY=sk-or-v1-69865e0b635ef9bb4a2edc7c520fe056fd94b791c3d5f65009a2
|
|||||||
OPENROUTER_MODEL=anthropic/claude-3-opus-20240229
|
OPENROUTER_MODEL=anthropic/claude-3-opus-20240229
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
PORT=3000
|
PORT=3001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Game Configuration
|
# Game Configuration
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
"folders": [
|
"folders": [
|
||||||
{
|
{
|
||||||
"path": "."
|
"path": "."
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../ink.js"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to copy required assets from ink.js project to AI Interactive Fiction
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Define asset directories
|
|
||||||
const sourceDir = 'e:/Georg/vhosts/ink.js';
|
|
||||||
const targetDir = 'e:/Georg/vhosts/ai.interactive.fiction/public';
|
|
||||||
|
|
||||||
// Assets to copy
|
|
||||||
const assets = [
|
|
||||||
{ src: 'book-3057904.png', dest: 'images/book-3057904.png' },
|
|
||||||
{ src: 'brown-wooden-flooring.jpg', dest: 'images/brown-wooden-flooring.jpg' },
|
|
||||||
{ src: 'EBGaramond12-Regular.otf', dest: 'fonts/EBGaramond12-Regular.otf' },
|
|
||||||
{ src: 'EBGaramond12-Italic.otf', dest: 'fonts/EBGaramond12-Italic.otf' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create necessary directories
|
|
||||||
const directories = ['images', 'fonts', 'js', 'css'].map(dir => path.join(targetDir, dir));
|
|
||||||
directories.forEach(dir => {
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
console.log(`Creating directory: ${dir}`);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy each asset
|
|
||||||
assets.forEach(asset => {
|
|
||||||
const source = path.join(sourceDir, asset.src);
|
|
||||||
const destination = path.join(targetDir, asset.dest);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(source)) {
|
|
||||||
fs.copyFileSync(source, destination);
|
|
||||||
console.log(`Successfully copied ${source} to ${destination}`);
|
|
||||||
} else {
|
|
||||||
console.error(`Source file does not exist: ${source}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error copying ${source}:`, error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Asset copying completed.');
|
|
||||||
Vendored
+1
-1
@@ -59,7 +59,7 @@ exports.server = server;
|
|||||||
const io = new socket_io_1.Server(server);
|
const io = new socket_io_1.Server(server);
|
||||||
exports.io = io;
|
exports.io = io;
|
||||||
// Get port from environment variables or use default
|
// Get port from environment variables or use default
|
||||||
const DEFAULT_PORT = 3000;
|
const DEFAULT_PORT = 3001;
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
||||||
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
|
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
|
||||||
// Serve static files from the public directory
|
// Serve static files from the public directory
|
||||||
|
|||||||
+1
-2
@@ -13,8 +13,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint --ext .ts src/",
|
"lint": "eslint --ext .ts src/",
|
||||||
"lint:fix": "eslint --ext .ts src/ --fix",
|
"lint:fix": "eslint --ext .ts src/ --fix"
|
||||||
"copy-assets": "node copy-assets.js"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
+470
-317
@@ -1,460 +1,613 @@
|
|||||||
/* AI Interactive Fiction - Web UI Styles */
|
/* @font-face {
|
||||||
|
font-family: "Quattrocento";
|
||||||
/* Variables */
|
font-style: normal;
|
||||||
:root {
|
src: url("Quattrocento-Regular.ttf");
|
||||||
--text-color: #222;
|
|
||||||
--background-color: #f8f4e8;
|
|
||||||
--book-shadow: rgba(0, 0, 0, 0.3);
|
|
||||||
--highlight-color: #783422;
|
|
||||||
--control-color: #555;
|
|
||||||
--light-color: rgba(255, 240, 210, 0.6);
|
|
||||||
--viewport-aspect-ratio: 1.6;
|
|
||||||
--book-width: 1000px;
|
|
||||||
--book-height: 620px;
|
|
||||||
--input-bg: rgba(255, 255, 255, 0.6);
|
|
||||||
--img-aspect-ratio: 1.613;
|
|
||||||
--aspect-ratio: min(var(--viewport-aspect-ratio), var(--img-aspect-ratio));
|
|
||||||
font-size: calc(var(--book-height)/(34 * 1.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Font faces */
|
|
||||||
@font-face {
|
|
||||||
font-family: "EB Garamond";
|
|
||||||
src: url("../fonts/EBGaramond12-Regular.otf") format("opentype");
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "EB Garamond";
|
font-family: "Quattrocento";
|
||||||
src: url("../fonts/EBGaramond12-Italic.otf") format("opentype");
|
font-weight: bold;
|
||||||
font-weight: normal;
|
src: url("Quattrocento-Bold.ttf");
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global styles */
|
@font-face {
|
||||||
* {
|
font-family: "Open Sans";
|
||||||
box-sizing: border-box;
|
font-style: normal;
|
||||||
margin: 0;
|
src: url("OpenSans-VariableFont_wdth,wght.ttf");
|
||||||
padding: 0;
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Open Sans";
|
||||||
|
font-style: italic;
|
||||||
|
src: url("OpenSans-Italic-VariableFont_wdth,wght.ttf");
|
||||||
|
} */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
font-style: normal;
|
||||||
|
src: url("../fonts/EBGaramond12-Regular.otf");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
font-style: italic;
|
||||||
|
src: url("../fonts/EBGaramond12-Italic.otf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
src: url("../fonts/EBGaramond-Regular.otf") format("opentype");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
font-style:italic;
|
||||||
|
font-weight: normal;
|
||||||
|
src: url("../fonts/EBGaramond-Italic.otf") format("opentype");
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* @font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: bold;
|
||||||
|
src: url("../fonts/EBGaramond-Bold.otf") format("opentype");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: bold;
|
||||||
|
src: url("../fonts/EBGaramond-BoldItalic.otf") format("opentype");
|
||||||
|
} */
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'EB Garamond', sans-serif;
|
||||||
|
/* font-kerning: normal; */
|
||||||
|
/* font-variant-ligatures: common-ligatures historical-ligatures; */
|
||||||
|
/* font-variant-numeric: oldstyle-nums; */
|
||||||
|
font-feature-settings: 'kern' on, 'liga' on, 'onum' on, 'dlig' on, 'clig' on, 'calt' on, 'hlig' off, 'swsh' on, 'locl' off;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
font-feature-settings: "sup" on;
|
||||||
|
/* vertical-align: inherit; */
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sub {
|
||||||
|
font-feature-settings: "subs" on;
|
||||||
|
} */
|
||||||
|
|
||||||
|
double {
|
||||||
|
font-size: 2rem;
|
||||||
|
vertical-align: -10%;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
double.separator {
|
||||||
|
display: block;
|
||||||
|
margin: 2rem auto 3rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: "EB Garamond", serif;
|
margin: 0;
|
||||||
color: var(--text-color);
|
display: flex;
|
||||||
background-color: #222;
|
align-items: center;
|
||||||
background-image: url(../images/brown-wooden-flooring.jpg);
|
justify-content: center;
|
||||||
background-size: cover;
|
}
|
||||||
background-position: center;
|
|
||||||
overflow: hidden;
|
body {
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
justify-content: center;
|
background-image: url('../images/brown-wooden-flooring.jpg');
|
||||||
align-items: center;
|
background-position: center center;
|
||||||
min-height: 100vh;
|
background-size: cover;
|
||||||
margin: 0;
|
background-repeat: no-repeat;
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.switched {
|
body.switched {
|
||||||
transition: color 0.6s, background-color 0.6s;
|
transition: color 0.6s, background-color 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-size: calc(var(--book-height)/(34 * 1.5));
|
||||||
|
--img-aspect-ratio: 1.613;
|
||||||
|
--aspect-ratio: min(var(--viewport-aspect-ratio), var(--img-aspect-ratio));
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 0.8rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-transform: uppercase;
|
text-transform:uppercase;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.chapter-heading {
|
||||||
|
font-style: italic;
|
||||||
|
margin: 2rem auto 3rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
padding-top: 3rem;
|
padding-top: 3rem;
|
||||||
padding-bottom: 3rem;
|
padding-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Built-in class:
|
||||||
|
# author: Name
|
||||||
|
*/
|
||||||
.byline {
|
.byline {
|
||||||
font-feature-settings: "smcp";
|
font-feature-settings: "smcp";
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-feature-settings: "scmp" off;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
p, #ruler, #indent {
|
/*
|
||||||
font-size: 1.2rem;
|
Enables <iframe> support work on itch.io when using mobile iOS
|
||||||
line-height: 1.2;
|
*/
|
||||||
color: rgba(0,0,0,0.9);
|
.outerContainer {
|
||||||
margin-block-end: 0;
|
position: absolute;
|
||||||
margin-block-start: 0;
|
display: block;
|
||||||
|
display: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overflow: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 24px;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 980px) {
|
||||||
|
.outerContainer {
|
||||||
|
margin-top: 44px;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p, #ruler,#indent {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: rgba(0,0,0,0.9);
|
||||||
|
margin-block-end: 0;
|
||||||
|
margin-block-start: 0;
|
||||||
|
/* text-indent: 1.5em; */
|
||||||
|
/* text-align: justify; */
|
||||||
|
/* hyphens: manual; */
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
transition: color 0.6s;
|
transition: color 0.6s;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
text-decoration-thickness: 1px;
|
text-decoration-thickness: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: black;
|
color: black;
|
||||||
transition: color 0.1s;
|
transition: color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: black;
|
color: black;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-cap {
|
||||||
|
font-size: 280%;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-quote {
|
||||||
|
font-size: 180%;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
cap {
|
||||||
|
font-feature-settings: "smcp";
|
||||||
}
|
}
|
||||||
|
|
||||||
.container .hide {
|
.container .hide {
|
||||||
opacity: 0.0;
|
opacity: 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container .invisible {
|
.container .invisible {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container > *, .container > p > * {
|
.container > *, .container > p > * {
|
||||||
opacity: 1.0;
|
opacity: 1.0;
|
||||||
transition: opacity 0.5s;
|
transition: opacity 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
#command_input {
|
#choices {
|
||||||
position: absolute;
|
display: grid;
|
||||||
bottom: 1rem;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
left: 3rem;
|
width: calc(var(--book-width) * 0.39)px;
|
||||||
right: 3rem;
|
|
||||||
display: flex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#player_input {
|
#choices *:first-child {
|
||||||
flex-grow: 1;
|
grid-column: 1 / -1;
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid rgba(0,0,0,0.3);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#submit_command {
|
#choices ol.categorized {
|
||||||
margin-left: 0.5rem;
|
list-style-type: lower-alpha;
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: 1px solid rgba(0,0,0,0.3);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#submit_command:hover {
|
/*
|
||||||
background: rgba(255,255,255,1);
|
Class applied to all choices
|
||||||
|
(Will always appear inside <p> element by default.)
|
||||||
|
*/
|
||||||
|
li.choice {
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.choice {
|
||||||
|
position: relative;
|
||||||
|
/* list-style-position: inside; To make sure the number is inside the li */
|
||||||
|
counter-reset: item; /* Set a counter */
|
||||||
|
padding-inline-start: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ol.choice li::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
content: "............................................................................";
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-indent: 1.5em;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/*
|
||||||
|
Class applied to first choice
|
||||||
|
*/
|
||||||
|
:not(.choice)+.choice {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Class applied to choice links
|
||||||
|
*/
|
||||||
|
.choice a {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Built-in class:
|
||||||
|
The End # CLASS: end
|
||||||
|
*/
|
||||||
|
.end {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
color: black;
|
||||||
|
padding-top: 2rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls {
|
#controls {
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 1rem;
|
top: -0.6rem;
|
||||||
padding-top: 1rem;
|
user-select: none;
|
||||||
user-select: none;
|
transition: color 0.6s, background 0.6s;
|
||||||
transition: color 0.6s, background 0.6s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls [disabled] {
|
#controls [disabled] {
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls input[type=range] {
|
#controls input[type=range] {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls input::-webkit-slider-thumb {
|
#controls input::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
width: 0.5rem;
|
width: 0.5rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
background-color: rgba(0,0,0,0.9);
|
background-color: rgba(0,0,0,0.9);
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: -407px 0 0 400px rgba(0,0,0,0.3);
|
/* slider progress trick */
|
||||||
|
box-shadow: -407px 0 0 400px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls input::-webkit-runnable-track {
|
#controls input::-webkit-runnable-track {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls>a:not(:last-of-type):after, #controls>span::after {
|
#controls>a:not(:last-of-type):after, #controls>span::after {
|
||||||
content: " | ";
|
content: " | ";
|
||||||
}
|
}
|
||||||
|
|
||||||
#book {
|
#book {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: var(--book-width);
|
width: var(--book-width);
|
||||||
height: var(--book-height);
|
height: var(--book-height);
|
||||||
background-image: url('../images/book-3057904.png');
|
background-image: url('../images/book-3057904.png');
|
||||||
background-size: contain;
|
background-size: contain; /* Changed from cover to contain */
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat; /* Prevents repeating the image when aspect ratio doesn't match */
|
||||||
perspective: 500px;
|
perspective: 500px;
|
||||||
perspective-origin: 50% 50%;
|
perspective-origin: 50% 50%;
|
||||||
max-width: 90vw;
|
|
||||||
max-height: 90vh;
|
|
||||||
margin: 0 auto;
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#page_left, #page_right {
|
#page_left, #page_right {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5%;
|
top: 5%; /* Adjust these values as necessary */
|
||||||
bottom: 10%;
|
bottom: 10%;
|
||||||
width: 39%;
|
width: 39%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 3rem 1rem 1rem;
|
padding: 0 3rem 1rem 1rem;
|
||||||
overflow: visible;
|
/* border: 1px dotted rgba(200,200,200,1); */
|
||||||
overflow-y: scroll;
|
overflow: visible;
|
||||||
opacity: 0.95;
|
overflow-y: scroll;
|
||||||
mix-blend-mode: darken;
|
opacity: 0.95;
|
||||||
|
mix-blend-mode: darken;
|
||||||
}
|
}
|
||||||
|
|
||||||
#story {
|
#story {
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #story p span {
|
||||||
|
font-feature-settings: 'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on;
|
||||||
|
} */
|
||||||
|
|
||||||
#page_left {
|
#page_left {
|
||||||
left: 11.5%;
|
left: 11.5%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#page_right {
|
#page_right {
|
||||||
right: 7%;
|
/* background-color: rgba(200,200,200,0.5); */
|
||||||
height: calc(28 * 1.2 * 1.2rem);
|
right: 7%;
|
||||||
padding-bottom: 4rem;
|
height: calc(28 * 1.2 * 1.2rem);
|
||||||
|
padding-bottom: 0;
|
||||||
|
/* transform: translateX(-1%) translateY(2%) rotateX(0deg) rotateY(-1deg) rotateZ(0deg); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-input {
|
|
||||||
font-style: italic;
|
|
||||||
color: #555;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.narrative {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Scrollbar CSS ===== */
|
/* ===== Scrollbar CSS ===== */
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: auto;
|
scrollbar-width: auto;
|
||||||
scrollbar-color: #000000 rgba(255,255,255,0);
|
scrollbar-color: #000000 rgba(255,255,255,0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chrome, Edge, and Safari */
|
/* Chrome, Edge, and Safari */
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
width: calc(1rem/4);
|
width: calc(1rem/4);
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
background: rgba(255,255,255,0.0);
|
background: rgba(255,255,255,0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
background-color: #000000;
|
background-color: #000000;
|
||||||
border-radius: calc(1rem/4/2);
|
border-radius: calc(1rem/4/2);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
.fade-in {
|
||||||
animation: fadeIn ease 1s;
|
animation: fadeIn ease 1s;
|
||||||
-webkit-animation: fadeIn ease 1s;
|
-webkit-animation: fadeIn ease 1s;
|
||||||
-moz-animation: fadeIn ease 1s;
|
-moz-animation: fadeIn ease 1s;
|
||||||
-o-animation: fadeIn ease 1s;
|
-o-animation: fadeIn ease 1s;
|
||||||
-ms-animation: fadeIn ease 1s;
|
-ms-animation: fadeIn ease 1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
0% {opacity:0;}
|
0% {opacity:0;}
|
||||||
100% {opacity:1;}
|
100% {opacity:1;}
|
||||||
}
|
}
|
||||||
|
|
||||||
@-moz-keyframes fadeIn {
|
@-moz-keyframes fadeIn {
|
||||||
0% {opacity:0;}
|
0% {opacity:0;}
|
||||||
100% {opacity:1;}
|
100% {opacity:1;}
|
||||||
}
|
}
|
||||||
|
|
||||||
@-webkit-keyframes fadeIn {
|
@-webkit-keyframes fadeIn {
|
||||||
0% {opacity:0;}
|
0% {opacity:0;}
|
||||||
100% {opacity:1;}
|
100% {opacity:1;}
|
||||||
}
|
}
|
||||||
|
|
||||||
@-o-keyframes fadeIn {
|
@-o-keyframes fadeIn {
|
||||||
0% {opacity:0;}
|
0% {opacity:0;}
|
||||||
100% {opacity:1;}
|
100% {opacity:1;}
|
||||||
}
|
}
|
||||||
|
|
||||||
@-ms-keyframes fadeIn {
|
@-ms-keyframes fadeIn {
|
||||||
0% {opacity:0;}
|
0% {opacity:0;}
|
||||||
100% {opacity:1;}
|
100% {opacity:1;}
|
||||||
|
}
|
||||||
|
|
||||||
|
#versions {
|
||||||
|
position: fixed;
|
||||||
|
top: 0px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
border: 1px none red;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 2px 2px 2px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview p {
|
||||||
|
text-indent: 0;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ruler, #indent {
|
#ruler, #indent {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8000px;
|
top: -8000px;
|
||||||
width: auto;
|
width: auto;
|
||||||
display: inline;
|
display: inline;
|
||||||
left: -8000px;
|
left: -8000px;
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
margin-block-end: 0;
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ruler i {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
#indent {
|
||||||
|
text-indent: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#remark {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lighting {
|
#lighting {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -35%;
|
top: -35%;
|
||||||
left: -35%;
|
left: -35%;
|
||||||
width: 180%;
|
width: 180%;
|
||||||
height: 180%;
|
height: 180%;
|
||||||
animation: gradient-animation-shrink 1s 1;
|
animation: gradient-animation-shrink 1s 1;
|
||||||
background: radial-gradient(circle, rgba(255,240,182,0.1) 0%, rgba(255,237,165,0.2) 20%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.9) 100%);
|
background: radial-gradient(circle, rgba(255,240,182,0.1) 0%, rgba(255,237,165,0.2) 20%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.9) 100%);
|
||||||
mix-blend-mode: color-burn;
|
mix-blend-mode: color-burn;
|
||||||
pointer-events: none;
|
pointer-events: none; /* makes the element ignore mouse events, and pass them to elements underneath */
|
||||||
z-index: 999;
|
z-index: 999; /* should be high enough to be on top of other elements */
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes gradient-animation-grow {
|
@keyframes gradient-animation-grow {
|
||||||
0% { width: 180%; height: 180%; left: -35%; top: -35%; }
|
0% { width: 180%; height: 180%; left: -35%; top: -35%; }
|
||||||
100% { width: 170%; height: 170%; left: -33%; top: -33%; }
|
100% { width: 170%; height: 170%; left: -33%; top: -33%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes gradient-animation-shrink {
|
@keyframes gradient-animation-shrink {
|
||||||
0% { width: 170%; height: 170%; left: -33%; top: -33%; }
|
0% { width: 170%; height: 170%; left: -33%; top: -33%; }
|
||||||
100% { width: 180%; height: 180%; left: -35%; top: -35%; }
|
100% { width: 180%; height: 180%; left: -35%; top: -35%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-indicator {
|
/* Command history */
|
||||||
display: inline-block;
|
#command_history {
|
||||||
position: relative;
|
max-height: 120px;
|
||||||
width: 1.2rem;
|
overflow-y: auto;
|
||||||
height: 1.2rem;
|
font-size: 16px;
|
||||||
margin-left: 0.5rem;
|
margin-bottom: 15px;
|
||||||
}
|
border-top: 1px solid #d1c8b9;
|
||||||
.loading-indicator div {
|
padding-top: 10px;
|
||||||
box-sizing: border-box;
|
scrollbar-width: thin;
|
||||||
display: block;
|
scrollbar-color: #8b7765 rgba(255, 255, 255, 0.1);
|
||||||
position: absolute;
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border: 0.2rem solid #000;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: loading-indicator 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
|
||||||
border-color: #000 transparent transparent transparent;
|
|
||||||
}
|
|
||||||
.loading-indicator div:nth-child(1) {
|
|
||||||
animation-delay: -0.45s;
|
|
||||||
}
|
|
||||||
.loading-indicator div:nth-child(2) {
|
|
||||||
animation-delay: -0.3s;
|
|
||||||
}
|
|
||||||
.loading-indicator div:nth-child(3) {
|
|
||||||
animation-delay: -0.15s;
|
|
||||||
}
|
|
||||||
@keyframes loading-indicator {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Media queries for responsive design */
|
#command_history::-webkit-scrollbar {
|
||||||
@media (max-width: 768px) {
|
width: 6px;
|
||||||
:root {
|
|
||||||
font-size: calc(var(--book-height)/(40 * 1.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
#book {
|
|
||||||
max-width: 95vw;
|
|
||||||
max-height: 95vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#page_left, #page_right {
|
|
||||||
width: 38%;
|
|
||||||
padding: 0 1rem 1rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure responsive book sizing */
|
#command_history::-webkit-scrollbar-track {
|
||||||
@media (max-width: 1200px) {
|
background: rgba(255, 255, 255, 0.1);
|
||||||
#book {
|
|
||||||
transform: scale(0.95);
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
#command_history::-webkit-scrollbar-thumb {
|
||||||
#book {
|
background-color: #8b7765;
|
||||||
transform: scale(0.85);
|
border-radius: 4px;
|
||||||
transform-origin: center center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* Input area */
|
||||||
#book {
|
#input_area {
|
||||||
transform: scale(0.75);
|
display: flex;
|
||||||
transform-origin: center center;
|
margin-bottom: 15px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
#player_input {
|
||||||
#book {
|
flex: 1;
|
||||||
transform: scale(0.65);
|
padding: 8px 12px;
|
||||||
transform-origin: center center;
|
border: 1px solid #d1c8b9;
|
||||||
}
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
font-family: 'EB Garamond', serif;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Additional responsive fix to ensure book remains centered */
|
#submit_command {
|
||||||
@media (max-height: 700px) {
|
background-color: #8b7765;
|
||||||
#book {
|
border: 1px solid #8b7765;
|
||||||
transform: scale(0.8);
|
color: white;
|
||||||
transform-origin: center center;
|
padding: 8px 12px;
|
||||||
}
|
cursor: pointer;
|
||||||
|
font-family: 'EB Garamond', serif;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 600px) {
|
#submit_command:hover {
|
||||||
#book {
|
background-color: #6d5d4d;
|
||||||
transform: scale(0.7);
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+31
-1
@@ -59,7 +59,37 @@
|
|||||||
<script>
|
<script>
|
||||||
var locale = "en";
|
var locale = "en";
|
||||||
</script>
|
</script>
|
||||||
<script src="js/ai-fiction.js"></script>
|
|
||||||
|
<!-- TTS implementation scripts - order matters! -->
|
||||||
|
<!-- 1. Kokoro TTS library - load as module -->
|
||||||
|
<script type="module">
|
||||||
|
try {
|
||||||
|
// Import KokoroTTS class from the module
|
||||||
|
const kokoroModule = await import('./js/kokoro-js.js');
|
||||||
|
|
||||||
|
// Expose the KokoroTTS class globally
|
||||||
|
window.KokoroTTS = kokoroModule.KokoroTTS;
|
||||||
|
console.log('KokoroTTS class loaded and exposed to window');
|
||||||
|
|
||||||
|
// Dispatch an event to signal that the class is ready
|
||||||
|
const event = new CustomEvent('kokoro-class-loaded');
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load KokoroTTS module:', error);
|
||||||
|
// Dispatch an event even on failure so handlers don't wait forever
|
||||||
|
const event = new CustomEvent('kokoro-class-load-failed');
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- 2. TTS handlers (kokoro-handler needs to wait for KokoroTTS) -->
|
||||||
|
<script src="js/kokoro-handler.js"></script>
|
||||||
<script src="js/tts-handler.js"></script>
|
<script src="js/tts-handler.js"></script>
|
||||||
|
|
||||||
|
<!-- 3. TTS Factory for automatic selection -->
|
||||||
|
<script src="js/tts-factory.js"></script>
|
||||||
|
|
||||||
|
<!-- Main application script -->
|
||||||
|
<script src="js/ai-fiction.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
/**
|
||||||
|
* @license Hyphenopoly 5.2.0-beta.1 - client side hyphenation for webbrowsers
|
||||||
|
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||||
|
* https://github.com/mnater/Hyphenopoly
|
||||||
|
*
|
||||||
|
* Released under the MIT license
|
||||||
|
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* globals Hyphenopoly:readonly */
|
||||||
|
((w, o) => {
|
||||||
|
"use strict";
|
||||||
|
const SOFTHYPHEN = "\u00AD";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event
|
||||||
|
*/
|
||||||
|
const event = ((H) => {
|
||||||
|
const knownEvents = new Map([
|
||||||
|
["afterElementHyphenation", []],
|
||||||
|
["beforeElementHyphenation", []],
|
||||||
|
["engineReady", []],
|
||||||
|
[
|
||||||
|
"error", [
|
||||||
|
(e) => {
|
||||||
|
if (e.runDefault) {
|
||||||
|
w.console.warn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
["hyphenopolyEnd", []],
|
||||||
|
["hyphenopolyStart", []]
|
||||||
|
]);
|
||||||
|
if (H.hev) {
|
||||||
|
const userEvents = new Map(o.entries(H.hev));
|
||||||
|
knownEvents.forEach((eventFuncs, eventName) => {
|
||||||
|
if (userEvents.has(eventName)) {
|
||||||
|
eventFuncs.unshift(userEvents.get(eventName));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"fire": ((eventName, eventData) => {
|
||||||
|
eventData.runDefault = true;
|
||||||
|
eventData.preventDefault = () => {
|
||||||
|
eventData.runDefault = false;
|
||||||
|
};
|
||||||
|
knownEvents.get(eventName).forEach((eventFn) => {
|
||||||
|
eventFn(eventData);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
})(Hyphenopoly);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register copy event on element
|
||||||
|
* @param {Object} el The element
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function registerOnCopy(el) {
|
||||||
|
el.addEventListener(
|
||||||
|
"copy",
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sel = w.getSelection();
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.appendChild(sel.getRangeAt(0).cloneContents());
|
||||||
|
e.clipboardData.setData("text/plain", sel.toString().replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||||
|
e.clipboardData.setData("text/html", div.innerHTML.replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert settings from H.setup-Object to Map
|
||||||
|
* This is a IIFE to keep complexity low.
|
||||||
|
*/
|
||||||
|
((H) => {
|
||||||
|
/**
|
||||||
|
* Create a Map with a default Map behind the scenes. This mimics
|
||||||
|
* kind of a prototype chain of an object, but without the object-
|
||||||
|
* injection security risk.
|
||||||
|
*
|
||||||
|
* @param {Map} defaultsMap - A Map with default values
|
||||||
|
* @returns {Proxy} - A Proxy for the Map (dot-notation or get/set)
|
||||||
|
*/
|
||||||
|
function createMapWithDefaults(defaultsMap) {
|
||||||
|
const userMap = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The get-trap: get the value from userMap or else from defaults
|
||||||
|
* @param {Sring} key - The key to retrieve the value for
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
function get(key) {
|
||||||
|
return (userMap.has(key))
|
||||||
|
? userMap.get(key)
|
||||||
|
: defaultsMap.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set-trap: set the value to userMap and don't touch defaults
|
||||||
|
* @param {Sring} key - The key for the value
|
||||||
|
* @param {*} value - The value
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
function set(key, value) {
|
||||||
|
userMap.set(key, value);
|
||||||
|
}
|
||||||
|
return new Proxy(defaultsMap, {
|
||||||
|
"get": (_target, prop) => {
|
||||||
|
if (prop === "set") {
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
if (prop === "get") {
|
||||||
|
return get;
|
||||||
|
}
|
||||||
|
return get(prop);
|
||||||
|
},
|
||||||
|
"ownKeys": () => {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
[...defaultsMap.keys(), ...userMap.keys()]
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = createMapWithDefaults(new Map([
|
||||||
|
["defaultLanguage", "en-us"],
|
||||||
|
[
|
||||||
|
"dontHyphenate", (() => {
|
||||||
|
const list = "abbr,acronym,audio,br,button,code,img,input,kbd,label,math,option,pre,samp,script,style,sub,sup,svg,textarea,var,video";
|
||||||
|
return createMapWithDefaults(
|
||||||
|
new Map(list.split(",").map((val) => {
|
||||||
|
return [val, true];
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
],
|
||||||
|
["dontHyphenateClass", "donthyphenate"],
|
||||||
|
["exceptions", new Map()],
|
||||||
|
["keepAlive", true],
|
||||||
|
["normalize", false],
|
||||||
|
["processShadows", false],
|
||||||
|
["safeCopy", true],
|
||||||
|
["substitute", new Map()],
|
||||||
|
["timeout", 1000]
|
||||||
|
]));
|
||||||
|
o.entries(H.s).forEach(([key, value]) => {
|
||||||
|
switch (key) {
|
||||||
|
case "selectors":
|
||||||
|
// Set settings.selectors to array of selectors
|
||||||
|
settings.set("selectors", o.keys(value));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* For each selector add a property to settings with
|
||||||
|
* selector specific settings
|
||||||
|
*/
|
||||||
|
o.entries(value).forEach(([sel, selSettings]) => {
|
||||||
|
const selectorSettings = createMapWithDefaults(new Map([
|
||||||
|
["compound", "hyphen"],
|
||||||
|
["hyphen", SOFTHYPHEN],
|
||||||
|
["leftmin", 0],
|
||||||
|
["leftminPerLang", 0],
|
||||||
|
["minWordLength", 6],
|
||||||
|
["mixedCase", true],
|
||||||
|
["orphanControl", 1],
|
||||||
|
["rightmin", 0],
|
||||||
|
["rightminPerLang", 0]
|
||||||
|
]));
|
||||||
|
o.entries(selSettings).forEach(
|
||||||
|
([selSetting, setVal]) => {
|
||||||
|
if (typeof setVal === "object") {
|
||||||
|
selectorSettings.set(
|
||||||
|
selSetting,
|
||||||
|
new Map(o.entries(setVal))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectorSettings.set(selSetting, setVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
settings.set(sel, selectorSettings);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "dontHyphenate":
|
||||||
|
case "exceptions":
|
||||||
|
o.entries(value).forEach(([k, v]) => {
|
||||||
|
settings.get(key).set(k, v);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "substitute":
|
||||||
|
o.entries(value).forEach(([lang, subst]) => {
|
||||||
|
settings.substitute.set(
|
||||||
|
lang,
|
||||||
|
new Map(o.entries(subst))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
settings.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
H.c = settings;
|
||||||
|
})(Hyphenopoly);
|
||||||
|
|
||||||
|
((H) => {
|
||||||
|
const C = H.c;
|
||||||
|
let mainLanguage = null;
|
||||||
|
|
||||||
|
event.fire(
|
||||||
|
"hyphenopolyStart",
|
||||||
|
{
|
||||||
|
"msg": "hyphenopolyStart"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for elements
|
||||||
|
* @returns {Object} elements-object
|
||||||
|
*/
|
||||||
|
function makeElementCollection() {
|
||||||
|
const list = new Map();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Counter counts the elements to be hyphenated.
|
||||||
|
* Needs to be an object (Pass by reference)
|
||||||
|
*/
|
||||||
|
const counter = [0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add element to elements
|
||||||
|
* @param {object} el The element
|
||||||
|
* @param {string} lang The language of the element
|
||||||
|
* @param {string} sel The selector of the element
|
||||||
|
* @returns {Object} An element-object
|
||||||
|
*/
|
||||||
|
function add(el, lang, sel) {
|
||||||
|
const elo = {
|
||||||
|
"element": el,
|
||||||
|
"selector": sel
|
||||||
|
};
|
||||||
|
if (!list.has(lang)) {
|
||||||
|
list.set(lang, []);
|
||||||
|
}
|
||||||
|
list.get(lang).push(elo);
|
||||||
|
counter[0] += 1;
|
||||||
|
return elo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes elements from the list and updates the counter
|
||||||
|
* @param {string} lang - The lang of the elements to remove
|
||||||
|
*/
|
||||||
|
function rem(lang) {
|
||||||
|
let langCount = 0;
|
||||||
|
if (list.has(lang)) {
|
||||||
|
langCount = list.get(lang).length;
|
||||||
|
list.delete(lang);
|
||||||
|
counter[0] -= langCount;
|
||||||
|
if (counter[0] === 0) {
|
||||||
|
event.fire(
|
||||||
|
"hyphenopolyEnd",
|
||||||
|
{
|
||||||
|
"msg": "hyphenopolyEnd"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!C.keepAlive) {
|
||||||
|
window.Hyphenopoly = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add,
|
||||||
|
counter,
|
||||||
|
list,
|
||||||
|
rem
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get language of element by searching its parents or fallback
|
||||||
|
* @param {Object} el The element
|
||||||
|
* @param {string} parentLang Lang of parent if available
|
||||||
|
* @param {boolean} fallback Will falback to mainlanguage
|
||||||
|
* @returns {string|null} The language or null
|
||||||
|
*/
|
||||||
|
function getLang(el, parentLang = "", fallback = true) {
|
||||||
|
// Find closest el with lang attr not empty
|
||||||
|
el = el.closest("[lang]:not([lang=''])");
|
||||||
|
if (el && el.lang) {
|
||||||
|
return el.lang.toLowerCase();
|
||||||
|
}
|
||||||
|
if (parentLang) {
|
||||||
|
return parentLang;
|
||||||
|
}
|
||||||
|
return (fallback)
|
||||||
|
? mainLanguage
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect elements that have a selector defined in C.selectors
|
||||||
|
* and add them to elements.
|
||||||
|
* @param {Object} [parent = null] The start point element
|
||||||
|
* @param {string} [selector = null] The selector matching the parent
|
||||||
|
* @returns {Object} elements-object
|
||||||
|
*/
|
||||||
|
function collectElements(parent = null, selector = null) {
|
||||||
|
const elements = makeElementCollection();
|
||||||
|
|
||||||
|
const dontHyphenateSelector = (() => {
|
||||||
|
let s = "." + C.dontHyphenateClass;
|
||||||
|
o.getOwnPropertyNames(C.dontHyphenate).forEach((tag) => {
|
||||||
|
if (C.dontHyphenate.get(tag)) {
|
||||||
|
s += "," + tag;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
})();
|
||||||
|
const matchingSelectors = C.selectors.join(",") + "," + dontHyphenateSelector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk all elements in el, lending lang and selName
|
||||||
|
* add them to elements if necessary.
|
||||||
|
* @param {Object} el The element to scan
|
||||||
|
* @param {string} pLang The language of the parent element
|
||||||
|
* @param {string} sel The selector of the parent element
|
||||||
|
* @param {boolean} isChild If el is a child element
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function processElements(el, pLang, sel, isChild = false) {
|
||||||
|
const eLang = getLang(el, pLang);
|
||||||
|
const langDef = H.cf.langs.get(eLang);
|
||||||
|
if (langDef === "H9Y") {
|
||||||
|
elements.add(el, eLang, sel);
|
||||||
|
if (!isChild && C.safeCopy) {
|
||||||
|
registerOnCopy(el);
|
||||||
|
}
|
||||||
|
} else if (!langDef && eLang !== "zxx") {
|
||||||
|
event.fire(
|
||||||
|
"error",
|
||||||
|
Error(`Element with '${eLang}' found, but '${eLang}.wasm' not loaded. Check language tags!`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
el.childNodes.forEach((n) => {
|
||||||
|
if (n.nodeType === 1 && !n.matches(matchingSelectors)) {
|
||||||
|
processElements(n, eLang, sel, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches the DOM for each sel
|
||||||
|
* @param {object} root The DOM root
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function getElems(root) {
|
||||||
|
C.selectors.forEach((sel) => {
|
||||||
|
root.querySelectorAll(sel).forEach((n) => {
|
||||||
|
processElements(n, getLang(n), sel, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent === null) {
|
||||||
|
if (C.processShadows) {
|
||||||
|
w.document.querySelectorAll("*").forEach((m) => {
|
||||||
|
if (m.shadowRoot) {
|
||||||
|
getElems(m.shadowRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getElems(w.document);
|
||||||
|
} else {
|
||||||
|
processElements(parent, getLang(parent), selector);
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordHyphenatorPool = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for hyphenatorFunctions for a specific language and selector
|
||||||
|
* @param {Object} lo Language-Object
|
||||||
|
* @param {string} lang The language
|
||||||
|
* @param {string} sel The selector
|
||||||
|
* @returns {function} The hyphenate function
|
||||||
|
*/
|
||||||
|
function createWordHyphenator(lo, lang, sel) {
|
||||||
|
const poolKey = lang + "-" + sel;
|
||||||
|
if (wordHyphenatorPool.has(poolKey)) {
|
||||||
|
return wordHyphenatorPool.get(poolKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selSettings = C.get(sel);
|
||||||
|
lo.cache.set(sel, new Map());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HyphenateFunction for non-compound words
|
||||||
|
* @param {string} word The word
|
||||||
|
* @returns {string} The hyphenated word
|
||||||
|
*/
|
||||||
|
function hyphenateNormal(word) {
|
||||||
|
if (word.length > 61) {
|
||||||
|
event.fire(
|
||||||
|
"error",
|
||||||
|
Error("Found word longer than 61 characters")
|
||||||
|
);
|
||||||
|
} else if (!lo.reNotAlphabet.test(word)) {
|
||||||
|
return lo.hyphenate(
|
||||||
|
word,
|
||||||
|
selSettings.hyphen.charCodeAt(0),
|
||||||
|
selSettings.leftminPerLang.get(lang),
|
||||||
|
selSettings.rightminPerLang.get(lang)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HyphenateFunction for compound words
|
||||||
|
* @param {string} word The word
|
||||||
|
* @returns {string} The hyphenated compound word
|
||||||
|
*/
|
||||||
|
function hyphenateCompound(word) {
|
||||||
|
const zeroWidthSpace = "\u200B";
|
||||||
|
let parts = null;
|
||||||
|
let wordHyphenator = null;
|
||||||
|
if (selSettings.compound === "auto" ||
|
||||||
|
selSettings.compound === "all") {
|
||||||
|
wordHyphenator = createWordHyphenator(lo, lang, sel);
|
||||||
|
parts = word.split("-").map((p) => {
|
||||||
|
if (p.length >= selSettings.minWordLength) {
|
||||||
|
return wordHyphenator(p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
if (selSettings.compound === "auto") {
|
||||||
|
word = parts.join("-");
|
||||||
|
} else {
|
||||||
|
word = parts.join("-" + zeroWidthSpace);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
word = word.replace("-", "-" + zeroWidthSpace);
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is mixed case
|
||||||
|
* @param {string} s The string
|
||||||
|
* @returns {boolean} true if s is mixed case
|
||||||
|
*/
|
||||||
|
function isMixedCase(s) {
|
||||||
|
return [...s].map((c) => {
|
||||||
|
return (c === c.toLowerCase());
|
||||||
|
}).some((v, i, a) => {
|
||||||
|
return (v !== a[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HyphenateFunction for words (compound or not)
|
||||||
|
* @param {string} word The word
|
||||||
|
* @returns {string} The hyphenated word
|
||||||
|
*/
|
||||||
|
function hyphenator(word) {
|
||||||
|
let hw = lo.cache.get(sel).get(word);
|
||||||
|
if (!hw) {
|
||||||
|
if (lo.exc.has(word)) {
|
||||||
|
hw = lo.exc.get(word).replace(
|
||||||
|
/-/g,
|
||||||
|
selSettings.hyphen
|
||||||
|
);
|
||||||
|
} else if (!selSettings.mixedCase && isMixedCase(word)) {
|
||||||
|
hw = word;
|
||||||
|
} else if (word.indexOf("-") === -1) {
|
||||||
|
hw = hyphenateNormal(word);
|
||||||
|
} else {
|
||||||
|
hw = hyphenateCompound(word);
|
||||||
|
}
|
||||||
|
lo.cache.get(sel).set(word, hw);
|
||||||
|
}
|
||||||
|
return hw;
|
||||||
|
}
|
||||||
|
wordHyphenatorPool.set(poolKey, hyphenator);
|
||||||
|
return hyphenator;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orphanControllerPool = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for function that handles orphans
|
||||||
|
* @param {string} sel The selector
|
||||||
|
* @returns {function} The function created
|
||||||
|
*/
|
||||||
|
function createOrphanController(sel) {
|
||||||
|
if (orphanControllerPool.has(sel)) {
|
||||||
|
return orphanControllerPool.get(sel);
|
||||||
|
}
|
||||||
|
const selSettings = C.get(sel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function template
|
||||||
|
* @param {string} ignore unused result of replace
|
||||||
|
* @param {string} leadingWhiteSpace The leading whiteSpace
|
||||||
|
* @param {string} lastWord The last word
|
||||||
|
* @param {string} trailingWhiteSpace The trailing whiteSpace
|
||||||
|
* @returns {string} Treated end of text
|
||||||
|
*/
|
||||||
|
function controlOrphans(
|
||||||
|
ignore,
|
||||||
|
leadingWhiteSpace,
|
||||||
|
lastWord,
|
||||||
|
trailingWhiteSpace
|
||||||
|
) {
|
||||||
|
if (selSettings.orphanControl === 3 && leadingWhiteSpace === " ") {
|
||||||
|
// \u00A0 = no-break space (nbsp)
|
||||||
|
leadingWhiteSpace = "\u00A0";
|
||||||
|
}
|
||||||
|
return leadingWhiteSpace + lastWord.replace(RegExp(selSettings.hyphen, "g"), "") + trailingWhiteSpace;
|
||||||
|
}
|
||||||
|
orphanControllerPool.set(sel, controlOrphans);
|
||||||
|
return controlOrphans;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordRegExpPool = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hyphenate an entitiy (text string or Element-Object)
|
||||||
|
* @param {string} lang - the language of the string
|
||||||
|
* @param {string} sel - the selectorName of settings
|
||||||
|
* @param {string} entity - the entity to be hyphenated
|
||||||
|
* @returns {string | null} hyphenated str according to setting of sel
|
||||||
|
*/
|
||||||
|
function hyphenate(lang, sel, entity) {
|
||||||
|
const lo = H.languages.get(lang);
|
||||||
|
const selSettings = C.get(sel);
|
||||||
|
const minWordLength = selSettings.minWordLength;
|
||||||
|
|
||||||
|
|
||||||
|
const regExpWord = (() => {
|
||||||
|
const key = lang + minWordLength;
|
||||||
|
if (wordRegExpPool.has(key)) {
|
||||||
|
return wordRegExpPool.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Transpiled RegExp of
|
||||||
|
* /[${alphabet}\p{Mn}Subset\p{Letter}\00AD-]
|
||||||
|
* {${minwordlength},}/gui
|
||||||
|
*/
|
||||||
|
const reWord = RegExp(
|
||||||
|
`[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\u00AD\u200B-\u200D-]{${minWordLength},}`, "gui"
|
||||||
|
);
|
||||||
|
wordRegExpPool.set(key, reWord);
|
||||||
|
return reWord;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hyphenate text according to setting in sel
|
||||||
|
* @param {string} text - the strint to be hyphenated
|
||||||
|
* @returns {string} hyphenated string according to setting of sel
|
||||||
|
*/
|
||||||
|
function hyphenateText(text) {
|
||||||
|
if (C.normalize) {
|
||||||
|
text = text.normalize("NFC");
|
||||||
|
}
|
||||||
|
let tn = text.replace(
|
||||||
|
regExpWord,
|
||||||
|
createWordHyphenator(lo, lang, sel)
|
||||||
|
);
|
||||||
|
if (selSettings.orphanControl !== 1) {
|
||||||
|
tn = tn.replace(
|
||||||
|
/(\u0020*)(\S+)(\s*)$/,
|
||||||
|
createOrphanController(sel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hyphenate element according to setting in sel
|
||||||
|
* @param {object} el - the HTMLElement to be hyphenated
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function hyphenateElement(el) {
|
||||||
|
event.fire(
|
||||||
|
"beforeElementHyphenation",
|
||||||
|
{
|
||||||
|
el,
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
);
|
||||||
|
el.childNodes.forEach((n) => {
|
||||||
|
if (
|
||||||
|
n.nodeType === 3 &&
|
||||||
|
(/\S/).test(n.data) &&
|
||||||
|
n.data.length >= minWordLength
|
||||||
|
) {
|
||||||
|
n.data = hyphenateText(n.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
H.res.els.counter[0] -= 1;
|
||||||
|
event.fire(
|
||||||
|
"afterElementHyphenation",
|
||||||
|
{
|
||||||
|
el,
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let r = null;
|
||||||
|
if (typeof entity === "string") {
|
||||||
|
r = hyphenateText(entity);
|
||||||
|
} else if (entity instanceof HTMLElement) {
|
||||||
|
hyphenateElement(entity);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a language-specific string hyphenator
|
||||||
|
* @param {String} lang - The language this hyphenator hyphenates
|
||||||
|
*/
|
||||||
|
function createStringHyphenator(lang) {
|
||||||
|
return ((entity, sel = ".hyphenate") => {
|
||||||
|
if (typeof entity !== "string") {
|
||||||
|
event.fire(
|
||||||
|
"error",
|
||||||
|
Error("This use of hyphenators is deprecated. See https://mnater.github.io/Hyphenopoly/Hyphenators.html")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hyphenate(lang, sel, entity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a polyglot HTML hyphenator
|
||||||
|
*/
|
||||||
|
function createDOMHyphenator() {
|
||||||
|
return ((entity, sel = ".hyphenate") => {
|
||||||
|
collectElements(entity, sel).list.forEach((els, l) => {
|
||||||
|
els.forEach((elo) => {
|
||||||
|
hyphenate(l, elo.selector, elo.element);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
H.unhyphenate = () => {
|
||||||
|
H.res.els.list.forEach((els) => {
|
||||||
|
els.forEach((elo) => {
|
||||||
|
const n = elo.element.firstChild;
|
||||||
|
n.data = n.data.replace(RegExp(C[elo.selector].hyphen, "g"), "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Promise.resolve(H.res.els);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hyphenate all elements with a given language
|
||||||
|
* @param {string} lang The language
|
||||||
|
* @param {Array} elArr Array of elements
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function hyphenateLangElements(lang, elements) {
|
||||||
|
const elArr = elements.list.get(lang);
|
||||||
|
if (elArr) {
|
||||||
|
elArr.forEach((elo) => {
|
||||||
|
hyphenate(lang, elo.selector, elo.element);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
event.fire(
|
||||||
|
"error",
|
||||||
|
Error(`Engine for language '${lang}' loaded, but no elements found.`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (elements.counter[0] === 0) {
|
||||||
|
w.clearTimeout(H.timeOutHandler);
|
||||||
|
H.hide(0, null);
|
||||||
|
event.fire(
|
||||||
|
"hyphenopolyEnd",
|
||||||
|
{
|
||||||
|
"msg": "hyphenopolyEnd"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!C.keepAlive) {
|
||||||
|
window.Hyphenopoly = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the exceptions from user input to Map
|
||||||
|
* @param {string} lang - The language for which the Map is created
|
||||||
|
* @return {Map}
|
||||||
|
*/
|
||||||
|
function createExceptionMap(lang) {
|
||||||
|
let exc = "";
|
||||||
|
if (C.exceptions.has(lang)) {
|
||||||
|
exc = C.exceptions.get(lang);
|
||||||
|
}
|
||||||
|
if (C.exceptions.has("global")) {
|
||||||
|
if (exc === "") {
|
||||||
|
exc = C.exceptions.get("global");
|
||||||
|
} else {
|
||||||
|
exc += ", " + C.exceptions.get("global");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exc === "") {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
return new Map(exc.split(", ").map((e) => {
|
||||||
|
return [e.replace(/-/g, ""), e];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup lo
|
||||||
|
* @param {string} lang The language
|
||||||
|
* @param {function} hyphenateFunction The hyphenateFunction
|
||||||
|
* @param {string} alphabet List of used characters
|
||||||
|
* @param {number} leftmin leftmin
|
||||||
|
* @param {number} rightmin rightmin
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function prepareLanguagesObj(
|
||||||
|
lang,
|
||||||
|
hyphenateFunction,
|
||||||
|
alphabet,
|
||||||
|
patternLeftmin,
|
||||||
|
patternRightmin
|
||||||
|
) {
|
||||||
|
C.selectors.forEach((sel) => {
|
||||||
|
const selSettings = C.get(sel);
|
||||||
|
if (selSettings.leftminPerLang === 0) {
|
||||||
|
selSettings.set("leftminPerLang", new Map());
|
||||||
|
}
|
||||||
|
if (selSettings.rightminPerLang === 0) {
|
||||||
|
selSettings.set("rightminPerLang", new Map());
|
||||||
|
}
|
||||||
|
selSettings.leftminPerLang.set(lang, Math.max(
|
||||||
|
patternLeftmin,
|
||||||
|
selSettings.leftmin,
|
||||||
|
Number(selSettings.leftminPerLang.get(lang)) || 0
|
||||||
|
));
|
||||||
|
|
||||||
|
selSettings.rightminPerLang.set(lang, Math.max(
|
||||||
|
patternRightmin,
|
||||||
|
selSettings.rightmin,
|
||||||
|
Number(selSettings.rightminPerLang.get(lang)) || 0
|
||||||
|
));
|
||||||
|
});
|
||||||
|
if (!H.languages) {
|
||||||
|
H.languages = new Map();
|
||||||
|
}
|
||||||
|
alphabet = alphabet.replace(/\\*-/g, "\\-");
|
||||||
|
H.languages.set(lang, {
|
||||||
|
alphabet,
|
||||||
|
"cache": new Map(),
|
||||||
|
"exc": createExceptionMap(lang),
|
||||||
|
"hyphenate": hyphenateFunction,
|
||||||
|
"ready": true,
|
||||||
|
"reNotAlphabet": RegExp(`[^${alphabet}]`, "i")
|
||||||
|
});
|
||||||
|
H.hy6ors.get(lang).resolve(createStringHyphenator(lang));
|
||||||
|
event.fire(
|
||||||
|
"engineReady",
|
||||||
|
{
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (H.res.els) {
|
||||||
|
hyphenateLangElements(lang, H.res.els);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decode = (() => {
|
||||||
|
const utf16ledecoder = new TextDecoder("utf-16le");
|
||||||
|
return ((ui16) => {
|
||||||
|
return utf16ledecoder.decode(ui16);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup env for hyphenateFunction
|
||||||
|
* @param {ArrayBuffer} buf Memory buffer
|
||||||
|
* @param {function} hyphenateFunc hyphenateFunction
|
||||||
|
* @returns {function} hyphenateFunction with closured environment
|
||||||
|
*/
|
||||||
|
function encloseHyphenateFunction(buf, hyphenateFunc) {
|
||||||
|
const wordStore = new Uint16Array(buf, 0, 64);
|
||||||
|
return ((word, hyphencc, leftmin, rightmin) => {
|
||||||
|
wordStore.set([
|
||||||
|
...[...word].map((c) => {
|
||||||
|
return c.charCodeAt(0);
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
]);
|
||||||
|
const len = hyphenateFunc(leftmin, rightmin, hyphencc);
|
||||||
|
if (len > 0) {
|
||||||
|
word = decode(
|
||||||
|
new Uint16Array(buf, 0, len)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate Wasm Engine
|
||||||
|
* @param {string} lang The language
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
function instantiateWasmEngine(heProm, lang) {
|
||||||
|
const wa = window.WebAssembly;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register character substitutions in the .wasm-hyphenEngine
|
||||||
|
* @param {number} alphalen - The length of the alphabet
|
||||||
|
* @param {object} exp - Export-object of the hyphenEngine
|
||||||
|
*/
|
||||||
|
function registerSubstitutions(alphalen, exp) {
|
||||||
|
if (C.substitute.has(lang)) {
|
||||||
|
const subst = C.substitute.get(lang);
|
||||||
|
subst.forEach((substituer, substituted) => {
|
||||||
|
const substitutedU = substituted.toUpperCase();
|
||||||
|
const substitutedUcc = (substitutedU === substituted)
|
||||||
|
? 0
|
||||||
|
: substitutedU.charCodeAt(0);
|
||||||
|
alphalen = exp.subst(
|
||||||
|
substituted.charCodeAt(0),
|
||||||
|
substitutedUcc,
|
||||||
|
substituer.charCodeAt(0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return alphalen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate the hyphenEngine
|
||||||
|
* @param {object} res - The fetched ressource
|
||||||
|
*/
|
||||||
|
function handleWasm(res) {
|
||||||
|
const exp = res.instance.exports;
|
||||||
|
// eslint-disable-next-line multiline-ternary
|
||||||
|
let alphalen = (wa.Global) ? exp.lct.value : exp.lct;
|
||||||
|
alphalen = registerSubstitutions(alphalen, exp);
|
||||||
|
heProm.l.forEach((l) => {
|
||||||
|
prepareLanguagesObj(
|
||||||
|
l,
|
||||||
|
encloseHyphenateFunction(
|
||||||
|
exp.mem.buffer,
|
||||||
|
exp.hyphenate
|
||||||
|
),
|
||||||
|
decode(new Uint16Array(exp.mem.buffer, 1408, alphalen)),
|
||||||
|
/* eslint-disable multiline-ternary */
|
||||||
|
(wa.Global) ? exp.lmi.value : exp.lmi,
|
||||||
|
(wa.Global) ? exp.rmi.value : exp.rmi
|
||||||
|
/* eslint-enable multiline-ternary */
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
heProm.w.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
if (
|
||||||
|
wa.instantiateStreaming &&
|
||||||
|
(response.headers.get("Content-Type") === "application/wasm")
|
||||||
|
) {
|
||||||
|
return wa.instantiateStreaming(response);
|
||||||
|
}
|
||||||
|
return response.arrayBuffer().then((ab) => {
|
||||||
|
return wa.instantiate(ab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(Error(`File ${lang}.wasm can't be loaded from ${H.paths.patterndir}`));
|
||||||
|
}).then(handleWasm, (e) => {
|
||||||
|
event.fire("error", e);
|
||||||
|
H.res.els.rem(lang);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
H.main = () => {
|
||||||
|
H.res.DOM.then(() => {
|
||||||
|
mainLanguage = getLang(w.document.documentElement, "", false);
|
||||||
|
if (!mainLanguage && C.defaultLanguage !== "") {
|
||||||
|
mainLanguage = C.defaultLanguage;
|
||||||
|
}
|
||||||
|
const elements = collectElements();
|
||||||
|
H.res.els = elements;
|
||||||
|
elements.list.forEach((ignore, lang) => {
|
||||||
|
if (H.languages &&
|
||||||
|
H.languages.has(lang) &&
|
||||||
|
H.languages.get(lang).ready
|
||||||
|
) {
|
||||||
|
hyphenateLangElements(lang, elements);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
H.res.he.forEach(instantiateWasmEngine);
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
// Make sure all lang specific hyphenators and DOM are ready
|
||||||
|
[...H.hy6ors.entries()].
|
||||||
|
reduce((accumulator, value) => {
|
||||||
|
if (value[0] !== "HTML") {
|
||||||
|
return accumulator.concat(value[1]);
|
||||||
|
}
|
||||||
|
return accumulator;
|
||||||
|
}, []).
|
||||||
|
concat(H.res.DOM)
|
||||||
|
).then(() => {
|
||||||
|
H.hy6ors.get("HTML").resolve(createDOMHyphenator());
|
||||||
|
}, (e) => {
|
||||||
|
event.fire("error", e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
H.main();
|
||||||
|
})(Hyphenopoly);
|
||||||
|
})(window, Object);
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* @license Hyphenopoly_Loader 5.2.0-beta.1 - client side hyphenation
|
||||||
|
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||||
|
* https://github.com/mnater/Hyphenopoly
|
||||||
|
*
|
||||||
|
* Released under the MIT license
|
||||||
|
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||||
|
*/
|
||||||
|
/* globals Hyphenopoly:readonly */
|
||||||
|
window.Hyphenopoly = {};
|
||||||
|
|
||||||
|
((w, d, H, o) => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut for new Map
|
||||||
|
* @param {any} init - initialiser for new Map
|
||||||
|
* @returns {Map}
|
||||||
|
*/
|
||||||
|
const mp = (init) => {
|
||||||
|
return new Map(init);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scriptName = "Hyphenopoly_Loader.js";
|
||||||
|
const thisScript = d.currentScript.src;
|
||||||
|
const store = sessionStorage;
|
||||||
|
let mainScriptLoaded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main function runs the feature test and loads Hyphenopoly if
|
||||||
|
* necessary.
|
||||||
|
*/
|
||||||
|
const main = (() => {
|
||||||
|
const shortcuts = {
|
||||||
|
"ac": "appendChild",
|
||||||
|
"ce": "createElement",
|
||||||
|
"ct": "createTextNode"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create deferred Promise
|
||||||
|
*
|
||||||
|
* From http://lea.verou.me/2016/12/resolve-promises-externally-with-
|
||||||
|
* this-one-weird-trick/
|
||||||
|
* @return {promise}
|
||||||
|
*/
|
||||||
|
const defProm = () => {
|
||||||
|
let res = null;
|
||||||
|
let rej = null;
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
|
res = resolve;
|
||||||
|
rej = reject;
|
||||||
|
});
|
||||||
|
promise.resolve = res;
|
||||||
|
promise.reject = rej;
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
H.ac = new AbortController();
|
||||||
|
const fetchOptions = {
|
||||||
|
"credentials": H.s.CORScredentials,
|
||||||
|
"signal": H.ac.signal
|
||||||
|
};
|
||||||
|
|
||||||
|
let stylesNode = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define function H.hide.
|
||||||
|
* This function hides (state = 1) or unhides (state = 0)
|
||||||
|
* the whole document (mode == 0) or
|
||||||
|
* each selected element (mode == 1) or
|
||||||
|
* text of each selected element (mode == 2) or
|
||||||
|
* nothing (mode == -1)
|
||||||
|
* @param {integer} state - State
|
||||||
|
* @param {integer} mode - Mode
|
||||||
|
*/
|
||||||
|
H.hide = (state, mode) => {
|
||||||
|
if (state) {
|
||||||
|
let vis = "{visibility:hidden!important}";
|
||||||
|
stylesNode = d[shortcuts.ce]("style");
|
||||||
|
let myStyle = "";
|
||||||
|
if (mode === 0) {
|
||||||
|
myStyle = "html" + vis;
|
||||||
|
} else if (mode !== -1) {
|
||||||
|
if (mode === 2) {
|
||||||
|
vis = "{color:transparent!important}";
|
||||||
|
}
|
||||||
|
o.keys(H.s.selectors).forEach((sel) => {
|
||||||
|
myStyle += sel + vis;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
stylesNode[shortcuts.ac](d[shortcuts.ct](myStyle));
|
||||||
|
d.head[shortcuts.ac](stylesNode);
|
||||||
|
} else if (stylesNode) {
|
||||||
|
stylesNode.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tester = (() => {
|
||||||
|
let fakeBody = null;
|
||||||
|
return {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append fakeBody with tests to document
|
||||||
|
* @returns {Object|null} The body element or null, if no tests
|
||||||
|
*/
|
||||||
|
"ap": () => {
|
||||||
|
if (fakeBody) {
|
||||||
|
d.documentElement[shortcuts.ac](fakeBody);
|
||||||
|
return fakeBody;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove fakeBody
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
"cl": () => {
|
||||||
|
if (fakeBody) {
|
||||||
|
fakeBody.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and append div with CSS-hyphenated word
|
||||||
|
* @param {string} lang Language
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
"cr": (lang) => {
|
||||||
|
if (H.cf.langs.has(lang)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fakeBody = fakeBody || d[shortcuts.ce]("body");
|
||||||
|
const testDiv = d[shortcuts.ce]("div");
|
||||||
|
const ha = "hyphens:auto";
|
||||||
|
testDiv.lang = lang;
|
||||||
|
testDiv.style.cssText = `visibility:hidden;-webkit-${ha};-ms-${ha};${ha};width:48px;font-size:12px;line-height:12px;border:none;padding:0;word-wrap:normal`;
|
||||||
|
testDiv[shortcuts.ac](
|
||||||
|
d[shortcuts.ct](H.lrq.get(lang).wo.toLowerCase())
|
||||||
|
);
|
||||||
|
fakeBody[shortcuts.ac](testDiv);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if hyphens (ev.prefixed) is set to auto for the element.
|
||||||
|
* @param {Object} elm - the element
|
||||||
|
* @returns {Boolean} result of the check
|
||||||
|
*/
|
||||||
|
const checkCSSHyphensSupport = (elmStyle) => {
|
||||||
|
const h = elmStyle.hyphens ||
|
||||||
|
elmStyle.webkitHyphens ||
|
||||||
|
elmStyle.msHyphens;
|
||||||
|
return (h === "auto");
|
||||||
|
};
|
||||||
|
|
||||||
|
H.res = {
|
||||||
|
"he": mp()
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load hyphenEngines to H.res.he
|
||||||
|
*
|
||||||
|
* Make sure each .wasm is loaded exactly once, even for fallbacks
|
||||||
|
* Store a list of languages to by hyphenated with each .wasm
|
||||||
|
* @param {string} lang The language
|
||||||
|
* @returns {undefined}
|
||||||
|
*/
|
||||||
|
const loadhyphenEngine = (lang) => {
|
||||||
|
const fn = H.lrq.get(lang).fn;
|
||||||
|
H.cf.pf = true;
|
||||||
|
H.cf.langs.set(lang, "H9Y");
|
||||||
|
if (H.res.he.has(fn)) {
|
||||||
|
H.res.he.get(fn).l.push(lang);
|
||||||
|
} else {
|
||||||
|
H.res.he.set(
|
||||||
|
fn,
|
||||||
|
{
|
||||||
|
"l": [lang],
|
||||||
|
"w": w.fetch(H.paths.patterndir + fn + ".wasm", fetchOptions)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
H.lrq.forEach((value, lang) => {
|
||||||
|
if (value.wo === "FORCEHYPHENOPOLY" || H.cf.langs.get(lang) === "H9Y") {
|
||||||
|
loadhyphenEngine(lang);
|
||||||
|
} else {
|
||||||
|
tester.cr(lang);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const testContainer = tester.ap();
|
||||||
|
if (testContainer) {
|
||||||
|
testContainer.querySelectorAll("div").forEach((n) => {
|
||||||
|
if (checkCSSHyphensSupport(n.style) && n.offsetHeight > 12) {
|
||||||
|
H.cf.langs.set(n.lang, "CSS");
|
||||||
|
} else {
|
||||||
|
loadhyphenEngine(n.lang);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tester.cl();
|
||||||
|
}
|
||||||
|
const hev = H.hev;
|
||||||
|
if (H.cf.pf) {
|
||||||
|
H.res.DOM = new Promise((res) => {
|
||||||
|
if (d.readyState === "loading") {
|
||||||
|
d.addEventListener(
|
||||||
|
"DOMContentLoaded",
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
"once": true,
|
||||||
|
"passive": true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
H.hide(1, H.s.hide);
|
||||||
|
H.timeOutHandler = w.setTimeout(() => {
|
||||||
|
H.hide(0, null);
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
if (H.s.timeout & 1) {
|
||||||
|
H.ac.abort();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.info(scriptName + " timed out.");
|
||||||
|
}, H.s.timeout);
|
||||||
|
if (mainScriptLoaded) {
|
||||||
|
H.main();
|
||||||
|
} else {
|
||||||
|
// Load main script
|
||||||
|
fetch(H.paths.maindir + "Hyphenopoly.js", fetchOptions).
|
||||||
|
then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
response.blob().then((blb) => {
|
||||||
|
const script = d[shortcuts.ce]("script");
|
||||||
|
script.src = URL.createObjectURL(blb);
|
||||||
|
d.head[shortcuts.ac](script);
|
||||||
|
mainScriptLoaded = true;
|
||||||
|
URL.revokeObjectURL(script.src);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
H.hy6ors = mp();
|
||||||
|
H.cf.langs.forEach((langDef, lang) => {
|
||||||
|
if (langDef === "H9Y") {
|
||||||
|
H.hy6ors.set(lang, defProm());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
H.hy6ors.set("HTML", defProm());
|
||||||
|
H.hyphenators = new Proxy(H.hy6ors, {
|
||||||
|
"get": (target, key) => {
|
||||||
|
return target.get(key);
|
||||||
|
},
|
||||||
|
"set": () => {
|
||||||
|
// Inhibit setting of hyphenators
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(() => {
|
||||||
|
if (hev && hev.polyfill) {
|
||||||
|
hev.polyfill();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
(() => {
|
||||||
|
if (hev && hev.tearDown) {
|
||||||
|
hev.tearDown();
|
||||||
|
}
|
||||||
|
w.Hyphenopoly = null;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
(() => {
|
||||||
|
if (H.cft) {
|
||||||
|
store.setItem(scriptName, JSON.stringify(
|
||||||
|
{
|
||||||
|
"langs": [...H.cf.langs.entries()],
|
||||||
|
"pf": H.cf.pf
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
H.config = (c) => {
|
||||||
|
/**
|
||||||
|
* Sets default properties for an Object
|
||||||
|
* @param {object} obj - The object to set defaults to
|
||||||
|
* @param {object} defaults - The defaults to set
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
const setDefaults = (obj, defaults) => {
|
||||||
|
if (obj) {
|
||||||
|
o.entries(defaults).forEach(([k, v]) => {
|
||||||
|
// eslint-disable-next-line security/detect-object-injection
|
||||||
|
obj[k] = obj[k] || v;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
H.cft = Boolean(c.cacheFeatureTests);
|
||||||
|
if (H.cft && store.getItem(scriptName)) {
|
||||||
|
H.cf = JSON.parse(store.getItem(scriptName));
|
||||||
|
H.cf.langs = mp(H.cf.langs);
|
||||||
|
} else {
|
||||||
|
H.cf = {
|
||||||
|
"langs": mp(),
|
||||||
|
"pf": false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maindir = thisScript.slice(0, (thisScript.lastIndexOf("/") + 1));
|
||||||
|
const patterndir = maindir + "patterns/";
|
||||||
|
H.paths = setDefaults(c.paths, {
|
||||||
|
maindir,
|
||||||
|
patterndir
|
||||||
|
});
|
||||||
|
H.s = setDefaults(c.setup, {
|
||||||
|
"CORScredentials": "include",
|
||||||
|
"hide": "all",
|
||||||
|
"selectors": {".hyphenate": {}},
|
||||||
|
"timeout": 1000
|
||||||
|
});
|
||||||
|
// Change mode string to mode int
|
||||||
|
H.s.hide = ["all", "element", "text"].indexOf(H.s.hide);
|
||||||
|
if (c.handleEvent) {
|
||||||
|
H.hev = c.handleEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbacks = mp(o.entries(c.fallbacks || {}));
|
||||||
|
H.lrq = mp();
|
||||||
|
o.entries(c.require).forEach(([lang, wo]) => {
|
||||||
|
H.lrq.set(lang.toLowerCase(), {
|
||||||
|
"fn": fallbacks.get(lang) || lang,
|
||||||
|
wo
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
main();
|
||||||
|
};
|
||||||
|
})(window, document, Hyphenopoly, Object);
|
||||||
+90
-14
@@ -33,29 +33,79 @@ class AIFiction {
|
|||||||
this.typingSpeed = 30; // Default value, will be adjusted by slider
|
this.typingSpeed = 30; // Default value, will be adjusted by slider
|
||||||
this.typingTimeout = null;
|
this.typingTimeout = null;
|
||||||
|
|
||||||
|
// Check for kokoro-js being loaded (Now handled by factory)
|
||||||
|
// this.checkForKokoroJs(); // No longer needed here
|
||||||
|
|
||||||
// Bind event handlers
|
// Bind event handlers
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
// Initialize socket communication
|
// Initialize socket communication
|
||||||
this.initializeSocket();
|
this.initializeSocket();
|
||||||
|
|
||||||
// Initialize UI
|
// Initialize UI (TTS part will be updated by event)
|
||||||
this.initializeUI();
|
this.initializeUI();
|
||||||
|
|
||||||
|
// Listen for TTS readiness
|
||||||
|
this.listenForTTSReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the UI
|
* Check if kokoro-js is loaded
|
||||||
|
*/
|
||||||
|
checkForKokoroJs() {
|
||||||
|
try {
|
||||||
|
// With our TTS factory in place, we don't need to manually check for kokoro
|
||||||
|
// as the factory will handle loading and fallback automatically
|
||||||
|
console.log("TTS Factory will handle initialization of speech systems");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Error checking for TTS systems:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the UI (Initial state, TTS updated later)
|
||||||
*/
|
*/
|
||||||
initializeUI() {
|
initializeUI() {
|
||||||
this.updateTypingSpeed();
|
this.updateTypingSpeed();
|
||||||
this.updateSpeechButton();
|
// Start with speech button disabled, will be enabled by tts-ready event
|
||||||
|
this.speechButton.setAttribute('disabled', 'disabled');
|
||||||
|
this.speechButton.setAttribute('title', 'Initializing Text-to-Speech...');
|
||||||
|
this.updateSpeechButton(false);
|
||||||
|
|
||||||
// Disable buttons initially
|
// Disable other buttons initially
|
||||||
this.rewindButton.setAttribute('disabled', 'disabled');
|
this.rewindButton.setAttribute('disabled', 'disabled');
|
||||||
this.loadButton.setAttribute('disabled', 'disabled');
|
this.loadButton.setAttribute('disabled', 'disabled');
|
||||||
|
|
||||||
// Start the game
|
// Start the game (if socket is ready)
|
||||||
this.startGame();
|
if (this.socket && this.socket.connected) {
|
||||||
|
this.startGame();
|
||||||
|
} else {
|
||||||
|
console.log("Waiting for socket connection to start game...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for the tts-ready event from the factory
|
||||||
|
*/
|
||||||
|
listenForTTSReady() {
|
||||||
|
window.addEventListener('tts-ready', (event) => {
|
||||||
|
console.log('Received tts-ready event:', event.detail);
|
||||||
|
const { available, type, handler } = event.detail;
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
console.log(`TTS System active: ${type}`);
|
||||||
|
this.speechButton.removeAttribute('disabled');
|
||||||
|
const ttsName = type === 'kokoro' ? 'Kokoro TTS' : 'Browser TTS';
|
||||||
|
this.speechButton.setAttribute('title', `Text-to-Speech (${ttsName})`);
|
||||||
|
// Ensure the button style reflects the initial state (off)
|
||||||
|
this.updateSpeechButton(window.ttsHandler ? window.ttsHandler.isEnabled() : false);
|
||||||
|
} else {
|
||||||
|
console.warn("No TTS system available after initialization.");
|
||||||
|
this.speechButton.setAttribute('disabled', 'disabled');
|
||||||
|
this.speechButton.setAttribute('title', 'Text-to-Speech not available');
|
||||||
|
this.updateSpeechButton(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,19 +124,40 @@ class AIFiction {
|
|||||||
|
|
||||||
// Toggle speech
|
// Toggle speech
|
||||||
this.speechButton.addEventListener('click', () => {
|
this.speechButton.addEventListener('click', () => {
|
||||||
if (ttsHandler && typeof ttsHandler.isEnabled === 'function') {
|
// Check if the handler is available (it should be if button is enabled)
|
||||||
const enabled = ttsHandler.toggle();
|
if (window.ttsHandler) {
|
||||||
|
// Ensure AudioContext is resumed on user interaction if using Kokoro
|
||||||
|
if (window.ttsFactory && window.ttsFactory.usingKokoro && window.ttsHandler.audioContext && window.ttsHandler.audioContext.state === 'suspended') {
|
||||||
|
window.ttsHandler.audioContext.resume().catch(err => console.error('Error resuming AudioContext on click:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user activation flag for the handler
|
||||||
|
window.ttsHandler.hasUserActivation = true;
|
||||||
|
|
||||||
|
const enabled = window.ttsHandler.toggle();
|
||||||
this.updateSpeechButton(enabled);
|
this.updateSpeechButton(enabled);
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// Speak the last narrative if speech was just enabled
|
// Speak the last narrative if speech was just enabled
|
||||||
const lastNarrative = this.storyContainer.lastElementChild;
|
const lastNarrative = this.storyContainer.lastElementChild;
|
||||||
if (lastNarrative && lastNarrative.classList.contains('narrative')) {
|
if (lastNarrative && lastNarrative.classList.contains('narrative')) {
|
||||||
ttsHandler.speak(lastNarrative.textContent);
|
console.log("Speaking last narrative on toggle");
|
||||||
|
// Use a slight delay to ensure audio context is resumed
|
||||||
|
setTimeout(() => window.ttsHandler.speak(lastNarrative.textContent), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the tooltip with active TTS system info
|
||||||
|
if (window.ttsFactory) {
|
||||||
|
const ttsInfo = window.ttsFactory.getActiveTTSInfo();
|
||||||
|
this.speechButton.setAttribute('title', `Text-to-Speech (${ttsInfo.name})`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If disabling, ensure speech stops
|
||||||
|
window.ttsHandler.stop();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('TTS not ready yet');
|
console.log('TTS handler not available when speech button clicked.');
|
||||||
|
// Optionally show an alert or keep button disabled
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,8 +352,10 @@ class AIFiction {
|
|||||||
element.className = 'narrative hide';
|
element.className = 'narrative hide';
|
||||||
this.storyContainer.appendChild(element);
|
this.storyContainer.appendChild(element);
|
||||||
|
|
||||||
// Apply SmartyPants transformations for better typography
|
// Apply SmartyPants transformations for better typography if available
|
||||||
const processedText = SmartyPants.smartypantsu ? SmartyPants.smartypantsu(text, 1) : text;
|
const processedText = window.SmartyPants && typeof window.SmartyPants.smartypantsu === 'function'
|
||||||
|
? window.SmartyPants.smartypantsu(text, 1)
|
||||||
|
: text;
|
||||||
|
|
||||||
// Clear any existing typing timeouts
|
// Clear any existing typing timeouts
|
||||||
if (this.typingTimeout) {
|
if (this.typingTimeout) {
|
||||||
@@ -293,8 +366,9 @@ class AIFiction {
|
|||||||
this.typeText(element, processedText, 0);
|
this.typeText(element, processedText, 0);
|
||||||
|
|
||||||
// Read text aloud if speech is enabled
|
// Read text aloud if speech is enabled
|
||||||
if (ttsHandler && ttsHandler.isEnabled()) {
|
if (window.ttsHandler && window.ttsHandler.isEnabled()) {
|
||||||
ttsHandler.speak(text);
|
console.log("Speaking narrative text with TTS");
|
||||||
|
window.ttsHandler.speak(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,9 +485,11 @@ class AIFiction {
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
this.speechButton.style.fontWeight = 'bold';
|
this.speechButton.style.fontWeight = 'bold';
|
||||||
this.speechButton.style.color = '#000';
|
this.speechButton.style.color = '#000';
|
||||||
|
this.speechButton.style.backgroundColor = '#eee';
|
||||||
} else {
|
} else {
|
||||||
this.speechButton.style.fontWeight = 'normal';
|
this.speechButton.style.fontWeight = 'normal';
|
||||||
this.speechButton.style.color = '#333';
|
this.speechButton.style.color = '#333';
|
||||||
|
this.speechButton.style.backgroundColor = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
function kap(text, measureText, measure, hyphenation) {
|
||||||
|
console.log("Typesetting hyphenated text:", text, measure);
|
||||||
|
if (!hyphenation) {
|
||||||
|
text = text.replace(/\|/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
let hyphenWidth = measureText('-');
|
||||||
|
let spaceWidth = measureText('\u00A0');
|
||||||
|
let nodes = [];
|
||||||
|
|
||||||
|
text.split(/([.,:;!?] |\s|\||<.*?>)/u).forEach(function (fragment) {
|
||||||
|
let fragmentWidth = measureText(fragment);
|
||||||
|
|
||||||
|
if (fragment === ' ') {
|
||||||
|
let stretch = (spaceWidth * 3) / 6;
|
||||||
|
let shrink = (spaceWidth * 3) / 9;
|
||||||
|
|
||||||
|
nodes.push(linebreak.glue(spaceWidth, stretch, shrink));
|
||||||
|
} else if (fragment === '|') {
|
||||||
|
// nodes.push(linebreak.penalty(hyphenWidth, 100, 1));
|
||||||
|
nodes.push(linebreak.penalty(hyphenWidth * 0.25, 100, 1));
|
||||||
|
} else if (fragment.match(/(<.*?>)/u)) {
|
||||||
|
nodes.push(linebreak.tag(fragmentWidth, fragment));
|
||||||
|
} else if (fragment.match(/[.,:;!?] /u)) {
|
||||||
|
let punctuation = fragment.match(/([.,:;!?])( )/u);
|
||||||
|
let punctuationSymbolWidth = measureText(punctuation[1]) * 0.25;
|
||||||
|
let punctuationWidth = measureText(punctuation[1]) * 0.75 + spaceWidth;
|
||||||
|
nodes.push(linebreak.box(punctuationSymbolWidth, punctuation[1]));
|
||||||
|
let stretch = (punctuationWidth * 3) / 6;
|
||||||
|
let shrink = (punctuationWidth * 3) / 9;
|
||||||
|
|
||||||
|
nodes.push(linebreak.glue(punctuationWidth, stretch, shrink));
|
||||||
|
} else if (fragment.match(/(\s+)/u)) {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
nodes.push(linebreak.box(fragmentWidth, fragment));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.push(linebreak.glue(0, linebreak.infinity, 0));
|
||||||
|
nodes.push(linebreak.penalty(0, -linebreak.infinity, 1));
|
||||||
|
|
||||||
|
let demerits = {
|
||||||
|
line: 10,
|
||||||
|
flagged: 100,
|
||||||
|
fitness: 3000
|
||||||
|
};
|
||||||
|
|
||||||
|
let breaks = linebreak(nodes, measure, { tolerance: 3, demerits });
|
||||||
|
|
||||||
|
if (!breaks.length) {
|
||||||
|
breaks = linebreak(nodes, measure, { tolerance: 10, demerits });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, breaks };
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
/**
|
||||||
|
* Kokoro Text-to-Speech Handler for AI Interactive Fiction
|
||||||
|
* Uses the kokoro-js library for high-quality TTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
class KokoroHandler {
|
||||||
|
constructor() {
|
||||||
|
this.enabled = false;
|
||||||
|
this.speaking = false;
|
||||||
|
this.paused = false;
|
||||||
|
this.audio = null;
|
||||||
|
this.currentSpeed = 1.0; // Note: KokoroTTS might not support speed changes directly
|
||||||
|
this.audioQueue = [];
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
this.kokoroReady = false;
|
||||||
|
this.kokoroInstance = null; // Store the KokoroTTS instance
|
||||||
|
this.hasUserActivation = false;
|
||||||
|
this.initializationPromise = null;
|
||||||
|
this.audioContext = null; // For playing the generated audio
|
||||||
|
this.currentVoice = "af_heart"; // Default voice from README
|
||||||
|
this.currentAudioSource = null; // To keep track of the playing audio source
|
||||||
|
|
||||||
|
// Start initialization process
|
||||||
|
this.initializeKokoro();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Kokoro TTS by waiting for the class and then instantiating
|
||||||
|
*/
|
||||||
|
async initializeKokoro() {
|
||||||
|
if (this.initializationPromise) {
|
||||||
|
return this.initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initializationPromise = new Promise(async (resolve) => {
|
||||||
|
try {
|
||||||
|
// Wait for the KokoroTTS class to be loaded
|
||||||
|
if (typeof window.KokoroTTS === 'undefined') {
|
||||||
|
console.log('Kokoro TTS class not found, waiting for it to load...');
|
||||||
|
|
||||||
|
let loadTimeoutId = null; // Variable to hold the timeout ID
|
||||||
|
|
||||||
|
const loadHandler = async () => {
|
||||||
|
clearTimeout(loadTimeoutId); // <<< Clear the timeout
|
||||||
|
window.removeEventListener('kokoro-class-loaded', loadHandler);
|
||||||
|
window.removeEventListener('kokoro-class-load-failed', failHandler);
|
||||||
|
console.log('KokoroTTS class loaded event received.');
|
||||||
|
const success = await this._initKokoroInstance();
|
||||||
|
resolve(success);
|
||||||
|
};
|
||||||
|
|
||||||
|
const failHandler = () => {
|
||||||
|
clearTimeout(loadTimeoutId); // <<< Clear the timeout
|
||||||
|
window.removeEventListener('kokoro-class-loaded', loadHandler);
|
||||||
|
window.removeEventListener('kokoro-class-load-failed', failHandler);
|
||||||
|
console.error('KokoroTTS class failed to load.');
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('kokoro-class-loaded', loadHandler);
|
||||||
|
window.addEventListener('kokoro-class-load-failed', failHandler);
|
||||||
|
|
||||||
|
// Timeout if the event never fires
|
||||||
|
loadTimeoutId = setTimeout(() => { // <<< Store the timeout ID
|
||||||
|
// Check if still waiting (listener might have run but instance not ready yet)
|
||||||
|
if (!this.kokoroInstance && !this.kokoroReady) {
|
||||||
|
window.removeEventListener('kokoro-class-loaded', loadHandler);
|
||||||
|
window.removeEventListener('kokoro-class-load-failed', failHandler);
|
||||||
|
console.error('Timed out waiting for KokoroTTS class load event.');
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 15000); // Increased timeout
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, KokoroTTS class is already available
|
||||||
|
console.log('KokoroTTS class found directly.');
|
||||||
|
const success = await this._initKokoroInstance();
|
||||||
|
resolve(success);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during KokoroHandler initialization:', error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to create and initialize the KokoroTTS instance
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _initKokoroInstance() {
|
||||||
|
if (this.kokoroInstance || this.kokoroReady) return true; // Already initialized or initializing
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Initializing KokoroTTS instance (GPU Only Attempt)...');
|
||||||
|
const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX";
|
||||||
|
|
||||||
|
// --- Check for WebGPU Support ---
|
||||||
|
const device = await this.getBestDevice();
|
||||||
|
if (device !== 'webgpu') {
|
||||||
|
console.warn('WebGPU not available or supported. Kokoro TTS (GPU) cannot be initialized.');
|
||||||
|
// Explicitly set ready to false and return false to signal failure
|
||||||
|
this.kokoroReady = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// --- End WebGPU Check ---
|
||||||
|
|
||||||
|
// Use fp32 for WebGPU as recommended
|
||||||
|
const dtype = 'fp32';
|
||||||
|
console.log(`Attempting KokoroTTS init with device: ${device}, dtype: ${dtype}`);
|
||||||
|
|
||||||
|
console.log(`Calling KokoroTTS.from_pretrained('${model_id}', { dtype: '${dtype}', device: '${device}' })...`);
|
||||||
|
|
||||||
|
// --- Add Timeout Wrapper for from_pretrained ---
|
||||||
|
const fromPretrainedPromise = window.KokoroTTS.from_pretrained(model_id, {
|
||||||
|
dtype: dtype,
|
||||||
|
device: device, // Always 'webgpu' if we reach here
|
||||||
|
});
|
||||||
|
|
||||||
|
const pretrainedTimeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('KokoroTTS.from_pretrained (WebGPU) timed out after 55 seconds')), 55000) // 55 seconds timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.kokoroInstance = await Promise.race([
|
||||||
|
fromPretrainedPromise,
|
||||||
|
pretrainedTimeoutPromise
|
||||||
|
]);
|
||||||
|
} catch (timeoutError) {
|
||||||
|
console.error(timeoutError.message); // Log the specific timeout error
|
||||||
|
throw timeoutError; // Re-throw to be caught by the outer catch block
|
||||||
|
}
|
||||||
|
// --- End Timeout Wrapper ---
|
||||||
|
|
||||||
|
console.log('KokoroTTS.from_pretrained call completed.');
|
||||||
|
|
||||||
|
if (!this.kokoroInstance) {
|
||||||
|
console.error('KokoroTTS.from_pretrained returned a falsy value.');
|
||||||
|
throw new Error('KokoroTTS.from_pretrained returned null or undefined.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer AudioContext creation until first use
|
||||||
|
|
||||||
|
this.kokoroReady = true;
|
||||||
|
console.log('Kokoro TTS (WebGPU) instance created successfully (AudioContext deferred).');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during KokoroTTS (WebGPU) initialization:', error);
|
||||||
|
if (error.message) {
|
||||||
|
console.error('Error message:', error.message);
|
||||||
|
}
|
||||||
|
if (error.stack) {
|
||||||
|
console.error('Error stack:', error.stack);
|
||||||
|
}
|
||||||
|
this.kokoroInstance = null;
|
||||||
|
this.kokoroReady = false;
|
||||||
|
return false; // Ensure failure is explicitly returned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the best device (webgpu or wasm)
|
||||||
|
* Checks for WebGPU support.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async getBestDevice() {
|
||||||
|
if (navigator.gpu) {
|
||||||
|
try {
|
||||||
|
// Request an adapter. If this succeeds, WebGPU is likely available.
|
||||||
|
const adapter = await navigator.gpu.requestAdapter();
|
||||||
|
if (adapter) {
|
||||||
|
console.log('WebGPU supported, selecting webgpu device.');
|
||||||
|
return 'webgpu';
|
||||||
|
}
|
||||||
|
console.warn('WebGPU adapter request returned null.');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('WebGPU adapter request failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('WebGPU not supported or available, cannot use GPU for Kokoro.');
|
||||||
|
return 'wasm'; // Return wasm indicating GPU is not the best/available option
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available voices (delegates to KokoroTTS instance)
|
||||||
|
*/
|
||||||
|
async listVoices() {
|
||||||
|
if (!this.kokoroReady || !this.kokoroInstance) {
|
||||||
|
console.warn('Kokoro not ready, cannot list voices.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The README uses tts.list_voices(), assuming it's a method on the instance
|
||||||
|
if (typeof this.kokoroInstance.list_voices === 'function') {
|
||||||
|
return await this.kokoroInstance.list_voices();
|
||||||
|
} else {
|
||||||
|
console.warn('list_voices method not found on KokoroTTS instance. Returning default.');
|
||||||
|
// Fallback based on README examples
|
||||||
|
return [{ name: 'af_heart', description: 'Default American Female' }];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing Kokoro voices:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the voice to use
|
||||||
|
* @param {string} voiceName - Name of the voice (e.g., 'af_heart')
|
||||||
|
*/
|
||||||
|
setVoice(voiceName) {
|
||||||
|
this.currentVoice = voiceName;
|
||||||
|
console.log(`Kokoro voice set to: ${voiceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle TTS functionality on/off
|
||||||
|
* @returns {boolean} New state of TTS (enabled/disabled)
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
// Set user activation flag when toggle is called
|
||||||
|
this.hasUserActivation = true;
|
||||||
|
|
||||||
|
// --- Create AudioContext on first activation ---
|
||||||
|
if (!this.audioContext) {
|
||||||
|
try {
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
console.log('AudioContext created on user activation.');
|
||||||
|
// Resume if context starts suspended
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume().catch(err => console.error('Error resuming initial AudioContext:', err));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create AudioContext:', e);
|
||||||
|
// If AudioContext fails, Kokoro cannot play audio
|
||||||
|
this.kokoroReady = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- End AudioContext Creation ---
|
||||||
|
|
||||||
|
if (!this.kokoroReady) {
|
||||||
|
console.warn('Kokoro TTS not ready yet');
|
||||||
|
// Optionally, trigger re-initialization or inform user
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enabled = !this.enabled;
|
||||||
|
console.log("Kokoro TTS toggled:", this.enabled ? "ON" : "OFF");
|
||||||
|
|
||||||
|
// Stop any ongoing speech when disabling
|
||||||
|
if (!this.enabled && (this.speaking || this.isProcessingQueue)) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the speech rate/speed
|
||||||
|
* @param {number} speed - Speed multiplier (0.1 to 2.0)
|
||||||
|
*/
|
||||||
|
setSpeed(speed) {
|
||||||
|
this.currentSpeed = Math.max(0.5, Math.min(2.0, speed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process text for better speech synthesis
|
||||||
|
* @param {string} text - Text to process
|
||||||
|
* @returns {string} - Processed text
|
||||||
|
*/
|
||||||
|
processTextForSpeech(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
|
||||||
|
// Remove markdown/formatting that would sound strange when read
|
||||||
|
text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Bold
|
||||||
|
text = text.replace(/\*([^*]+)\*/g, '$1'); // Italic
|
||||||
|
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // Links
|
||||||
|
|
||||||
|
// Clean up any HTML tags
|
||||||
|
text = text.replace(/<[^>]+>/g, '');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split text into digestible chunks for better TTS handling
|
||||||
|
* @param {string} text - Text to split
|
||||||
|
* @returns {string[]} - Array of text chunks
|
||||||
|
*/
|
||||||
|
splitTextIntoChunks(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
// Split by sentence terminators, keeping the terminator with the chunk
|
||||||
|
const sentenceRegex = /[^.!?]+[.!?]+/g;
|
||||||
|
const sentences = text.match(sentenceRegex) || [text];
|
||||||
|
|
||||||
|
// Group sentences into chunks for better performance
|
||||||
|
const chunks = [];
|
||||||
|
let currentChunk = '';
|
||||||
|
|
||||||
|
for (const sentence of sentences) {
|
||||||
|
// If adding this sentence would make the chunk too long, start a new chunk
|
||||||
|
if (currentChunk.length + sentence.length > 500) {
|
||||||
|
if (currentChunk) chunks.push(currentChunk);
|
||||||
|
currentChunk = sentence;
|
||||||
|
} else {
|
||||||
|
currentChunk += sentence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the last chunk if it's not empty
|
||||||
|
if (currentChunk) chunks.push(currentChunk);
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the speech queue using KokoroTTS
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async processQueue() {
|
||||||
|
// Ensure AudioContext is ready before processing
|
||||||
|
if (!this.audioContext) {
|
||||||
|
console.warn('AudioContext not available, cannot process Kokoro queue.');
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
this.speaking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ensure AudioContext is running
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
await this.audioContext.resume().catch(err => console.error('Error resuming AudioContext for queue:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isProcessingQueue || this.audioQueue.length === 0 || !this.kokoroReady || !this.kokoroInstance) {
|
||||||
|
if (this.audioQueue.length === 0) {
|
||||||
|
this.speaking = false; // Ensure speaking flag is reset when queue is empty
|
||||||
|
}
|
||||||
|
// Reset processing flag if we exit early
|
||||||
|
if (this.isProcessingQueue && this.audioQueue.length === 0) {
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isProcessingQueue = true;
|
||||||
|
this.speaking = true; // Set speaking true when processing starts
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textChunk = this.audioQueue.shift();
|
||||||
|
|
||||||
|
if (!textChunk) {
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
this.speaking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Kokoro generating chunk (${this.audioQueue.length} remaining):`, textChunk.substring(0, 30) + "...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use Kokoro instance to generate audio
|
||||||
|
const audioResult = await this.kokoroInstance.generate(textChunk, {
|
||||||
|
voice: this.currentVoice,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Updated Check: Expect Float32Array ---
|
||||||
|
if (!audioResult || !audioResult.audio || !(audioResult.audio instanceof Float32Array) || !audioResult.sampling_rate) {
|
||||||
|
console.error('Invalid audio data or sampling rate received from KokoroTTS.generate', audioResult);
|
||||||
|
throw new Error('Invalid audio data or sampling rate received from KokoroTTS.generate');
|
||||||
|
}
|
||||||
|
// --- End Updated Check ---
|
||||||
|
|
||||||
|
const rawAudioSamples = audioResult.audio;
|
||||||
|
const samplingRate = audioResult.sampling_rate;
|
||||||
|
console.log(`Received raw audio samples (${rawAudioSamples.length}), sample rate: ${samplingRate}`);
|
||||||
|
|
||||||
|
// Decode and play the raw audio samples
|
||||||
|
await this.playRawAudio(rawAudioSamples, samplingRate);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating or playing Kokoro speech:", error);
|
||||||
|
} finally {
|
||||||
|
// Always continue processing the queue
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
// Check if queue is now empty to reset speaking flag
|
||||||
|
if (this.audioQueue.length === 0) {
|
||||||
|
this.speaking = false;
|
||||||
|
console.log("Kokoro queue finished.");
|
||||||
|
}
|
||||||
|
// Use setTimeout to avoid potential stack overflow on rapid processing
|
||||||
|
setTimeout(() => this.processQueue(), 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in Kokoro processQueue:", error);
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
this.speaking = false; // Reset speaking flag on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play raw Float32Array audio samples using Web Audio API
|
||||||
|
* @param {Float32Array} samples - The raw audio samples
|
||||||
|
* @param {number} sampleRate - The sample rate of the audio
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async playRawAudio(samples, sampleRate) {
|
||||||
|
if (!this.audioContext) {
|
||||||
|
console.error('AudioContext not initialized.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
await this.audioContext.resume().catch(err => console.error('Error resuming AudioContext for playback:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create an AudioBuffer
|
||||||
|
const audioBuffer = this.audioContext.createBuffer(
|
||||||
|
1, // Number of channels (assuming mono)
|
||||||
|
samples.length, // Length of the buffer
|
||||||
|
sampleRate // Sample rate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy the samples to the AudioBuffer
|
||||||
|
// NOTE: If audio is stereo, this needs adjustment
|
||||||
|
audioBuffer.copyToChannel(samples, 0);
|
||||||
|
|
||||||
|
// Create a source node
|
||||||
|
const source = this.audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
// Store the current source to allow stopping
|
||||||
|
this.currentAudioSource = source;
|
||||||
|
|
||||||
|
console.log(`Playing audio buffer (${(samples.length / sampleRate).toFixed(2)}s)`);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
source.onended = () => {
|
||||||
|
// Check if this source was the one we intended to stop
|
||||||
|
if (this.currentAudioSource === source) {
|
||||||
|
this.currentAudioSource = null;
|
||||||
|
}
|
||||||
|
console.log('Audio playback finished.');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
source.start(0); // Start playback immediately
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating or playing raw audio buffer:', error);
|
||||||
|
this.currentAudioSource = null; // Clear source on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak the provided text using KokoroTTS
|
||||||
|
* @param {string} text - Text to be spoken
|
||||||
|
* @param {function} onEndCallback - Callback when all speech ends
|
||||||
|
*/
|
||||||
|
speak(text, onEndCallback = null) {
|
||||||
|
if (!this.enabled || !text) {
|
||||||
|
if (onEndCallback) onEndCallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If kokoro is not ready yet, wait for initialization
|
||||||
|
if (!this.kokoroReady) {
|
||||||
|
console.warn("Kokoro TTS not ready yet, waiting for initialization...");
|
||||||
|
this.initializationPromise.then(success => {
|
||||||
|
if (success && this.enabled) {
|
||||||
|
this._speakInternal(text, onEndCallback);
|
||||||
|
} else {
|
||||||
|
console.error("Kokoro failed to initialize, cannot speak.");
|
||||||
|
if (onEndCallback) onEndCallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._speakInternal(text, onEndCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to handle speech after initialization checks
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_speakInternal(text, onEndCallback) {
|
||||||
|
// Ensure AudioContext is resumed after user interaction
|
||||||
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume().catch(err => console.error('Error resuming AudioContext:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't attempt to speak without user activation
|
||||||
|
if (!this.hasUserActivation) {
|
||||||
|
console.warn("Not attempting to speak because there hasn't been user interaction yet");
|
||||||
|
if (onEndCallback) onEndCallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processedText = this.processTextForSpeech(text);
|
||||||
|
console.log("Kokoro TTS attempting to speak:", processedText.substring(0, 50) + "...");
|
||||||
|
|
||||||
|
// Stop any existing speech
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
// Split into manageable chunks (consider if Kokoro handles long text well)
|
||||||
|
const chunks = this.splitTextIntoChunks(processedText);
|
||||||
|
this.audioQueue = chunks;
|
||||||
|
|
||||||
|
// Start processing the queue
|
||||||
|
if (this.audioQueue.length > 0 && !this.isProcessingQueue) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up a completion callback
|
||||||
|
if (onEndCallback) {
|
||||||
|
const checkCompletion = () => {
|
||||||
|
if (!this.isSpeaking()) { // Check if speaking is false
|
||||||
|
onEndCallback();
|
||||||
|
} else {
|
||||||
|
setTimeout(checkCompletion, 150); // Check again shortly
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Start checking slightly after processing begins
|
||||||
|
setTimeout(checkCompletion, 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in Kokoro speak:", error);
|
||||||
|
if (onEndCallback) onEndCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the current speech (Note: May not be perfectly resumable with AudioBufferSourceNode)
|
||||||
|
*/
|
||||||
|
pause() {
|
||||||
|
if (!this.speaking || !this.audioContext) return;
|
||||||
|
// Suspending AudioContext is a way to pause, but resuming might not be seamless
|
||||||
|
this.audioContext.suspend().catch(err => console.error('Error suspending AudioContext:', err));
|
||||||
|
this.paused = true;
|
||||||
|
console.log('Kokoro audio paused (via AudioContext suspend)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume paused speech
|
||||||
|
*/
|
||||||
|
resume() {
|
||||||
|
if (!this.paused || !this.audioContext) return;
|
||||||
|
this.audioContext.resume().catch(err => console.error('Error resuming AudioContext:', err));
|
||||||
|
this.paused = false;
|
||||||
|
console.log('Kokoro audio resumed (via AudioContext resume)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current speech
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
// Stop any currently playing audio source
|
||||||
|
if (this.currentAudioSource) {
|
||||||
|
try {
|
||||||
|
this.currentAudioSource.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors if source already stopped
|
||||||
|
}
|
||||||
|
this.currentAudioSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the queue and reset flags
|
||||||
|
this.audioQueue = [];
|
||||||
|
this.isProcessingQueue = false;
|
||||||
|
this.speaking = false;
|
||||||
|
this.paused = false;
|
||||||
|
console.log('Kokoro speech stopped and queue cleared.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if TTS is currently active/enabled
|
||||||
|
*/
|
||||||
|
isEnabled() {
|
||||||
|
return this.enabled && this.kokoroReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if speech is currently in progress
|
||||||
|
*/
|
||||||
|
isSpeaking() {
|
||||||
|
// Consider both the processing flag and if an audio source is active
|
||||||
|
return this.speaking || this.isProcessingQueue || !!this.currentAudioSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't create a global instance here - the factory will do this
|
||||||
|
// const ttsHandler = new KokoroHandler();
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,334 @@
|
|||||||
|
var linebreak = function (nodes, lines, settings = {
|
||||||
|
demerits: {
|
||||||
|
line: 10,
|
||||||
|
flagged: 100,
|
||||||
|
fitness: 3000
|
||||||
|
},
|
||||||
|
tolerance: 2
|
||||||
|
}) {
|
||||||
|
const options = settings;
|
||||||
|
activeNodes = new LinkedList(),
|
||||||
|
sum = {
|
||||||
|
width: 0,
|
||||||
|
stretch: 0,
|
||||||
|
shrink: 0
|
||||||
|
},
|
||||||
|
lineLengths = lines,
|
||||||
|
breaks = [],
|
||||||
|
tmp = {
|
||||||
|
data: {
|
||||||
|
demerits: Infinity
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function breakpoint(position, demerits, ratio, line, fitnessClass, totals, previous) {
|
||||||
|
return {
|
||||||
|
position: position,
|
||||||
|
demerits: demerits,
|
||||||
|
ratio: ratio,
|
||||||
|
line: line,
|
||||||
|
fitnessClass: fitnessClass,
|
||||||
|
totals: totals || {
|
||||||
|
width: 0,
|
||||||
|
stretch: 0,
|
||||||
|
shrink: 0
|
||||||
|
},
|
||||||
|
previous: previous
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCost(start, end, active, currentLine) {
|
||||||
|
var width = sum.width - active.totals.width,
|
||||||
|
stretch = 0,
|
||||||
|
shrink = 0,
|
||||||
|
// If the current line index is within the list of linelengths, use it, otherwise use
|
||||||
|
// the last line length of the list.
|
||||||
|
lineLength = currentLine < lineLengths.length ? lineLengths[currentLine - 1] : lineLengths[lineLengths.length - 1];
|
||||||
|
|
||||||
|
if (nodes[end].type === 'penalty') {
|
||||||
|
width += nodes[end].width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width < lineLength) {
|
||||||
|
// Calculate the stretch ratio
|
||||||
|
stretch = sum.stretch - active.totals.stretch;
|
||||||
|
|
||||||
|
if (stretch > 0) {
|
||||||
|
return (lineLength - width) / stretch;
|
||||||
|
} else {
|
||||||
|
return linebreak.infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (width > lineLength) {
|
||||||
|
// Calculate the shrink ratio
|
||||||
|
shrink = sum.shrink - active.totals.shrink;
|
||||||
|
|
||||||
|
if (shrink > 0) {
|
||||||
|
return (lineLength - width) / shrink;
|
||||||
|
} else {
|
||||||
|
return linebreak.infinity;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// perfect match
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Add width, stretch and shrink values from the current
|
||||||
|
// break point up to the next box or forced penalty.
|
||||||
|
function computeSum(breakPointIndex) {
|
||||||
|
var result = {
|
||||||
|
width: sum.width,
|
||||||
|
stretch: sum.stretch,
|
||||||
|
shrink: sum.shrink
|
||||||
|
},
|
||||||
|
i = 0;
|
||||||
|
|
||||||
|
for (i = breakPointIndex; i < nodes.length; i += 1) {
|
||||||
|
if (nodes[i].type === 'glue') {
|
||||||
|
result.width += nodes[i].width;
|
||||||
|
result.stretch += nodes[i].stretch;
|
||||||
|
result.shrink += nodes[i].shrink;
|
||||||
|
} else if (nodes[i].type === 'box' || (nodes[i].type === 'penalty' && nodes[i].penalty === -linebreak.infinity && i > breakPointIndex)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let graphNodes = [];
|
||||||
|
let graphEdges = [];
|
||||||
|
|
||||||
|
// The main loop of the algorithm
|
||||||
|
function mainLoop(node, index, nodes) {
|
||||||
|
var active = activeNodes.first,
|
||||||
|
next = null,
|
||||||
|
ratio = 0,
|
||||||
|
demerits = 0,
|
||||||
|
candidates = [],
|
||||||
|
badness,
|
||||||
|
currentLine = 0,
|
||||||
|
tmpSum,
|
||||||
|
currentClass = 0,
|
||||||
|
fitnessClass,
|
||||||
|
candidate,
|
||||||
|
newNode;
|
||||||
|
|
||||||
|
// The inner loop iterates through all the active nodes with line < currentLine and then
|
||||||
|
// breaks out to insert the new active node candidates before looking at the next active
|
||||||
|
// nodes for the next lines. The result of this is that the active node list is always
|
||||||
|
// sorted by line number.
|
||||||
|
while (active !== null) {
|
||||||
|
|
||||||
|
candidates = [{
|
||||||
|
demerits: Infinity
|
||||||
|
}, {
|
||||||
|
demerits: Infinity
|
||||||
|
}, {
|
||||||
|
demerits: Infinity
|
||||||
|
}, {
|
||||||
|
demerits: Infinity
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Iterate through the linked list of active nodes to find new potential active nodes
|
||||||
|
// and deactivate current active nodes.
|
||||||
|
while (active !== null) {
|
||||||
|
next = active.next;
|
||||||
|
currentLine = active.data.line + 1;
|
||||||
|
ratio = computeCost(active.data.position, index, active.data, currentLine);
|
||||||
|
|
||||||
|
// Deactive nodes when the distance between the current active node and the
|
||||||
|
// current node becomes too large (i.e. it exceeds the stretch limit and the stretch
|
||||||
|
// ratio becomes negative) or when the current node is a forced break (i.e. the end
|
||||||
|
// of the paragraph when we want to remove all active nodes, but possibly have a final
|
||||||
|
// candidate active node---if the paragraph can be set using the given tolerance value.)
|
||||||
|
if (ratio < -1 || (node.type === 'penalty' && node.penalty === -linebreak.infinity)) {
|
||||||
|
activeNodes.remove(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the ratio is within the valid range of -1 <= ratio <= tolerance calculate the
|
||||||
|
// total demerits and record a candidate active node.
|
||||||
|
if (-1 <= ratio && ratio <= options.tolerance) {
|
||||||
|
badness = 100 * Math.pow(Math.abs(ratio), 3);
|
||||||
|
|
||||||
|
// Positive penalty
|
||||||
|
if (node.type === 'penalty' && node.penalty >= 0) {
|
||||||
|
demerits = Math.pow(options.demerits.line + badness, 2) + Math.pow(node.penalty, 2);
|
||||||
|
// Negative penalty but not a forced break
|
||||||
|
} else if (node.type === 'penalty' && node.penalty !== -linebreak.infinity) {
|
||||||
|
demerits = Math.pow(options.demerits.line + badness, 2) - Math.pow(node.penalty, 2);
|
||||||
|
// All other cases
|
||||||
|
} else {
|
||||||
|
demerits = Math.pow(options.demerits.line + badness, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'penalty' && nodes[active.data.position].type === 'penalty') {
|
||||||
|
demerits += options.demerits.flagged * node.flagged * nodes[active.data.position].flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the fitness class for this candidate active node.
|
||||||
|
if (ratio < -0.5) {
|
||||||
|
currentClass = 0;
|
||||||
|
} else if (ratio <= 0.5) {
|
||||||
|
currentClass = 1;
|
||||||
|
} else if (ratio <= 1) {
|
||||||
|
currentClass = 2;
|
||||||
|
} else {
|
||||||
|
currentClass = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a fitness penalty to the demerits if the fitness classes of two adjacent lines
|
||||||
|
// differ too much.
|
||||||
|
if (Math.abs(currentClass - active.data.fitnessClass) > 1) {
|
||||||
|
demerits += options.demerits.fitness;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the total demerits of the active node to get the total demerits of this candidate node.
|
||||||
|
demerits += active.data.demerits;
|
||||||
|
|
||||||
|
// Only store the best candidate for each fitness class
|
||||||
|
if (demerits < candidates[currentClass].demerits) {
|
||||||
|
candidates[currentClass] = {
|
||||||
|
active: active,
|
||||||
|
demerits: demerits,
|
||||||
|
ratio: ratio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
active = next;
|
||||||
|
|
||||||
|
// Stop iterating through active nodes to insert new candidate active nodes in the active list
|
||||||
|
// before moving on to the active nodes for the next line.
|
||||||
|
// TODO: The Knuth and Plass paper suggests a conditional for currentLine < j0. This means paragraphs
|
||||||
|
// with identical line lengths will not be sorted by line number. Find out if that is a desirable outcome.
|
||||||
|
// For now I left this out, as it only adds minimal overhead to the algorithm and keeping the active node
|
||||||
|
// list sorted has a higher priority.
|
||||||
|
if (active !== null && active.data.line >= currentLine) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpSum = computeSum(index);
|
||||||
|
|
||||||
|
for (fitnessClass = 0; fitnessClass < candidates.length; fitnessClass += 1) {
|
||||||
|
candidate = candidates[fitnessClass];
|
||||||
|
|
||||||
|
if (candidate.demerits < Infinity) {
|
||||||
|
newNode = new Node(breakpoint(index, candidate.demerits, candidate.ratio,
|
||||||
|
candidate.active.data.line + 1, fitnessClass, tmpSum, candidate.active));
|
||||||
|
|
||||||
|
graphNodes.push({
|
||||||
|
id: index
|
||||||
|
});
|
||||||
|
|
||||||
|
graphEdges.push({
|
||||||
|
from: index,
|
||||||
|
to: candidate.active.data.position,
|
||||||
|
label: candidate.ratio.toFixed(2)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active !== null) {
|
||||||
|
activeNodes.insertBefore(active, newNode);
|
||||||
|
} else {
|
||||||
|
activeNodes.push(newNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an active node for the start of the paragraph.
|
||||||
|
activeNodes.push(new Node(breakpoint(0, 0, 0, 0, 0, undefined, null)));
|
||||||
|
|
||||||
|
graphNodes.push({
|
||||||
|
id: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(function (node, index, nodes) {
|
||||||
|
if (node.type === 'box') {
|
||||||
|
sum.width += node.width;
|
||||||
|
} else if (node.type === 'glue') {
|
||||||
|
if (index > 0 && nodes[index - 1].type === 'box') {
|
||||||
|
mainLoop(node, index, nodes);
|
||||||
|
}
|
||||||
|
sum.width += node.width;
|
||||||
|
sum.stretch += node.stretch;
|
||||||
|
sum.shrink += node.shrink;
|
||||||
|
} else if (node.type === 'penalty' && node.penalty !== linebreak.infinity) {
|
||||||
|
mainLoop(node, index, nodes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (activeNodes.size !== 0) {
|
||||||
|
// Find the best active node (the one with the least total demerits.)
|
||||||
|
activeNodes.forEach(function (node) {
|
||||||
|
if (node.data.demerits < tmp.data.demerits) {
|
||||||
|
tmp = node;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
graphNodes.forEach(function (n) {
|
||||||
|
let label = nodes[n.id].value;
|
||||||
|
|
||||||
|
if (nodes[n.id].type === 'glue') {
|
||||||
|
label = nodes[n.id - 1].value;
|
||||||
|
} else if (nodes[n.id].type === 'penalty') {
|
||||||
|
label = nodes[n.id - 1].value;
|
||||||
|
} else {
|
||||||
|
label = nodes[n.id].value;
|
||||||
|
}
|
||||||
|
n.label = label;
|
||||||
|
});
|
||||||
|
|
||||||
|
while (tmp !== null) {
|
||||||
|
breaks.push({
|
||||||
|
position: tmp.data.position,
|
||||||
|
ratio: tmp.data.ratio
|
||||||
|
});
|
||||||
|
tmp = tmp.data.previous;
|
||||||
|
}
|
||||||
|
return breaks.reverse();
|
||||||
|
} else {
|
||||||
|
console.warn('Overfull paragraph.');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
linebreak.infinity = 10000;
|
||||||
|
|
||||||
|
linebreak.glue = function (width, stretch, shrink) {
|
||||||
|
return {
|
||||||
|
type: 'glue',
|
||||||
|
width: width,
|
||||||
|
stretch: stretch,
|
||||||
|
shrink: shrink
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
linebreak.box = function (width, value) {
|
||||||
|
return {
|
||||||
|
type: 'box',
|
||||||
|
width: width,
|
||||||
|
value: value
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
linebreak.tag = function (width, value) {
|
||||||
|
return {
|
||||||
|
type: 'tag',
|
||||||
|
width: width,
|
||||||
|
value: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
linebreak.penalty = function (width, penalty, flagged) {
|
||||||
|
return {
|
||||||
|
type: 'penalty',
|
||||||
|
width: width,
|
||||||
|
penalty: penalty,
|
||||||
|
flagged: flagged
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
class LinkedList {
|
||||||
|
constructor() {
|
||||||
|
this.head = null;
|
||||||
|
this.tail = null;
|
||||||
|
this.listSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this.listSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLinked(node) {
|
||||||
|
return !((node && node.prev === null && node.next === null && this.tail !== node && this.head !== node) || this.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty() {
|
||||||
|
return this.listSize === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get first() {
|
||||||
|
return this.head;
|
||||||
|
}
|
||||||
|
|
||||||
|
get last() {
|
||||||
|
return this.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.toArray().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() {
|
||||||
|
var node = this.head,
|
||||||
|
result = [];
|
||||||
|
while (node !== null) {
|
||||||
|
result.push(node);
|
||||||
|
node = node.next;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that modifying the list during
|
||||||
|
// iteration is not safe.
|
||||||
|
forEach(fun) {
|
||||||
|
var node = this.head;
|
||||||
|
while (node !== null) {
|
||||||
|
fun(node);
|
||||||
|
node = node.next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(n) {
|
||||||
|
var node = this.head;
|
||||||
|
if (!this.isLinked(n)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
while (node !== null) {
|
||||||
|
if (node === n) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
node = node.next;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
at(i) {
|
||||||
|
var node = this.head, index = 0;
|
||||||
|
|
||||||
|
if (i >= this.listLength || i < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (node !== null) {
|
||||||
|
if (i === index) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node = node.next;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAfter(node, newNode) {
|
||||||
|
if (!this.isLinked(node)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
newNode.prev = node;
|
||||||
|
newNode.next = node.next;
|
||||||
|
if (node.next === null) {
|
||||||
|
this.tail = newNode;
|
||||||
|
} else {
|
||||||
|
node.next.prev = newNode;
|
||||||
|
}
|
||||||
|
node.next = newNode;
|
||||||
|
this.listSize += 1;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertBefore(node, newNode) {
|
||||||
|
if (!this.isLinked(node)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
newNode.prev = node.prev;
|
||||||
|
newNode.next = node;
|
||||||
|
if (node.prev === null) {
|
||||||
|
this.head = newNode;
|
||||||
|
} else {
|
||||||
|
node.prev.next = newNode;
|
||||||
|
}
|
||||||
|
node.prev = newNode;
|
||||||
|
this.listSize += 1;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
push(node) {
|
||||||
|
if (this.head === null) {
|
||||||
|
this.unshift(node);
|
||||||
|
} else {
|
||||||
|
this.insertAfter(this.tail, node);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
unshift(node) {
|
||||||
|
if (this.head === null) {
|
||||||
|
this.head = node;
|
||||||
|
this.tail = node;
|
||||||
|
node.prev = null;
|
||||||
|
node.next = null;
|
||||||
|
this.listSize += 1;
|
||||||
|
} else {
|
||||||
|
this.insertBefore(this.head, node);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(node) {
|
||||||
|
if (!this.isLinked(node)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (node.prev === null) {
|
||||||
|
this.head = node.next;
|
||||||
|
} else {
|
||||||
|
node.prev.next = node.next;
|
||||||
|
}
|
||||||
|
if (node.next === null) {
|
||||||
|
this.tail = node.prev;
|
||||||
|
} else {
|
||||||
|
node.next.prev = node.prev;
|
||||||
|
}
|
||||||
|
this.listSize -= 1;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
pop() {
|
||||||
|
var node = this.tail;
|
||||||
|
this.tail.prev.next = null;
|
||||||
|
this.tail = this.tail.prev;
|
||||||
|
this.listSize -= 1;
|
||||||
|
node.prev = null;
|
||||||
|
node.next = null;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
shift() {
|
||||||
|
var node = this.head;
|
||||||
|
this.head.next.prev = null;
|
||||||
|
this.head = this.head.next;
|
||||||
|
this.listSize -= 1;
|
||||||
|
node.prev = null;
|
||||||
|
node.next = null;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Node {
|
||||||
|
constructor(data) {
|
||||||
|
this.prev = null;
|
||||||
|
this.next = null;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.data.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+807
-59
@@ -1,66 +1,814 @@
|
|||||||
|
/* eslint-env browser, amd, node */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmartyPants - Smart typography for web content
|
*
|
||||||
* Converts straight quotes to curly quotes, dashes to em-dashes, etc.
|
* @file Translates plain ASCII punctuation characters into "smart" typographic punctuation
|
||||||
* Based on the original SmartyPants by John Gruber
|
* @desc See the readme for details, installation instructions, and * license information.
|
||||||
|
* @version 0.0.4
|
||||||
|
* @author othree
|
||||||
|
*
|
||||||
|
* Copyright (c) 2003-2004 John Gruber
|
||||||
|
* Copyright (c) 2016 Kao, Wei-Ko(othree)
|
||||||
|
*
|
||||||
|
* @see {@link https://www.npmjs.com/package/smartypants|smartypants.js}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SmartyPants = (function() {
|
/*! smartypants.js 0.0.6 | (c) Kao, Wei-Ko(othree) | github.com/othree/smartypants.js/blob/master/LICENSE */
|
||||||
// Regular expressions for matching
|
|
||||||
const quotes = {
|
(function (root, factory) {
|
||||||
double: {
|
if (typeof define === 'function' && define.amd) {
|
||||||
opening: /(\s|^)"(\w)/g,
|
// AMD
|
||||||
closing: /(\w)"/g,
|
define('SmartyPants', ['exports'], function (exports) {
|
||||||
openingNested: /(\s|^)'(\w)/g,
|
factory((root.SmartyPants = exports));
|
||||||
closingNested: /(\w)'/g
|
});
|
||||||
},
|
} else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
|
||||||
single: {
|
// CommonJS
|
||||||
opening: /(\s|^)'(\w)/g,
|
factory(exports);
|
||||||
closing: /(\w)'/g
|
} else {
|
||||||
|
// Browser globals
|
||||||
|
factory((root.SmartyPants = {}));
|
||||||
|
}
|
||||||
|
}(this, function (exports) {
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
exports.__esModule = true;
|
||||||
|
exports.smartypantsu = exports.smartellipses = exports.smartdashes = exports.smartquotes = exports.smartypants = void 0;
|
||||||
|
var tags_to_skip = /<(\/?)(?:pre|code|kbd|script|math)[^>]*>/i;
|
||||||
|
/**
|
||||||
|
* @param text text to be parsed
|
||||||
|
* @param attr value of the smart_quotes="" attribute
|
||||||
|
*/
|
||||||
|
var SmartyPants = function (text, attr) {
|
||||||
|
if (text === void 0) { text = ''; }
|
||||||
|
if (attr === void 0) { attr = '1'; }
|
||||||
|
var do_quotes;
|
||||||
|
var do_backticks;
|
||||||
|
var do_dashes;
|
||||||
|
var do_ellipses;
|
||||||
|
var do_stupefy;
|
||||||
|
var convert_quot = 0;
|
||||||
|
if (typeof attr === 'number') {
|
||||||
|
attr = attr.toString();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
attr = attr.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Parse attributes:
|
||||||
|
* 0 : do nothing
|
||||||
|
* 1 : set all
|
||||||
|
* 2 : set all, using old school en- and em- dash shortcuts
|
||||||
|
* 3 : set all, using inverted old school en and em- dash shortcuts
|
||||||
|
*
|
||||||
|
* q : quotes
|
||||||
|
* b : backtick quotes (``double'' only)
|
||||||
|
* B : backtick quotes (``double'' and `single')
|
||||||
|
* d : dashes
|
||||||
|
* D : old school dashes
|
||||||
|
* i : inverted old school dashes
|
||||||
|
* e : ellipses
|
||||||
|
* w : convert " entities to " for Dreamweaver users
|
||||||
|
*/
|
||||||
|
if (attr === '0') {
|
||||||
|
// Do nothing
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
else if (attr === '1') {
|
||||||
|
// Do everything, turn all options on.
|
||||||
|
do_quotes = 1;
|
||||||
|
do_backticks = 1;
|
||||||
|
do_dashes = 1;
|
||||||
|
do_ellipses = 1;
|
||||||
|
}
|
||||||
|
else if (attr === '2') {
|
||||||
|
// Do everything, turn all options on, use old school dash shorthand.
|
||||||
|
do_quotes = 1;
|
||||||
|
do_backticks = 1;
|
||||||
|
do_dashes = 2;
|
||||||
|
do_ellipses = 1;
|
||||||
|
}
|
||||||
|
else if (attr === '3') {
|
||||||
|
// Do everything, turn all options on, use inverted old school dash shorthand.
|
||||||
|
do_quotes = 1;
|
||||||
|
do_backticks = 1;
|
||||||
|
do_dashes = 3;
|
||||||
|
do_ellipses = 1;
|
||||||
|
}
|
||||||
|
else if (attr === '-1') {
|
||||||
|
// Special "stupefy" mode.
|
||||||
|
do_stupefy = 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (var i = 0; i < attr.length; i++) {
|
||||||
|
var c = attr[i];
|
||||||
|
if (c === 'q') {
|
||||||
|
do_quotes = 1;
|
||||||
|
}
|
||||||
|
if (c === 'b') {
|
||||||
|
do_backticks = 1;
|
||||||
|
}
|
||||||
|
if (c === 'B') {
|
||||||
|
do_backticks = 2;
|
||||||
|
}
|
||||||
|
if (c === 'd') {
|
||||||
|
do_dashes = 1;
|
||||||
|
}
|
||||||
|
if (c === 'D') {
|
||||||
|
do_dashes = 2;
|
||||||
|
}
|
||||||
|
if (c === 'i') {
|
||||||
|
do_dashes = 3;
|
||||||
|
}
|
||||||
|
if (c === 'e') {
|
||||||
|
do_ellipses = 1;
|
||||||
|
}
|
||||||
|
if (c === 'w') {
|
||||||
|
convert_quot = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var tokens = _tokenize(text);
|
||||||
|
var result = '';
|
||||||
|
/**
|
||||||
|
* Keep track of when we're inside <pre> or <code> tags.
|
||||||
|
*/
|
||||||
|
var in_pre = 0;
|
||||||
|
/**
|
||||||
|
* This is a cheat, used to get some context
|
||||||
|
* for one-character tokens that consist of
|
||||||
|
* just a quote char. What we do is remember
|
||||||
|
* the last character of the previous text
|
||||||
|
* token, to use as context to curl single-
|
||||||
|
* character quote tokens correctly.
|
||||||
|
*/
|
||||||
|
var prev_token_last_char = '';
|
||||||
|
for (var i = 0; i < tokens.length; i++) {
|
||||||
|
var cur_token = tokens[i];
|
||||||
|
if (cur_token[0] === 'tag') {
|
||||||
|
result = result + cur_token[1];
|
||||||
|
var matched = tags_to_skip.exec(cur_token[1]);
|
||||||
|
if (matched) {
|
||||||
|
if (matched[1] === '/') {
|
||||||
|
in_pre = 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
in_pre = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var t = cur_token[1];
|
||||||
|
var last_char = t.substring(t.length - 1, t.length); // Remember last char of this token before processing.
|
||||||
|
if (!in_pre) {
|
||||||
|
t = ProcessEscapes(t);
|
||||||
|
if (convert_quot) {
|
||||||
|
t = t.replace(/$quot;/g, '"');
|
||||||
|
}
|
||||||
|
if (do_dashes) {
|
||||||
|
if (do_dashes === 1) {
|
||||||
|
t = EducateDashes(t);
|
||||||
|
}
|
||||||
|
if (do_dashes === 2) {
|
||||||
|
t = EducateDashesOldSchool(t);
|
||||||
|
}
|
||||||
|
if (do_dashes === 3) {
|
||||||
|
t = EducateDashesOldSchoolInverted(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (do_ellipses) {
|
||||||
|
t = EducateEllipses(t);
|
||||||
|
}
|
||||||
|
// Note: backticks need to be processed before quotes.
|
||||||
|
if (do_backticks) {
|
||||||
|
t = EducateBackticks(t);
|
||||||
|
if (do_backticks === 2) {
|
||||||
|
t = EducateSingleBackticks(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (do_quotes) {
|
||||||
|
if (t === '\'') {
|
||||||
|
// Special case: single-character ' token
|
||||||
|
if (/\S/.test(prev_token_last_char)) {
|
||||||
|
t = '’';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
t = '‘';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (t === '"') {
|
||||||
|
// Special case: single-character " token
|
||||||
|
if (/\S/.test(prev_token_last_char)) {
|
||||||
|
t = '”';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
t = '“';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Normal case:
|
||||||
|
t = EducateQuotes(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (do_stupefy) {
|
||||||
|
t = StupefyEntities(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_token_last_char = last_char;
|
||||||
|
result = result + t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
exports.smartypants = SmartyPants;
|
||||||
|
var SmartQuotes = function (text, attr) {
|
||||||
|
/**
|
||||||
|
* should we educate ``backticks'' -style quotes?
|
||||||
|
*/
|
||||||
|
// var do_backticks:number;
|
||||||
|
if (text === void 0) { text = ''; }
|
||||||
|
if (attr === void 0) { attr = '1'; }
|
||||||
|
if (typeof attr === 'number') {
|
||||||
|
attr = attr.toString();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
attr = attr.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
if (attr === '0') {
|
||||||
|
// Do nothing
|
||||||
|
return text;
|
||||||
|
// } else if (attr === '2') {
|
||||||
|
// // smarten ``backticks'' -style quotes
|
||||||
|
// do_backticks = 1;
|
||||||
|
// } else {
|
||||||
|
// do_backticks = 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Special case to handle quotes at the very end of $text when preceded by
|
||||||
|
* an HTML tag. Add a space to give the quote education algorithm a bit of
|
||||||
|
* context, so that it can guess correctly that it's a closing quote:
|
||||||
|
*/
|
||||||
|
var add_extra_space = 0;
|
||||||
|
if (/>['"]$/.test(text)) {
|
||||||
|
add_extra_space = 1; // Remember, so we can trim the extra space later.
|
||||||
|
text = text + ' ';
|
||||||
|
}
|
||||||
|
var tokens = _tokenize(text);
|
||||||
|
var result = '';
|
||||||
|
/**
|
||||||
|
* Keep track of when we're inside <pre> or <code> tags.
|
||||||
|
*/
|
||||||
|
var in_pre = 0;
|
||||||
|
/**
|
||||||
|
* This is a cheat, used to get some context
|
||||||
|
* for one-character tokens that consist of
|
||||||
|
* just a quote char. What we do is remember
|
||||||
|
* the last character of the previous text
|
||||||
|
* token, to use as context to curl single-
|
||||||
|
* character quote tokens correctly.
|
||||||
|
*/
|
||||||
|
var prev_token_last_char = '';
|
||||||
|
for (var i = 0; i < tokens.length; i++) {
|
||||||
|
var cur_token = tokens[i];
|
||||||
|
if (cur_token[0] === 'tag') {
|
||||||
|
result = result + cur_token[1];
|
||||||
|
var matched = tags_to_skip.exec(cur_token[1]);
|
||||||
|
if (matched) {
|
||||||
|
if (matched[1] === '/') {
|
||||||
|
in_pre = 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
in_pre = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var t = cur_token[1];
|
||||||
|
var last_char = t.substring(t.length - 1, t.length); // Remember last char of this token before processing.
|
||||||
|
if (!in_pre) {
|
||||||
|
t = ProcessEscapes(t);
|
||||||
|
if (t === '\'') {
|
||||||
|
// Special case: single-character ' token
|
||||||
|
if (/\S/.test(prev_token_last_char)) {
|
||||||
|
t = '’';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
t = '‘';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (t === '"') {
|
||||||
|
// Special case: single-character " token
|
||||||
|
if (/\S/.test(prev_token_last_char)) {
|
||||||
|
t = '”';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
t = '“';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Normal case:
|
||||||
|
t = EducateQuotes(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_token_last_char = last_char;
|
||||||
|
result = result + t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (add_extra_space) {
|
||||||
|
result = result.replace(/ $/, '');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
exports.smartquotes = SmartQuotes;
|
||||||
|
var SmartDashes = function (text, attr) {
|
||||||
|
if (text === void 0) { text = ''; }
|
||||||
|
if (attr === void 0) { attr = '1'; }
|
||||||
|
// reference to the subroutine to use for dash education, default to EducateDashes:
|
||||||
|
var dash_sub_ref = EducateDashes;
|
||||||
|
if (typeof attr === 'number') {
|
||||||
|
attr = attr.toString();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
attr = attr.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
if (attr === '0') {
|
||||||
|
// Do nothing
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
else if (attr === '2') {
|
||||||
|
// use old smart dash shortcuts, "--" for en, "---" for em
|
||||||
|
dash_sub_ref = EducateDashesOldSchool;
|
||||||
|
}
|
||||||
|
else if (attr === '3') {
|
||||||
|
// inverse of 2, "--" for em, "---" for en
|
||||||
|
dash_sub_ref = EducateDashesOldSchoolInverted;
|
||||||
|
}
|
||||||
|
var tokens = _tokenize(text);
|
||||||
|
var result = '';
|
||||||
|
/**
|
||||||
|
* Keep track of when we're inside <pre> or <code> tags.
|
||||||
|
*/
|
||||||
|
var in_pre = 0;
|
||||||
|
for (var i = 0; i < tokens.length; i++) {
|
||||||
|
var cur_token = tokens[i];
|
||||||
|
if (cur_token[0] === 'tag') {
|
||||||
|
result = result + cur_token[1];
|
||||||
|
var matched = tags_to_skip.exec(cur_token[1]);
|
||||||
|
if (matched) {
|
||||||
|
if (matched[1] === '/') {
|
||||||
|
in_pre = 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
in_pre = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var t = cur_token[1];
|
||||||
|
if (!in_pre) {
|
||||||
|
t = ProcessEscapes(t);
|
||||||
|
t = dash_sub_ref(t);
|
||||||
|
}
|
||||||
|
result = result + t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
exports.smartdashes = SmartDashes;
|
||||||
|
var SmartEllipses = function (text, attr) {
|
||||||
|
if (text === void 0) { text = ''; }
|
||||||
|
if (attr === void 0) { attr = '1'; }
|
||||||
|
if (typeof attr === 'number') {
|
||||||
|
attr = attr.toString();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
attr = attr.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
if (attr === '0') {
|
||||||
|
// Do nothing
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
var tokens = _tokenize(text);
|
||||||
|
var result = '';
|
||||||
|
/**
|
||||||
|
* Keep track of when we're inside <pre> or <code> tags.
|
||||||
|
*/
|
||||||
|
var in_pre = 0;
|
||||||
|
for (var i = 0; i < tokens.length; i++) {
|
||||||
|
var cur_token = tokens[i];
|
||||||
|
if (cur_token[0] === 'tag') {
|
||||||
|
result = result + cur_token[1];
|
||||||
|
var matched = tags_to_skip.exec(cur_token[1]);
|
||||||
|
if (matched) {
|
||||||
|
if (matched[1] === '/') {
|
||||||
|
in_pre = 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
in_pre = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var t = cur_token[1];
|
||||||
|
if (!in_pre) {
|
||||||
|
t = ProcessEscapes(t);
|
||||||
|
t = EducateEllipses(t);
|
||||||
|
}
|
||||||
|
result = result + t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
exports.smartellipses = SmartEllipses;
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with "educated" curly quote HTML entities.
|
||||||
|
*
|
||||||
|
* Example input: "Isn't this fun?"
|
||||||
|
* Example output: “Isn’t this fun?”
|
||||||
|
*/
|
||||||
|
var EducateQuotes = function (str) {
|
||||||
|
/**
|
||||||
|
* Make our own "punctuation" character class, because the POSIX-style
|
||||||
|
* [:PUNCT:] is only available in Perl 5.6 or later:
|
||||||
|
*
|
||||||
|
* JavaScript don't have punctuation class neither.
|
||||||
|
*/
|
||||||
|
var punct_class = '[!"#\$\%\'()*+,-./:;<=>?\@\[\\\]\^_`{|}~]'; // eslint-disable-line no-useless-escape
|
||||||
|
/**
|
||||||
|
* Special case if the very first character is a quote
|
||||||
|
* followed by punctuation at a non-word-break. Close the quotes by brute force:
|
||||||
|
*/
|
||||||
|
str = str.replace(new RegExp("^'(?=" + punct_class + "\\B)"), '’'); // eslint-disable-line no-useless-escape
|
||||||
|
str = str.replace(new RegExp("^\"(?=" + punct_class + "\\B)"), '”'); // eslint-disable-line no-useless-escape
|
||||||
|
/**
|
||||||
|
* Special case for double sets of quotes, e.g.:
|
||||||
|
* <p>He said, "'Quoted' words in a larger quote."</p>
|
||||||
|
*/
|
||||||
|
str = str.replace(/"'(?=\w)/, '“‘');
|
||||||
|
str = str.replace(/'"(?=\w)/, '‘“');
|
||||||
|
/**
|
||||||
|
* Special case for decade abbreviations (the '80s):
|
||||||
|
*/
|
||||||
|
str = str.replace(/'(?=\d\d)/, '’');
|
||||||
|
var close_class = '[^\\ \\t\\r\\n\\[\\{\\(\\-]'; // eslint-disable-line no-useless-escape
|
||||||
|
var not_close_class = '[\\ \\t\\r\\n\\[\\{\\(\\-]'; // eslint-disable-line no-useless-escape
|
||||||
|
var dec_dashes = '–|—';
|
||||||
|
/**
|
||||||
|
* Get most opening single quotes:
|
||||||
|
* s {
|
||||||
|
* (
|
||||||
|
* \s | # a whitespace char, or
|
||||||
|
* | # a non-breaking space entity, or
|
||||||
|
* -- | # dashes, or
|
||||||
|
* &[mn]dash; | # named dash entities
|
||||||
|
* $dec_dashes | # or decimal entities
|
||||||
|
* &\#x201[34]; # or hex
|
||||||
|
* )
|
||||||
|
* ' # the quote
|
||||||
|
* (?=\w) # followed by a word character
|
||||||
|
* } {$1‘}xg;
|
||||||
|
*/
|
||||||
|
str = str.replace(new RegExp("(\\s| |--|&[mn]dash;|" + dec_dashes + "|ȁ[34])'(?=\\w)", 'g'), '\$1‘'); // eslint-disable-line no-useless-escape
|
||||||
|
/**
|
||||||
|
* Single closing quotes:
|
||||||
|
* s {
|
||||||
|
* ($close_class)?
|
||||||
|
* '
|
||||||
|
* (?(1)| # If $1 captured, then do nothing;
|
||||||
|
* (?=\s | s\b) # otherwise, positive lookahead for a whitespace
|
||||||
|
* ) # char or an 's' at a word ending position. This
|
||||||
|
* # is a special case to handle something like:
|
||||||
|
* # "<i>Custer</i>'s Last Stand."
|
||||||
|
* } {$1’}xgi;
|
||||||
|
*/
|
||||||
|
str = str.replace(new RegExp("(" + close_class + ")'", 'g'), '\$1’'); // eslint-disable-line no-useless-escape
|
||||||
|
str = str.replace(new RegExp("(" + not_close_class + "?)'(?=\\s|s\\b)", 'g'), '\$1’'); // eslint-disable-line no-useless-escape
|
||||||
|
/**
|
||||||
|
* Any remaining single quotes should be opening ones:
|
||||||
|
*/
|
||||||
|
str = str.replace(/'/g, '‘');
|
||||||
|
/**
|
||||||
|
* Get most opening double quotes:
|
||||||
|
* s {
|
||||||
|
* (
|
||||||
|
* \s | # a whitespace char, or
|
||||||
|
* | # a non-breaking space entity, or
|
||||||
|
* -- | # dashes, or
|
||||||
|
* &[mn]dash; | # named dash entities
|
||||||
|
* $dec_dashes | # or decimal entities
|
||||||
|
* &\#x201[34]; # or hex
|
||||||
|
* )
|
||||||
|
* " # the quote
|
||||||
|
* (?=\w) # followed by a word character
|
||||||
|
* } {$1“}xg;
|
||||||
|
*/
|
||||||
|
str = str.replace(new RegExp("(\\s| |--|&[mn]dash;|" + dec_dashes + "|ȁ[34])\"(?=\\w)", 'g'), '\$1“'); // eslint-disable-line no-useless-escape
|
||||||
|
/**
|
||||||
|
* Double closing quotes:
|
||||||
|
* s {
|
||||||
|
* ($close_class)?
|
||||||
|
* "
|
||||||
|
* (?(1)|(?=\s)) # If $1 captured, then do nothing;
|
||||||
|
* # if not, then make sure the next char is whitespace.
|
||||||
|
* } {$1”}xg;
|
||||||
|
*/
|
||||||
|
str = str.replace(new RegExp("(" + close_class + ")\"", 'g'), '\$1”'); // eslint-disable-line no-useless-escape
|
||||||
|
str = str.replace(new RegExp("(" + not_close_class + "?)\"(?=\\s)", 'g'), '\$1”'); // eslint-disable-line no-useless-escape
|
||||||
|
/**
|
||||||
|
* Any remaining quotes should be opening ones.
|
||||||
|
*/
|
||||||
|
str = str.replace(/"/g, '“');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with ``backticks'' -style double quotes
|
||||||
|
* translated into HTML curly quote entities.
|
||||||
|
*
|
||||||
|
* Example input: ``Isn't this fun?''
|
||||||
|
* Example output: “Isn't this fun?”
|
||||||
|
*/
|
||||||
|
var EducateBackticks = function (str) {
|
||||||
|
str = str.replace(/``/g, '“');
|
||||||
|
str = str.replace(/''/g, '”');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with `backticks' -style single quotes
|
||||||
|
* translated into HTML curly quote entities.
|
||||||
|
*
|
||||||
|
* Example input: `Isn't this fun?'
|
||||||
|
* Example output: ‘Isn’t this fun?’
|
||||||
|
*/
|
||||||
|
var EducateSingleBackticks = function (str) {
|
||||||
|
str = str.replace(/`/g, '‘');
|
||||||
|
str = str.replace(/'/g, '’');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with each instance of "--" translated to
|
||||||
|
* an em-dash HTML entity.
|
||||||
|
*/
|
||||||
|
var EducateDashes = function (str) {
|
||||||
|
str = str.replace(/--/g, '—');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with each instance of "--" translated to
|
||||||
|
* an en-dash HTML entity, and each "---" translated to
|
||||||
|
* an em-dash HTML entity.
|
||||||
|
*/
|
||||||
|
var EducateDashesOldSchool = function (str) {
|
||||||
|
str = str.replace(/---/g, '—');
|
||||||
|
str = str.replace(/--/g, '–');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with each instance of "--" translated to
|
||||||
|
* an em-dash HTML entity, and each "---" translated to
|
||||||
|
* an en-dash HTML entity. Two reasons why: First, unlike the
|
||||||
|
* en- and em-dash syntax supported by
|
||||||
|
* EducateDashesOldSchool(), it's compatible with existing
|
||||||
|
* entries written before SmartyPants 1.1, back when "--" was
|
||||||
|
* only used for em-dashes. Second, em-dashes are more
|
||||||
|
* common than en-dashes, and so it sort of makes sense that
|
||||||
|
* the shortcut should be shorter to type. (Thanks to Aaron
|
||||||
|
* Swartz for the idea.)
|
||||||
|
*/
|
||||||
|
var EducateDashesOldSchoolInverted = function (str) {
|
||||||
|
str = str.replace(/---/g, '–');
|
||||||
|
str = str.replace(/--/g, '—');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with each instance of "..." translated to
|
||||||
|
* an ellipsis HTML entity. Also converts the case where
|
||||||
|
* there are spaces between the dots.
|
||||||
|
*
|
||||||
|
* Example input: Huh...?
|
||||||
|
* Example output: Huh…?
|
||||||
|
*/
|
||||||
|
var EducateEllipses = function (str) {
|
||||||
|
str = str.replace(/\.\.\./g, '…');
|
||||||
|
str = str.replace(/\. \. \./g, '…');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with each SmartyPants HTML entity translated to
|
||||||
|
* its ASCII counterpart.
|
||||||
|
*
|
||||||
|
* Example input: “Hello — world.”
|
||||||
|
* Example output: "Hello -- world."
|
||||||
|
*/
|
||||||
|
var StupefyEntities = function (str) {
|
||||||
|
str = str.replace(/–/g, '-'); // en-dash
|
||||||
|
str = str.replace(/—/g, '--'); // em-dash
|
||||||
|
str = str.replace(/‘/g, '\''); // open single quote
|
||||||
|
str = str.replace(/’/g, '\''); // close single quote
|
||||||
|
str = str.replace(/“/g, '"'); // open double quote
|
||||||
|
str = str.replace(/”/g, '"'); // close double quote
|
||||||
|
str = str.replace(/…/g, '...'); // ellipsis
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with each SmartyPants HTML entity translated to
|
||||||
|
* UTF-8 characters.
|
||||||
|
*
|
||||||
|
* Example input: “Hello ’ world.”
|
||||||
|
* Example output: "Hello — world."
|
||||||
|
*/
|
||||||
|
var EducateEntities = function (text, attr) {
|
||||||
|
if (attr === void 0) { attr = '1'; }
|
||||||
|
var do_quotes;
|
||||||
|
var do_backticks;
|
||||||
|
var do_dashes;
|
||||||
|
var do_ellipses;
|
||||||
|
// var do_stupefy:number;
|
||||||
|
if (typeof attr === 'number') {
|
||||||
|
attr = attr.toString();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
attr = attr.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
if (attr === '0') {
|
||||||
|
// Do nothing
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
else if (attr === '1') {
|
||||||
|
// Do everything, turn all options on.
|
||||||
|
do_quotes = 1;
|
||||||
|
do_backticks = 1;
|
||||||
|
do_dashes = 1;
|
||||||
|
do_ellipses = 1;
|
||||||
|
}
|
||||||
|
else if (attr === '2') {
|
||||||
|
// Do everything, turn all options on, use old school dash shorthand.
|
||||||
|
do_quotes = 1;
|
||||||
|
do_backticks = 1;
|
||||||
|
do_dashes = 3;
|
||||||
|
do_ellipses = 1;
|
||||||
|
}
|
||||||
|
else if (attr === '3') {
|
||||||
|
// Do everything, turn all options on, use inverted old school dash shorthand.
|
||||||
|
do_quotes = 1;
|
||||||
|
do_backticks = 1;
|
||||||
|
do_dashes = 3;
|
||||||
|
do_ellipses = 1;
|
||||||
|
// } else if (attr === '-1') {
|
||||||
|
// // Special "stupefy" mode.
|
||||||
|
// do_stupefy = 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (var i = 0; i < attr.length; i++) {
|
||||||
|
var c = attr[i];
|
||||||
|
if (c === 'q') {
|
||||||
|
do_quotes = 1;
|
||||||
|
}
|
||||||
|
if (c === 'b') {
|
||||||
|
do_backticks = 1;
|
||||||
|
}
|
||||||
|
if (c === 'B') {
|
||||||
|
do_backticks = 2;
|
||||||
|
}
|
||||||
|
if (c === 'd') {
|
||||||
|
do_dashes = 1;
|
||||||
|
}
|
||||||
|
if (c === 'D') {
|
||||||
|
do_dashes = 2;
|
||||||
|
}
|
||||||
|
if (c === 'i') {
|
||||||
|
do_dashes = 3;
|
||||||
|
}
|
||||||
|
if (c === 'e') {
|
||||||
|
do_ellipses = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (do_dashes) {
|
||||||
|
text = text.replace(/–/g, '\u2013'); // en-dash
|
||||||
|
text = text.replace(/—/g, '\u2014'); // em-dash
|
||||||
|
}
|
||||||
|
if (do_quotes || do_backticks) {
|
||||||
|
if(locale === 'de') {
|
||||||
|
text = text.replace(/“/g, '\u00bb'); // open double quote
|
||||||
|
text = text.replace(/”/g, '\u00ab'); // close double quote
|
||||||
|
} else {
|
||||||
|
text = text.replace(/“/g, '\u201c'); // open double quote
|
||||||
|
text = text.replace(/”/g, '\u201d'); // close double quote
|
||||||
|
}
|
||||||
|
text = text.replace(/‘/g, '\u2018'); // open single quote
|
||||||
|
text = text.replace(/’/g, '\u2019'); // close single quote
|
||||||
|
}
|
||||||
|
if (do_ellipses) {
|
||||||
|
text = text.replace(/…/g, '\u2026'); // ellipsis
|
||||||
|
}
|
||||||
|
// Do markdown bold and italics
|
||||||
|
text = text.replace(/\*\*(.*)\*\*/gim, '<b>$1</b>') // bold text
|
||||||
|
text = text.replace(/\_(.*)\_/gim, '<i>$1</i>'); // italic text
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} The string, with each SmartyPants UTF-8 chars translated to
|
||||||
|
* its ASCII counterpart.
|
||||||
|
*
|
||||||
|
* Example input: “Hello — world.”
|
||||||
|
* Example output: "Hello -- world."
|
||||||
|
*/
|
||||||
|
var StupifyUTF8Char = function (str) {
|
||||||
|
str = str.replace(/\u2013/g, '-'); // en-dash
|
||||||
|
str = str.replace(/\u2014/g, '--'); // em-dash
|
||||||
|
str = str.replace(/\u2018/g, '\''); // open single quote
|
||||||
|
str = str.replace(/\u2019/g, '\''); // close single quote
|
||||||
|
str = str.replace(/\u201c/g, '"'); // open double quote
|
||||||
|
str = str.replace(/\u201d/g, '"'); // close double quote
|
||||||
|
str = str.replace(/\u2026/g, '...'); // ellipsis
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String
|
||||||
|
* @return {string} string, with after processing the following backslash
|
||||||
|
* escape sequences. This is useful if you want to force a "dumb"
|
||||||
|
* quote or other character to appear.
|
||||||
|
*
|
||||||
|
* Escape Value
|
||||||
|
* ------ -----
|
||||||
|
* \\ \
|
||||||
|
* \" "
|
||||||
|
* \' '
|
||||||
|
* \. .
|
||||||
|
* \- -
|
||||||
|
* \` `
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
var ProcessEscapes = function (str) {
|
||||||
|
str = str.replace(/\\\\/g, '\');
|
||||||
|
str = str.replace(/\\"/g, '"');
|
||||||
|
str = str.replace(/\\'/g, ''');
|
||||||
|
str = str.replace(/\\\./g, '.');
|
||||||
|
str = str.replace(/\\-/g, '-');
|
||||||
|
str = str.replace(/\\`/g, '`');
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @param {string} str String containing HTML markup.
|
||||||
|
* @return {Array<token>} Reference to an array of the tokens comprising the input
|
||||||
|
* string. Each token is either a tag (possibly with nested,
|
||||||
|
* tags contained therein, such as <a href="<MTFoo>">, or a
|
||||||
|
* run of text between tags. Each element of the array is a
|
||||||
|
* two-element array; the first is either 'tag' or 'text';
|
||||||
|
* the second is the actual value.
|
||||||
|
*
|
||||||
|
* Based on the _tokenize() subroutine from Brad Choate's MTRegex plugin.
|
||||||
|
* <http://www.bradchoate.com/past/mtregex.php>
|
||||||
|
*/
|
||||||
|
var _tokenize = function (str) {
|
||||||
|
var pos = 0;
|
||||||
|
var len = str.length;
|
||||||
|
var tokens = [];
|
||||||
|
var match = /<!--[\s\S]*?-->|<\?.*?\?>|<[^>]*>/g;
|
||||||
|
var matched = null;
|
||||||
|
while (matched = match.exec(str)) { // eslint-disable-line no-cond-assign
|
||||||
|
if (pos < matched.index) {
|
||||||
|
var t_1 = ['text', str.substring(pos, matched.index)];
|
||||||
|
tokens.push(t_1);
|
||||||
|
}
|
||||||
|
var t = ['tag', matched.toString()];
|
||||||
|
tokens.push(t);
|
||||||
|
pos = match.lastIndex;
|
||||||
|
}
|
||||||
|
if (pos < len) {
|
||||||
|
var t = ['text', str.substring(pos, len)];
|
||||||
|
tokens.push(t);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
var smartypantsu = function (text, attr) {
|
||||||
|
if (text === void 0) { text = ''; }
|
||||||
|
if (attr === void 0) { attr = '1'; }
|
||||||
|
var str = SmartyPants(text, attr);
|
||||||
|
if (typeof attr === 'number') {
|
||||||
|
attr = attr.toString();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
attr = attr.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
if (attr === '-1') {
|
||||||
|
return StupifyUTF8Char(str);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return EducateEntities(str, attr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
exports.smartypantsu = smartypantsu;
|
||||||
|
exports["default"] = SmartyPants;
|
||||||
|
|
||||||
const dashes = {
|
|
||||||
emDash: /--/g,
|
|
||||||
enDash: / - /g
|
|
||||||
};
|
|
||||||
|
|
||||||
const ellipses = /\.\.\./g;
|
}));
|
||||||
|
|
||||||
/**
|
|
||||||
* Process text with SmartyPants transformations
|
|
||||||
*/
|
|
||||||
function process(text) {
|
|
||||||
if (!text) return text;
|
|
||||||
|
|
||||||
let result = text;
|
|
||||||
|
|
||||||
// Transform double quotes
|
|
||||||
result = result.replace(quotes.double.opening, '$1"$2');
|
|
||||||
result = result.replace(quotes.double.closing, '$1"');
|
|
||||||
|
|
||||||
// Transform single quotes
|
|
||||||
result = result.replace(quotes.single.opening, '$1\u2018$2');
|
|
||||||
result = result.replace(quotes.single.closing, '$1\u2019');
|
|
||||||
|
|
||||||
// Transform apostrophes (same as closing single quotes)
|
|
||||||
result = result.replace(/(\w)'(\w)/g, '$1\u2019$2');
|
|
||||||
|
|
||||||
// Transform dashes
|
|
||||||
result = result.replace(dashes.emDash, '—');
|
|
||||||
result = result.replace(dashes.enDash, ' – ');
|
|
||||||
|
|
||||||
// Transform ellipses
|
|
||||||
result = result.replace(ellipses, '…');
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
process: process
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Make available in browser and Node.js environments
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = SmartyPants;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* TTS Factory for AI Interactive Fiction
|
||||||
|
* Attempts to use Kokoro TTS first, then falls back to browser TTS if needed
|
||||||
|
*/
|
||||||
|
|
||||||
|
class TTSFactory {
|
||||||
|
constructor() {
|
||||||
|
this.activeTTSHandler = null;
|
||||||
|
this.kokoroHandler = null;
|
||||||
|
this.browserTTSHandler = null;
|
||||||
|
this.initializationAttempted = false;
|
||||||
|
this.usingKokoro = false;
|
||||||
|
this.initializationPromise = null; // Promise for the factory initialization
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => this.initialize());
|
||||||
|
} else {
|
||||||
|
// Use requestAnimationFrame to ensure scripts are parsed
|
||||||
|
requestAnimationFrame(() => this.initialize());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize available TTS handlers
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (this.initializationAttempted) return this.initializationPromise;
|
||||||
|
this.initializationAttempted = true;
|
||||||
|
|
||||||
|
console.log('Initializing TTS Factory...');
|
||||||
|
|
||||||
|
this.initializationPromise = new Promise(async (resolve) => {
|
||||||
|
let kokoroInitialized = false;
|
||||||
|
// Try to initialize Kokoro first (preferred option)
|
||||||
|
try {
|
||||||
|
// Check if KokoroHandler class is defined (loaded via script tag)
|
||||||
|
if (typeof KokoroHandler !== 'undefined') {
|
||||||
|
console.log('Attempting to initialize Kokoro TTS...');
|
||||||
|
this.kokoroHandler = new KokoroHandler();
|
||||||
|
|
||||||
|
// --- Increase Timeout for Kokoro Initialization ---
|
||||||
|
// Wait for KokoroHandler's internal initialization promise
|
||||||
|
// Use Promise.race to add a longer timeout specifically for Kokoro init
|
||||||
|
const kokoroTimeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Kokoro initialization timed out in factory')), 60000) // 60 seconds timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
kokoroInitialized = await Promise.race([
|
||||||
|
this.kokoroHandler.initializationPromise,
|
||||||
|
kokoroTimeoutPromise
|
||||||
|
]);
|
||||||
|
} catch (timeoutError) {
|
||||||
|
console.error(timeoutError.message); // Log the timeout error
|
||||||
|
kokoroInitialized = false;
|
||||||
|
}
|
||||||
|
// --- End Increase Timeout ---
|
||||||
|
|
||||||
|
if (kokoroInitialized) {
|
||||||
|
console.log('Kokoro Handler reported successful initialization.');
|
||||||
|
} else {
|
||||||
|
console.warn('Kokoro Handler reported failed or timed out initialization.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('KokoroHandler class not found when factory initialized.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating or initializing Kokoro Handler:', error);
|
||||||
|
kokoroInitialized = false; // Ensure it's marked as failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize browser TTS as fallback (can happen in parallel)
|
||||||
|
try {
|
||||||
|
if (typeof TTSHandler !== 'undefined') {
|
||||||
|
console.log('Initializing browser TTS as fallback...');
|
||||||
|
this.browserTTSHandler = new TTSHandler();
|
||||||
|
} else {
|
||||||
|
console.warn('TTSHandler class not found when factory initialized.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing browser TTS:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide which handler to use based on Kokoro's success
|
||||||
|
this.selectActiveHandler(kokoroInitialized);
|
||||||
|
resolve(); // Resolve the factory's promise
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed waitForKokoroInitialization as KokoroHandler now manages its own promise
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select which TTS handler to use
|
||||||
|
* @param {boolean} kokoroInitialized - Whether Kokoro initialization succeeded
|
||||||
|
*/
|
||||||
|
selectActiveHandler(kokoroInitialized) {
|
||||||
|
// First choice: Kokoro if it's available and initialized successfully
|
||||||
|
if (kokoroInitialized && this.kokoroHandler && this.kokoroHandler.kokoroReady) {
|
||||||
|
console.log('Using Kokoro TTS as primary TTS system');
|
||||||
|
this.activeTTSHandler = this.kokoroHandler;
|
||||||
|
this.usingKokoro = true;
|
||||||
|
}
|
||||||
|
// Fallback to browser TTS if available
|
||||||
|
else if (this.browserTTSHandler) {
|
||||||
|
console.log('Falling back to browser TTS.');
|
||||||
|
this.activeTTSHandler = this.browserTTSHandler;
|
||||||
|
this.usingKokoro = false;
|
||||||
|
}
|
||||||
|
// No TTS available
|
||||||
|
else {
|
||||||
|
console.error('No TTS system available.');
|
||||||
|
this.activeTTSHandler = null;
|
||||||
|
this.usingKokoro = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose the active handler as the global ttsHandler
|
||||||
|
window.ttsHandler = this.activeTTSHandler;
|
||||||
|
|
||||||
|
// Log the active TTS system
|
||||||
|
if (this.usingKokoro) {
|
||||||
|
console.log('TTS Factory initialized with Kokoro TTS');
|
||||||
|
} else if (this.activeTTSHandler) {
|
||||||
|
console.log('TTS Factory initialized with browser TTS');
|
||||||
|
} else {
|
||||||
|
console.log('TTS Factory initialized with no available TTS');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch an event to notify the UI that TTS is ready (or not)
|
||||||
|
const ttsReadyEvent = new CustomEvent('tts-ready', {
|
||||||
|
detail: {
|
||||||
|
available: !!this.activeTTSHandler,
|
||||||
|
type: this.usingKokoro ? 'kokoro' : (this.activeTTSHandler ? 'browser' : 'none'),
|
||||||
|
handler: this.activeTTSHandler
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(ttsReadyEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get info about the active TTS system
|
||||||
|
*/
|
||||||
|
getActiveTTSInfo() {
|
||||||
|
if (!this.activeTTSHandler) {
|
||||||
|
return { available: false, type: 'none', name: 'None' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
type: this.usingKokoro ? 'kokoro' : 'browser',
|
||||||
|
name: this.usingKokoro ? 'Kokoro TTS' : 'Browser TTS'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force switching to a specific TTS system
|
||||||
|
* @param {string} type - Either 'kokoro' or 'browser'
|
||||||
|
*/
|
||||||
|
switchTTS(type) {
|
||||||
|
if (type === 'kokoro' && this.kokoroHandler && this.kokoroHandler.kokoroReady) {
|
||||||
|
this.activeTTSHandler = this.kokoroHandler;
|
||||||
|
this.usingKokoro = true;
|
||||||
|
window.ttsHandler = this.activeTTSHandler;
|
||||||
|
console.log('Switched to Kokoro TTS');
|
||||||
|
// Dispatch event on switch
|
||||||
|
const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'kokoro', handler: this.activeTTSHandler } });
|
||||||
|
window.dispatchEvent(ttsReadyEvent);
|
||||||
|
return true;
|
||||||
|
} else if (type === 'browser' && this.browserTTSHandler) {
|
||||||
|
this.activeTTSHandler = this.browserTTSHandler;
|
||||||
|
this.usingKokoro = false;
|
||||||
|
window.ttsHandler = this.activeTTSHandler;
|
||||||
|
console.log('Switched to browser TTS');
|
||||||
|
// Dispatch event on switch
|
||||||
|
const ttsReadyEvent = new CustomEvent('tts-ready', { detail: { available: true, type: 'browser', handler: this.activeTTSHandler } });
|
||||||
|
window.dispatchEvent(ttsReadyEvent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Failed to switch to ${type} TTS - not available`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the global TTS factory instance
|
||||||
|
window.ttsFactory = new TTSFactory();
|
||||||
@@ -18,6 +18,9 @@ class TTSHandler {
|
|||||||
this.speakQueue = [];
|
this.speakQueue = [];
|
||||||
this.isSpeakingFromQueue = false;
|
this.isSpeakingFromQueue = false;
|
||||||
|
|
||||||
|
// Flag to track when we're deliberately stopping speech
|
||||||
|
this.intentionalStop = false;
|
||||||
|
|
||||||
// Initialize if speech synthesis is available
|
// Initialize if speech synthesis is available
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
this.synth = window.speechSynthesis;
|
this.synth = window.speechSynthesis;
|
||||||
@@ -242,10 +245,15 @@ class TTSHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
utterance.onerror = (event) => {
|
utterance.onerror = (event) => {
|
||||||
console.error("Speech synthesis error:", event);
|
// Don't treat interrupted errors as real errors when we're deliberately stopping
|
||||||
if (event.error === "not-allowed") {
|
if (event.error === "interrupted" && this.intentionalStop) {
|
||||||
this.permissionError = true;
|
console.log("Speech intentionally interrupted");
|
||||||
this.enabled = false;
|
} else {
|
||||||
|
console.error("Speech synthesis error:", event);
|
||||||
|
if (event.error === "not-allowed") {
|
||||||
|
this.permissionError = true;
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onEndCallback) onEndCallback();
|
if (onEndCallback) onEndCallback();
|
||||||
@@ -368,12 +376,23 @@ class TTSHandler {
|
|||||||
stop() {
|
stop() {
|
||||||
if (!this.synth) return;
|
if (!this.synth) return;
|
||||||
|
|
||||||
|
// Set flag to indicate this is an intentional stop before canceling
|
||||||
|
this.intentionalStop = true;
|
||||||
|
|
||||||
|
// Cancel any current speech synthesis
|
||||||
this.synth.cancel();
|
this.synth.cancel();
|
||||||
|
|
||||||
|
// Reset state
|
||||||
this.speaking = false;
|
this.speaking = false;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
this.utterance = null;
|
this.utterance = null;
|
||||||
this.speakQueue = [];
|
this.speakQueue = [];
|
||||||
this.isSpeakingFromQueue = false;
|
this.isSpeakingFromQueue = false;
|
||||||
|
|
||||||
|
// Reset the intentional stop flag after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.intentionalStop = false;
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1034
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -20,7 +20,7 @@ const server = http.createServer(app);
|
|||||||
const io = new SocketIOServer(server);
|
const io = new SocketIOServer(server);
|
||||||
|
|
||||||
// Get port from environment variables or use default
|
// Get port from environment variables or use default
|
||||||
const DEFAULT_PORT = 3000;
|
const DEFAULT_PORT = 3001;
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
||||||
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
|
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user